// 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 = `';
lines.push(head);
lines.push(e.content);
lines.push('');
});
return lines.join('\n') + '\n';
}
}