diff --git a/plugin/index.ts b/plugin/index.ts index b10e414..b820697 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -21,6 +21,8 @@ import type { OpenClawAgentInfo } from './core/openclaw-agents.js'; import { registerGatewayStartHook } from './hooks/gateway-start.js'; import { registerGatewayStopHook } from './hooks/gateway-stop.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 { type KBDeps, @@ -978,7 +980,7 @@ function register(api: PluginAPI): void { } }, 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 @@ -1003,7 +1005,12 @@ function register(api: PluginAPI): void { // subblock injection via before_prompt_build hook // (DESIGN-DYNAMIC-BLOCK.md §2 + §7: openclaw side uses // appendSystemContext since the hook can't replace baked-in - // precisely). Empty block → no append. + // 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; if (typeof apiOn === 'function') { apiOn.call( @@ -1016,7 +1023,16 @@ function register(api: PluginAPI): void { let body = ''; try { 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) { logger.warn(`kb-block render failed: ${err}`); return undefined; diff --git a/plugin/tools/dynamic-kb.ts b/plugin/tools/dynamic-kb.ts index 8076165..7d732b2 100644 --- a/plugin/tools/dynamic-kb.ts +++ b/plugin/tools/dynamic-kb.ts @@ -39,8 +39,13 @@ export interface KBDeps { tokenFor: ((agentId: string) => Promise) | null; /** Build new client given a token (so each call gets fresh per-agent client). */ 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))); } } - const turn = deps.turnFor(ctx.agentId); + const turn = deps.turnFor(ctx.sessionId); const fetchedByID = new Map(fetched.map((f) => [f.id, f])); const added: number[] = []; for (const id of toFetch) { diff --git a/plugin/tools/kbblock.ts b/plugin/tools/kbblock.ts index 5333400..2b9c131 100644 --- a/plugin/tools/kbblock.ts +++ b/plugin/tools/kbblock.ts @@ -9,11 +9,11 @@ // /agents//sessions//plugins/ // harbor-forge/kb-block.json // -// Fade algorithm + types are implemented (see fade.ts + renderFaded / -// tick methods), but the openclaw `before_prompt_build` hook ctx -// doesn't currently expose currentTurn to plugins. Plain render() (no -// fade) is used in the hook for v1; renderFaded scaffolding stays for -// when turn signal lands. Plexum side fully wires fade per turn. +// Fade algorithm wired in v0.3 via turn-tracker.ts: the +// before_prompt_build hook bumps a per-session counter (seeded from +// max(entry.last_refresh_at_turn) on the first touch after plugin +// restart) and calls tick() + renderFaded() each turn. The Plexum +// mirror runs the same algorithm inside RenderDynamicSubblock. import * as fs from 'node:fs'; import * as os from 'node:os'; @@ -149,9 +149,8 @@ export class Block { /** * RenderFaded mirrors render() but applies fade() per entry given - * currentTurn + params. Available for callers that have turn signal - * (Plexum side does; openclaw `before_prompt_build` hook doesn't - * currently — uses plain render()). + * currentTurn + params. Used by the before_prompt_build hook (turn + * source: tools/turn-tracker.ts). */ renderFaded(currentTurn: number, params: FadeParams): string { return this.renderInner((e) => fade(e.content, e.seed, currentTurn - e.last_refresh_at_turn, params).rendered); diff --git a/plugin/tools/turn-tracker.ts b/plugin/tools/turn-tracker.ts new file mode 100644 index 0000000..2fb1e8c --- /dev/null +++ b/plugin/tools/turn-tracker.ts @@ -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 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(); + +/** + * 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(); +}