// kbblock.ts — TypeScript mirror of HarborForge.PlexumPlugin's // internal/kbblock package. Per-session storage of HarborForge KB // facts the agent has cached, rendered as // source=topic:>content per DESIGN-DYNAMIC-BLOCK.md // §3.3 / §4.4. // // Storage location: // // /agents//sessions//plugins/ // harbor-forge/kb-block.json // // Fade NOT applied in v1 (§9 #3 placeholder; defer until prod data). // Cross-runtime aligned with Plexum kbblock. import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; 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; } 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//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; 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, }; 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 inner body — flat sequence of * source=topic:>content * ordered by insert_seq (§9 #4). Empty block → "". */ render(): 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 = `'); }); return lines.join('\n') + '\n'; } }