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.
197 lines
6.3 KiB
TypeScript
197 lines
6.3 KiB
TypeScript
// kbblock.ts — TypeScript mirror of HarborForge.PlexumPlugin's
|
|
// internal/kbblock package. Per-session storage of HarborForge KB
|
|
// facts the agent has cached, rendered as <kb-fact id=N kb=<code>
|
|
// source=topic:<slug>>content</kb-fact> per DESIGN-DYNAMIC-BLOCK.md
|
|
// §3.3 / §4.4.
|
|
//
|
|
// Storage location:
|
|
//
|
|
// <OPENCLAW_PATH>/agents/<agentId>/sessions/<sessionId>/plugins/
|
|
// harbor-forge/kb-block.json
|
|
//
|
|
// 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';
|
|
import * as path from 'node:path';
|
|
import { fade, shouldDrop, type FadeParams } from './fade.js';
|
|
|
|
export interface Entry {
|
|
id: number; // HF backend DB primary key
|
|
kb_code: string; // e.g. "KB-PAYROT"
|
|
source_topic: string; // human slug from HF, e.g. "debugging"
|
|
content: string;
|
|
insert_seq: number;
|
|
added_at_turn: number;
|
|
last_refresh_at_turn: number;
|
|
seed: number; // PRNG seed for fade — generated at add() time
|
|
}
|
|
|
|
interface BlockShape {
|
|
version: number;
|
|
next_seq: number;
|
|
entries: Entry[];
|
|
}
|
|
|
|
const FILE_NAME = 'kb-block.json';
|
|
const VERSION = 1;
|
|
|
|
function openclawRoot(): string {
|
|
const env = process.env.OPENCLAW_PATH;
|
|
if (env) return env;
|
|
const home = process.env.HOME || os.homedir();
|
|
return path.join(home, '.openclaw');
|
|
}
|
|
|
|
/** sessions/<sid>/plugins/harbor-forge path under openclaw profile. */
|
|
export function sessionDir(agentId: string, sessionId: string): string {
|
|
return path.join(openclawRoot(), 'agents', agentId, 'sessions', sessionId, 'plugins', 'harbor-forge');
|
|
}
|
|
|
|
export function blockPath(agentId: string, sessionId: string): string {
|
|
return path.join(sessionDir(agentId, sessionId), FILE_NAME);
|
|
}
|
|
|
|
/** Block is the in-memory representation. Cheap to Open + Save per call. */
|
|
export class Block {
|
|
private readonly path: string;
|
|
next_seq: number = 1;
|
|
entries: Entry[] = [];
|
|
|
|
private constructor(blockPath: string) { this.path = blockPath; }
|
|
|
|
/** Open from disk (missing file → empty block). Throws on parse error. */
|
|
static open(agentId: string, sessionId: string): Block {
|
|
if (!agentId || !sessionId) {
|
|
throw new Error('kbblock.open: agentId + sessionId required');
|
|
}
|
|
const b = new Block(blockPath(agentId, sessionId));
|
|
try {
|
|
const raw = fs.readFileSync(b.path, 'utf8');
|
|
if (raw.trim().length === 0) return b;
|
|
const data = JSON.parse(raw) as Partial<BlockShape>;
|
|
if (data.next_seq && data.next_seq > 0) b.next_seq = data.next_seq;
|
|
if (Array.isArray(data.entries)) b.entries = data.entries;
|
|
} catch (err: any) {
|
|
if (err?.code === 'ENOENT') return b;
|
|
throw err;
|
|
}
|
|
return b;
|
|
}
|
|
|
|
len(): number { return this.entries.length; }
|
|
|
|
has(id: number): boolean {
|
|
return this.entries.some((e) => e.id === id);
|
|
}
|
|
|
|
/** Add new entry. Returns it; null on duplicate (silent no-op per §9 #4). */
|
|
add(id: number, kb_code: string, source_topic: string, content: string, at_turn: number): Entry | null {
|
|
if (this.has(id)) return null;
|
|
const e: Entry = {
|
|
id, kb_code, source_topic, content,
|
|
insert_seq: this.next_seq,
|
|
added_at_turn: at_turn,
|
|
last_refresh_at_turn: at_turn,
|
|
seed: Math.floor(Math.random() * 0x7FFFFFFF),
|
|
};
|
|
this.next_seq += 1;
|
|
this.entries.push(e);
|
|
return e;
|
|
}
|
|
|
|
/** Remove given IDs. Returns IDs actually removed. */
|
|
remove(ids: number[]): number[] {
|
|
const want = new Set(ids);
|
|
const removed: number[] = [];
|
|
this.entries = this.entries.filter((e) => {
|
|
if (want.has(e.id)) {
|
|
removed.push(e.id);
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
return removed;
|
|
}
|
|
|
|
lookup(id: number): Entry | undefined {
|
|
return this.entries.find((e) => e.id === id);
|
|
}
|
|
|
|
/** Atomic save (tmp+rename, 0600). Empty block with no prior file = no-op. */
|
|
save(): void {
|
|
if (this.entries.length === 0) {
|
|
if (!fs.existsSync(this.path)) return;
|
|
}
|
|
fs.mkdirSync(path.dirname(this.path), { recursive: true });
|
|
const payload: BlockShape = {
|
|
version: VERSION,
|
|
next_seq: this.next_seq,
|
|
entries: this.entries,
|
|
};
|
|
const tmp = this.path + '.tmp';
|
|
fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), { mode: 0o600 });
|
|
fs.renameSync(tmp, this.path);
|
|
}
|
|
|
|
/**
|
|
* Render the <kb-block> inner body — flat sequence of
|
|
* <kb-fact id=N kb=<code> source=topic:<slug>>content</kb-fact>
|
|
* ordered by insert_seq (§9 #4). Empty block → "". No fade applied.
|
|
*/
|
|
render(): string {
|
|
return this.renderInner(null);
|
|
}
|
|
|
|
/**
|
|
* RenderFaded mirrors render() but applies fade() per entry given
|
|
* 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);
|
|
}
|
|
|
|
private renderInner(transform: ((e: Entry) => string) | null): string {
|
|
if (this.entries.length === 0) return '';
|
|
const ordered = [...this.entries].sort((a, b) => a.insert_seq - b.insert_seq);
|
|
const lines: string[] = [];
|
|
ordered.forEach((e, i) => {
|
|
if (i > 0) lines.push('');
|
|
let head = `<kb-fact id=${e.id} kb=${e.kb_code}`;
|
|
if (e.source_topic) head += ` source=topic:${e.source_topic}`;
|
|
head += '>';
|
|
lines.push(head);
|
|
lines.push(transform ? transform(e) : e.content);
|
|
lines.push('</kb-fact>');
|
|
});
|
|
return lines.join('\n') + '\n';
|
|
}
|
|
|
|
/**
|
|
* Tick applies fade tick — drops entries whose underscore ratio
|
|
* crossed the m% threshold. Returns IDs dropped. Caller must
|
|
* save() after.
|
|
*/
|
|
tick(currentTurn: number, params: FadeParams): number[] {
|
|
if (this.entries.length === 0) return [];
|
|
const dropped: number[] = [];
|
|
const kept: Entry[] = [];
|
|
for (const e of this.entries) {
|
|
const d = currentTurn - e.last_refresh_at_turn;
|
|
const r = fade(e.content, e.seed, d, params);
|
|
if (shouldDrop(r, params)) {
|
|
dropped.push(e.id);
|
|
} else {
|
|
kept.push(e);
|
|
}
|
|
}
|
|
this.entries = kept;
|
|
return dropped;
|
|
}
|
|
}
|