feat(kb): wire kb-block fade into before_prompt_build
The previous commit shipped renderFaded + tick + seed on Block but left them dead code because openclaw's `before_prompt_build` hook ctx doesn't carry a currentTurn signal — the hook called plain render() and turnFor() returned 0, so add() always stamped last_refresh_at_turn=0 and fade distance never moved. This adds a small per-session turn tracker (tools/turn-tracker.ts) that the hook bumps each fire. On the first bump after plugin start the counter is seeded from max(entry.last_refresh_at_turn) on the loaded block, so a restart doesn't regress accumulated fade for surviving entries. dynamic-kb-cache now reads currentTurnForSession via the same counter so a fact cached on turn N reads back at d=0 on turn N+1. The hook now: bumps turn, ticks (drop entries past the m% threshold, log + save when anything drops), and renderFaded() into appendSystemContext. Same algorithm as the Plexum-side RenderDynamicSubblock path. KBDeps.turnFor signature changed from (agentId) → (sessionId) since turn is session-scoped — fixed the one cache-tool call site. Verified end-to-end with a standalone smoke replaying the full hook+tool flow against a temp profile: stale entry (d=201) drops on turn 1, fresh entry (d=1) survives untouched, restart re-seeds the counter from the surviving entry's last_refresh.
This commit is contained in:
@@ -21,6 +21,8 @@ import type { OpenClawAgentInfo } from './core/openclaw-agents.js';
|
|||||||
import { registerGatewayStartHook } from './hooks/gateway-start.js';
|
import { registerGatewayStartHook } from './hooks/gateway-start.js';
|
||||||
import { registerGatewayStopHook } from './hooks/gateway-stop.js';
|
import { registerGatewayStopHook } from './hooks/gateway-stop.js';
|
||||||
import { Block as KBBlock } from './tools/kbblock.js';
|
import { Block as KBBlock } from './tools/kbblock.js';
|
||||||
|
import { defaultFadeParams } from './tools/fade.js';
|
||||||
|
import { bumpTurnForSession, currentTurnForSession } from './tools/turn-tracker.js';
|
||||||
import { KBClient } from './tools/kbclient.js';
|
import { KBClient } from './tools/kbclient.js';
|
||||||
import {
|
import {
|
||||||
type KBDeps,
|
type KBDeps,
|
||||||
@@ -978,7 +980,7 @@ function register(api: PluginAPI): void {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
makeClient: (token: string) => new KBClient(kbBackendUrl, token),
|
makeClient: (token: string) => new KBClient(kbBackendUrl, token),
|
||||||
turnFor: () => 0,
|
turnFor: (sessionId: string) => currentTurnForSession(sessionId),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Wrap each KB tool factory: pass ctx into execute so cache/evict can
|
// Wrap each KB tool factory: pass ctx into execute so cache/evict can
|
||||||
@@ -1003,7 +1005,12 @@ function register(api: PluginAPI): void {
|
|||||||
// <kb-block> subblock injection via before_prompt_build hook
|
// <kb-block> subblock injection via before_prompt_build hook
|
||||||
// (DESIGN-DYNAMIC-BLOCK.md §2 + §7: openclaw side uses
|
// (DESIGN-DYNAMIC-BLOCK.md §2 + §7: openclaw side uses
|
||||||
// appendSystemContext since the hook can't replace baked-in
|
// appendSystemContext since the hook can't replace baked-in
|
||||||
// <available_skills> precisely). Empty block → no append.
|
// <available_skills> precisely).
|
||||||
|
//
|
||||||
|
// Per-turn fade: bump the session turn counter, then tick
|
||||||
|
// (dropping entries past the m% threshold) + renderFaded so the
|
||||||
|
// rendered text shows accumulated underscore masking. The Plexum
|
||||||
|
// mirror runs the same algorithm inside RenderDynamicSubblock.
|
||||||
const apiOn = (api as any).on;
|
const apiOn = (api as any).on;
|
||||||
if (typeof apiOn === 'function') {
|
if (typeof apiOn === 'function') {
|
||||||
apiOn.call(
|
apiOn.call(
|
||||||
@@ -1016,7 +1023,16 @@ function register(api: PluginAPI): void {
|
|||||||
let body = '';
|
let body = '';
|
||||||
try {
|
try {
|
||||||
const block = KBBlock.open(agentId, sessionId);
|
const block = KBBlock.open(agentId, sessionId);
|
||||||
body = block.render();
|
const turn = bumpTurnForSession(sessionId, block);
|
||||||
|
const fadeParams = defaultFadeParams();
|
||||||
|
const dropped = block.tick(turn, fadeParams);
|
||||||
|
if (dropped.length > 0) {
|
||||||
|
logger.info(
|
||||||
|
`kb-block fade tick agent=${agentId} session=${sessionId} dropped=[${dropped.join(',')}]`,
|
||||||
|
);
|
||||||
|
block.save();
|
||||||
|
}
|
||||||
|
body = block.renderFaded(turn, fadeParams);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn(`kb-block render failed: ${err}`);
|
logger.warn(`kb-block render failed: ${err}`);
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
@@ -39,8 +39,13 @@ export interface KBDeps {
|
|||||||
tokenFor: ((agentId: string) => Promise<string | null>) | null;
|
tokenFor: ((agentId: string) => Promise<string | null>) | null;
|
||||||
/** Build new client given a token (so each call gets fresh per-agent client). */
|
/** Build new client given a token (so each call gets fresh per-agent client). */
|
||||||
makeClient: ((token: string) => KBClient) | null;
|
makeClient: ((token: string) => KBClient) | null;
|
||||||
/** Best-effort current turn number; 0 is acceptable. */
|
/**
|
||||||
turnFor: (agentId: string) => number;
|
* Best-effort current turn number for a session; 0 is acceptable.
|
||||||
|
* Wired against the same per-session counter that the
|
||||||
|
* before_prompt_build hook bumps, so a fact added during turn N
|
||||||
|
* reads back at d=0 on turn N+1.
|
||||||
|
*/
|
||||||
|
turnFor: (sessionId: string) => number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -211,7 +216,7 @@ export function createCacheTool(deps: KBDeps) {
|
|||||||
return err('dynamic-kb-cache: ' + (e?.message || String(e)));
|
return err('dynamic-kb-cache: ' + (e?.message || String(e)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const turn = deps.turnFor(ctx.agentId);
|
const turn = deps.turnFor(ctx.sessionId);
|
||||||
const fetchedByID = new Map(fetched.map((f) => [f.id, f]));
|
const fetchedByID = new Map(fetched.map((f) => [f.id, f]));
|
||||||
const added: number[] = [];
|
const added: number[] = [];
|
||||||
for (const id of toFetch) {
|
for (const id of toFetch) {
|
||||||
|
|||||||
@@ -9,11 +9,11 @@
|
|||||||
// <OPENCLAW_PATH>/agents/<agentId>/sessions/<sessionId>/plugins/
|
// <OPENCLAW_PATH>/agents/<agentId>/sessions/<sessionId>/plugins/
|
||||||
// harbor-forge/kb-block.json
|
// harbor-forge/kb-block.json
|
||||||
//
|
//
|
||||||
// Fade algorithm + types are implemented (see fade.ts + renderFaded /
|
// Fade algorithm wired in v0.3 via turn-tracker.ts: the
|
||||||
// tick methods), but the openclaw `before_prompt_build` hook ctx
|
// before_prompt_build hook bumps a per-session counter (seeded from
|
||||||
// doesn't currently expose currentTurn to plugins. Plain render() (no
|
// max(entry.last_refresh_at_turn) on the first touch after plugin
|
||||||
// fade) is used in the hook for v1; renderFaded scaffolding stays for
|
// restart) and calls tick() + renderFaded() each turn. The Plexum
|
||||||
// when turn signal lands. Plexum side fully wires fade per turn.
|
// mirror runs the same algorithm inside RenderDynamicSubblock.
|
||||||
|
|
||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import * as os from 'node:os';
|
import * as os from 'node:os';
|
||||||
@@ -149,9 +149,8 @@ export class Block {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* RenderFaded mirrors render() but applies fade() per entry given
|
* RenderFaded mirrors render() but applies fade() per entry given
|
||||||
* currentTurn + params. Available for callers that have turn signal
|
* currentTurn + params. Used by the before_prompt_build hook (turn
|
||||||
* (Plexum side does; openclaw `before_prompt_build` hook doesn't
|
* source: tools/turn-tracker.ts).
|
||||||
* currently — uses plain render()).
|
|
||||||
*/
|
*/
|
||||||
renderFaded(currentTurn: number, params: FadeParams): string {
|
renderFaded(currentTurn: number, params: FadeParams): string {
|
||||||
return this.renderInner((e) => fade(e.content, e.seed, currentTurn - e.last_refresh_at_turn, params).rendered);
|
return this.renderInner((e) => fade(e.content, e.seed, currentTurn - e.last_refresh_at_turn, params).rendered);
|
||||||
|
|||||||
62
plugin/tools/turn-tracker.ts
Normal file
62
plugin/tools/turn-tracker.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
// turn-tracker.ts — per-session monotonic turn counter for KB fade.
|
||||||
|
//
|
||||||
|
// The openclaw `before_prompt_build` hook does not expose currentTurn
|
||||||
|
// to plugins (verified against the SDK hook-types: PluginHookAgentContext
|
||||||
|
// has agentId/sessionId/workspaceDir/... but no turn). To make fade
|
||||||
|
// effective, we maintain a small Map<sessionId, turn> that increments
|
||||||
|
// once per before_prompt_build fire. The `dynamic-kb-cache` tool reads
|
||||||
|
// the same counter so each new Entry's `last_refresh_at_turn` is
|
||||||
|
// consistent with the hook's view of the clock.
|
||||||
|
//
|
||||||
|
// Restart safety: in-memory counters reset to 0 on plugin reload, but
|
||||||
|
// `seedFromBlock` is called the first time we touch a given session
|
||||||
|
// during a hook; it scans the loaded block's entries and seeds the
|
||||||
|
// counter to max(last_refresh_at_turn). The next bump then yields a
|
||||||
|
// strictly-greater turn, so existing entries keep their accumulated
|
||||||
|
// fade distance instead of regressing to d=0.
|
||||||
|
//
|
||||||
|
// Process-local only. The Plexum side keeps the same semantics in Go
|
||||||
|
// (it has currentTurn from the host).
|
||||||
|
|
||||||
|
import type { Block } from './kbblock.js';
|
||||||
|
|
||||||
|
const counters = new Map<string, number>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the latest turn assigned to this session, or 0 if untouched.
|
||||||
|
* `dynamic-kb-cache` uses this for `at_turn` on Block.add so a fact
|
||||||
|
* cached during turn N reads back at d=0 on turn N+1.
|
||||||
|
*/
|
||||||
|
export function currentTurnForSession(sessionId: string): number {
|
||||||
|
if (!sessionId) return 0;
|
||||||
|
return counters.get(sessionId) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increment the counter for this session and return the new value.
|
||||||
|
* If the counter is unset (first hook after plugin start), seed it to
|
||||||
|
* max(entry.last_refresh_at_turn) so restart doesn't regress fade.
|
||||||
|
*
|
||||||
|
* Pass the loaded block so seeding doesn't need a second disk read.
|
||||||
|
*/
|
||||||
|
export function bumpTurnForSession(sessionId: string, block: Block): number {
|
||||||
|
if (!sessionId) return 0;
|
||||||
|
let cur = counters.get(sessionId);
|
||||||
|
if (cur === undefined) {
|
||||||
|
cur = 0;
|
||||||
|
for (const e of block.entries) {
|
||||||
|
if (e.last_refresh_at_turn > cur) cur = e.last_refresh_at_turn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const next = cur + 1;
|
||||||
|
counters.set(sessionId, next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test hook. Forget all in-memory counters. Not exported through any
|
||||||
|
* stable contract — for unit tests only.
|
||||||
|
*/
|
||||||
|
export function _resetForTesting(): void {
|
||||||
|
counters.clear();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user