// 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=] 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 { let path = '/knowledge-bases'; if (projectCode) path += '?project=' + encodeURIComponent(projectCode); return this.get(path); } async listTopics(kbCode: string): Promise { if (!kbCode) throw new Error('kbclient: kb-code required'); return this.get('/knowledge-bases/' + encodeURIComponent(kbCode) + '/topics'); } /** ListFacts pulls the tree once + flattens facts under topic-ids client-side. */ async listFacts(kbCode: string, topicIds: number[]): Promise { 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('/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 { 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(p: string): Promise { 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; }