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:
h z
2026-06-05 23:32:34 +01:00
parent 07e91ea858
commit c9792d1290
5 changed files with 682 additions and 1 deletions

166
plugin/tools/kbclient.ts Normal file
View File

@@ -0,0 +1,166 @@
// kbclient.ts — TypeScript HTTP client for HarborForge.Backend KB
// REST routes. Mirrors HarborForge.PlexumPlugin/internal/kbclient.
// Routes verified against app/api/routers/knowledge.py:
//
// GET /knowledge-bases[?project=<code>] list KBs
// GET /knowledge-bases/{id|code}/topics list topics in a KB
// GET /knowledge-bases/{id|code}/tree full hierarchy
// GET /knowledge-facts/{id} single fact
//
// Auth: per-agent HF token (looked up via secret-mgr by caller; this
// client takes a pre-resolved token string). Cross-runtime parity
// note: Plexum client uses plugin-level APIKey from config because
// the Plexum SDK can't reach secret-mgr; openclaw side has agent
// context so we go per-agent here.
export interface KBSummary {
id: number;
knowledge_base_code: string;
title: string;
description: string;
}
export interface TopicSummary {
id: number;
topic: string;
description: string;
}
export interface FactSummary {
id: number;
topic_id: number;
topic_slug: string;
title: string;
snippet: string;
}
export interface Fact {
id: number;
kb_code: string;
topic_slug: string;
content: string;
}
interface TreeNode {
id: number;
topic?: string;
category?: string;
title?: string;
content?: string;
categories?: TreeNode[];
facts?: TreeNode[];
}
interface KBTree {
knowledge_base_code: string;
topics: TreeNode[];
}
export class KBClient {
private readonly baseUrl: string;
private readonly token: string;
constructor(baseUrl: string, token: string) {
this.baseUrl = baseUrl.replace(/\/$/, '');
this.token = token;
}
async listKBs(projectCode?: string): Promise<KBSummary[]> {
let path = '/knowledge-bases';
if (projectCode) path += '?project=' + encodeURIComponent(projectCode);
return this.get<KBSummary[]>(path);
}
async listTopics(kbCode: string): Promise<TopicSummary[]> {
if (!kbCode) throw new Error('kbclient: kb-code required');
return this.get<TopicSummary[]>('/knowledge-bases/' + encodeURIComponent(kbCode) + '/topics');
}
/** ListFacts pulls the tree once + flattens facts under topic-ids client-side. */
async listFacts(kbCode: string, topicIds: number[]): Promise<FactSummary[]> {
if (!kbCode) throw new Error('kbclient: kb-code required');
if (!topicIds || topicIds.length === 0) throw new Error('kbclient: topic-ids required');
const tree = await this.get<KBTree>('/knowledge-bases/' + encodeURIComponent(kbCode) + '/tree');
const wantTopic = new Set(topicIds);
const out: FactSummary[] = [];
for (const topic of tree.topics || []) {
if (!wantTopic.has(topic.id)) continue;
this.walkFactsInto(out, topic.id, topic.topic || '', topic.categories || [], topic.facts || []);
}
return out;
}
private walkFactsInto(
out: FactSummary[],
topicId: number,
topicSlug: string,
cats: TreeNode[],
facts: TreeNode[],
): void {
for (const f of facts) {
out.push({
id: f.id,
topic_id: topicId,
topic_slug: topicSlug,
title: f.title || '',
snippet: snippet(f.content || '', 120),
});
}
for (const c of cats) {
this.walkFactsInto(out, topicId, topicSlug, c.categories || [], c.facts || []);
}
}
/** GetFacts pulls each fact's full body. Backend has no batch route. */
async getFacts(kbCode: string, factIds: number[]): Promise<Fact[]> {
if (!kbCode) throw new Error('kbclient: kb-code required');
const out: Fact[] = [];
for (const id of factIds) {
try {
const node = await this.get<{ id: number; title: string; content: string; topic: string }>(
'/knowledge-facts/' + id,
);
out.push({
id: node.id,
kb_code: kbCode,
topic_slug: node.topic || '',
content: node.content || '',
});
} catch (err: any) {
if (err?.code === 404) continue; // skip missing
throw err;
}
}
return out;
}
private async get<T>(p: string): Promise<T> {
const res = await fetch(this.baseUrl + p, {
method: 'GET',
headers: {
Accept: 'application/json',
...(this.token ? { Authorization: 'Bearer ' + this.token } : {}),
},
});
if (res.status === 404) {
const e: any = new Error(`GET ${p}: 404 not found`);
e.code = 404;
throw e;
}
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`GET ${p}: HTTP ${res.status}: ${truncate(body, 200)}`);
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
}
function snippet(s: string, n: number): string {
s = s.trim();
return s.length <= n ? s : s.slice(0, n) + '...';
}
function truncate(s: string, n: number): string {
return s.length > n ? s.slice(0, n) + '...' : s;
}