feat(kb): dynamic-kb-* tool family + <kb-block> subblock injection
Openclaw mirror of HarborForge.PlexumPlugin's Phase 2 work. Same tool
names + input schemas + return shapes + storage path layout so a
single ClawSkills workflow text works on both runtimes.
plugin/openclaw.plugin.json:
- 5 new dynamic-kb-* contracts.tools entries
plugin/tools/kbblock.ts (new):
- TS class mirror of Plexum internal/kbblock package
- Storage at <OPENCLAW_PATH>/agents/<agentId>/sessions/<sessionId>/
plugins/harbor-forge/kb-block.json
- Entry shape + Render emit <kb-fact id=N kb=<code>
source=topic:<slug>>...</kb-fact> identical to Plexum side
- Insert-order rendering (§9 #4), no fade in v1 (§9 #3 placeholder)
plugin/tools/kbclient.ts (new):
- TS HTTP client for HF backend KB routes:
GET /knowledge-bases[?project=<code>], /knowledge-bases/{id}/topics,
/knowledge-bases/{id}/tree, /knowledge-facts/{id}
- ListFacts flattens the tree client-side filtered by topic ids
- Bearer auth via plugin-level apiKey (per-agent hf-token resolution
deferred — TODO matching Plexum side)
plugin/tools/dynamic-kb.ts (new):
- 5 tool factories (createListKBs/Topics/Facts/Cache/Evict)
- Each accepts ctx in execute so cache/evict resolve sessionID
- KBDeps bundle wires client + token-for + makeClient + turn-for
plugin/index.ts:
- Register 5 KB tools via the existing wrapped api.registerTool
(already handles MCP content shape + PaddedCell tools-cache gate)
- before_prompt_build hook: when block non-empty, returns
appendSystemContext = <dynamic-block><kb-block>...</kb-block>
</dynamic-block>. Empty block → no append. Limitation: append
only (can't replace openclaw's baked-in static sys-msg content);
the dynamic-block lands at the end rather than slotted into
System[1] like on Plexum
No tests yet — sim verification on dind-t2 next (requires HF backend
container rebuild to expose /knowledge-* routes; current image
predates the KB module).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
155
plugin/tools/kbblock.ts
Normal file
155
plugin/tools/kbblock.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
// 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 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/<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,
|
||||
};
|
||||
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 → "".
|
||||
*/
|
||||
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 = `<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(e.content);
|
||||
lines.push('</kb-fact>');
|
||||
});
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user