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>
167 lines
4.8 KiB
TypeScript
167 lines
4.8 KiB
TypeScript
// 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;
|
|
}
|