diff --git a/plugin/index.ts b/plugin/index.ts index 26b3969..b792ba1 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -20,6 +20,16 @@ import { MonitorBridgeClient, type OpenClawMeta } from './core/monitor-bridge.js import type { OpenClawAgentInfo } from './core/openclaw-agents.js'; import { registerGatewayStartHook } from './hooks/gateway-start.js'; import { registerGatewayStopHook } from './hooks/gateway-stop.js'; +import { Block as KBBlock } from './tools/kbblock.js'; +import { KBClient } from './tools/kbclient.js'; +import { + type KBDeps, + createListKBsTool, + createListTopicsTool, + createListFactsTool, + createCacheTool, + createEvictTool, +} from './tools/dynamic-kb.js'; import { createCalendarBridgeClient, CalendarScheduler, @@ -928,6 +938,73 @@ function register(api: PluginAPI): void { }, })); + // ---- dynamic-kb-* family (DESIGN-DYNAMIC-BLOCK.md §3.3 / §4.4) + // Cross-runtime mirror of HarborForge.PlexumPlugin/internal/tools/kb.go + + // /internal/kbblock + /internal/kbclient. v1 auth = plugin-level apiKey + // via Bearer (per-agent hf-token resolution is a TODO matching the + // Plexum side). + const kbCfg = resolveConfig(); + const kbBackendUrl = + typeof kbCfg?.backendUrl === 'string' && kbCfg.backendUrl + ? (kbCfg.backendUrl as string) + : 'https://monitor.hangman-lab.top'; + const kbApiKey = typeof kbCfg?.apiKey === 'string' ? (kbCfg.apiKey as string) : ''; + const kbDeps: KBDeps = { + client: kbApiKey ? new KBClient(kbBackendUrl, kbApiKey) : null, + tokenFor: null, + makeClient: null, + turnFor: () => 0, + }; + + // Wrap each KB tool factory: pass ctx into execute so cache/evict can + // resolve the per-session kb-block.json path. + const kbFactories = [ + createListKBsTool, + createListTopicsTool, + createListFactsTool, + createCacheTool, + createEvictTool, + ]; + for (const make of kbFactories) { + api.registerTool((ctx: any) => { + const tool = make(kbDeps); + const inner = tool.execute; + tool.execute = async (callId: string, params: any) => + inner(callId, params, { agentId: ctx?.agentId, sessionId: ctx?.sessionId }); + return tool; + }); + } + + // subblock injection via before_prompt_build hook + // (DESIGN-DYNAMIC-BLOCK.md §2 + §7: openclaw side uses + // appendSystemContext since the hook can't replace baked-in + // precisely). Empty block → no append. + const apiOn = (api as any).on; + if (typeof apiOn === 'function') { + apiOn.call( + api, + 'before_prompt_build', + async (_event: any, ctx: any) => { + const agentId = ctx?.agentId; + const sessionId = ctx?.sessionId; + if (!agentId || !sessionId) return undefined; + let body = ''; + try { + const block = KBBlock.open(agentId, sessionId); + body = block.render(); + } catch (err) { + logger.warn(`kb-block render failed: ${err}`); + return undefined; + } + if (!body) return undefined; + return { + appendSystemContext: `\n\n${body}\n\n`, + }; + }, + ); + logger.info('HarborForge kb-block subblock hook registered'); + } + logger.info('HarborForge plugin registered (id: harbor-forge)'); } diff --git a/plugin/openclaw.plugin.json b/plugin/openclaw.plugin.json index d49cd37..59fb99d 100644 --- a/plugin/openclaw.plugin.json +++ b/plugin/openclaw.plugin.json @@ -15,7 +15,12 @@ "harborforge_calendar_abort", "harborforge_calendar_pause", "harborforge_calendar_resume", - "harborforge_restart_status" + "harborforge_restart_status", + "dynamic-kb-list-kbs", + "dynamic-kb-list-topics", + "dynamic-kb-list-facts", + "dynamic-kb-cache", + "dynamic-kb-evict" ] }, "configSchema": { diff --git a/plugin/tools/dynamic-kb.ts b/plugin/tools/dynamic-kb.ts new file mode 100644 index 0000000..8076165 --- /dev/null +++ b/plugin/tools/dynamic-kb.ts @@ -0,0 +1,278 @@ +// dynamic-kb.ts — 5 host tools (dynamic-kb-list-kbs / list-topics / +// list-facts / cache / evict) implementing the agent-side knowledge- +// base browse + cache flow per DESIGN-DYNAMIC-BLOCK.md §4.4. +// +// Cross-runtime aligned with Plexum's dynmem/kb_tools.go — same tool +// names, same input shapes, same return shapes (so ClawSkills +// workflow text reads identically on either runtime). +// +// Returns ToolResult.content[].text for both ok and error cases. The +// `isError: true` flag surfaces errors to the model rather than RPC- +// failing the tool call. + +import { Block as KBBlock } from './kbblock.js'; +import { KBClient } from './kbclient.js'; + +interface ToolCtx { + agentId?: string; + sessionId?: string; +} + +interface ToolResult { + content: Array<{ type: 'text'; text: string }>; + isError?: boolean; +} + +function ok(text: string): ToolResult { + return { content: [{ type: 'text', text }] }; +} + +function err(text: string): ToolResult { + return { isError: true, content: [{ type: 'text', text }] }; +} + +/** Dependencies the 5 KB tools share. Built once in index.ts. */ +export interface KBDeps { + /** HF backend KB HTTP client. */ + client: KBClient | null; + /** Resolve hf-token for the agent at call time (via secret-mgr). null → no auth */ + tokenFor: ((agentId: string) => Promise) | null; + /** Build new client given a token (so each call gets fresh per-agent client). */ + makeClient: ((token: string) => KBClient) | null; + /** Best-effort current turn number; 0 is acceptable. */ + turnFor: (agentId: string) => number; +} + +/** + * Resolve a per-agent KBClient. Returns null with reason if no client + * (no agent context / no token / no backend URL). + */ +async function clientFor(deps: KBDeps, ctx: ToolCtx): Promise<{ c: KBClient | null; reason: string }> { + if (!ctx.agentId) return { c: null, reason: 'no agent context' }; + if (deps.makeClient && deps.tokenFor) { + const tok = await deps.tokenFor(ctx.agentId); + if (!tok) return { c: null, reason: 'agent has no hf-token in secret-mgr' }; + return { c: deps.makeClient(tok), reason: '' }; + } + if (deps.client) return { c: deps.client, reason: '' }; + return { c: null, reason: 'HF KB backend unavailable' }; +} + +// ----- 5 tool factories ----- + +export function createListKBsTool(deps: KBDeps) { + return { + name: 'dynamic-kb-list-kbs', + description: 'List HarborForge Knowledge Bases the agent can access. Optional project-code filter.', + parameters: { + type: 'object', + properties: { 'project-code': { type: 'string' } }, + }, + async execute(_id: string, params: any, ctxArg?: ToolCtx): Promise { + const ctx = ctxArg ?? {}; + const r = await clientFor(deps, ctx); + if (!r.c) return err('dynamic-kb-list-kbs: ' + r.reason); + const proj = params?.['project-code']; + try { + const kbs = await r.c.listKBs(typeof proj === 'string' ? proj : undefined); + const lines: string[] = []; + if (proj) lines.push(`project: ${proj}`); + lines.push(`kbs: ${kbs.length}`, ''); + for (const k of kbs) { + lines.push(`[${k.knowledge_base_code}] ${k.title}`); + lines.push(` ${k.description || ''}`, ''); + } + lines.push(`Call dynamic-kb-list-topics({kb-code: ""}) to drill into a KB.`); + return ok(lines.join('\n')); + } catch (e: any) { + return err('dynamic-kb-list-kbs: ' + (e?.message || String(e))); + } + }, + }; +} + +export function createListTopicsTool(deps: KBDeps) { + return { + name: 'dynamic-kb-list-topics', + description: 'List topics in one HF KB by code.', + parameters: { + type: 'object', + properties: { 'kb-code': { type: 'string' } }, + required: ['kb-code'], + }, + async execute(_id: string, params: any, ctxArg?: ToolCtx): Promise { + const ctx = ctxArg ?? {}; + const r = await clientFor(deps, ctx); + if (!r.c) return err('dynamic-kb-list-topics: ' + r.reason); + const kbCode = String(params?.['kb-code'] ?? ''); + if (!kbCode) return err('dynamic-kb-list-topics: kb-code required'); + try { + const ts = await r.c.listTopics(kbCode); + const lines = [ + `kb: ${kbCode}`, + `topics: ${ts.length}`, + '', + ...ts.flatMap((t) => [`[${t.id}] ${t.topic}`, ` ${t.description || ''}`, '']), + `Call dynamic-kb-list-facts({kb-code: "${kbCode}", topic-ids: [, ...]}) to drill into facts.`, + ]; + return ok(lines.join('\n')); + } catch (e: any) { + return err('dynamic-kb-list-topics: ' + (e?.message || String(e))); + } + }, + }; +} + +export function createListFactsTool(deps: KBDeps) { + return { + name: 'dynamic-kb-list-facts', + description: 'List fact previews (id/topic/title/snippet) in given topics. Feed into dynamic-kb-cache.', + parameters: { + type: 'object', + properties: { + 'kb-code': { type: 'string' }, + 'topic-ids': { type: 'array', items: { type: 'integer' } }, + }, + required: ['kb-code', 'topic-ids'], + }, + async execute(_id: string, params: any, ctxArg?: ToolCtx): Promise { + const ctx = ctxArg ?? {}; + const r = await clientFor(deps, ctx); + if (!r.c) return err('dynamic-kb-list-facts: ' + r.reason); + const kbCode = String(params?.['kb-code'] ?? ''); + const topicIds = Array.isArray(params?.['topic-ids']) ? params['topic-ids'].map((x: any) => Number(x)) : []; + if (!kbCode || topicIds.length === 0) { + return err('dynamic-kb-list-facts: kb-code + topic-ids required'); + } + try { + const facts = await r.c.listFacts(kbCode, topicIds); + const lines = [ + `kb: ${kbCode}`, + `topics: [${topicIds.join(', ')}]`, + `facts: ${facts.length}`, + '', + ...facts.flatMap((f) => [ + `[${f.id}] (topic ${f.topic_id}/${f.topic_slug}) ${f.title}`, + ` ${f.snippet}`, + '', + ]), + `Call dynamic-kb-cache({kb-code: "${kbCode}", fact-ids: [, ...]}) to commit selected facts to your kb-block.`, + ]; + return ok(lines.join('\n')); + } catch (e: any) { + return err('dynamic-kb-list-facts: ' + (e?.message || String(e))); + } + }, + }; +} + +export function createCacheTool(deps: KBDeps) { + return { + name: 'dynamic-kb-cache', + description: 'Cache KB facts into your per-session kb-block (visible next turn).', + parameters: { + type: 'object', + properties: { + 'kb-code': { type: 'string' }, + 'fact-ids': { type: 'array', items: { type: 'integer' } }, + 'previous-dynamic-id': { type: 'string' }, + }, + required: ['kb-code', 'fact-ids'], + }, + async execute(_id: string, params: any, ctxArg?: ToolCtx): Promise { + const ctx = ctxArg ?? {}; + if (!ctx.agentId || !ctx.sessionId) { + return err('dynamic-kb-cache: no per-session context'); + } + const r = await clientFor(deps, ctx); + if (!r.c) return err('dynamic-kb-cache: ' + r.reason); + const kbCode = String(params?.['kb-code'] ?? ''); + const factIds = Array.isArray(params?.['fact-ids']) ? params['fact-ids'].map((x: any) => Number(x)) : []; + if (!kbCode || factIds.length === 0) { + return err('dynamic-kb-cache: kb-code + fact-ids required'); + } + let block: KBBlock; + try { + block = KBBlock.open(ctx.agentId, ctx.sessionId); + } catch (e: any) { + return err('dynamic-kb-cache: open block: ' + (e?.message || String(e))); + } + const toFetch: number[] = []; + const alreadyCached: number[] = []; + for (const id of factIds) { + if (block.has(id)) alreadyCached.push(id); + else toFetch.push(id); + } + let fetched: Array<{ id: number; kb_code: string; topic_slug: string; content: string }> = []; + if (toFetch.length > 0) { + try { + fetched = await r.c.getFacts(kbCode, toFetch); + } catch (e: any) { + return err('dynamic-kb-cache: ' + (e?.message || String(e))); + } + } + const turn = deps.turnFor(ctx.agentId); + const fetchedByID = new Map(fetched.map((f) => [f.id, f])); + const added: number[] = []; + for (const id of toFetch) { + const f = fetchedByID.get(id); + if (!f) continue; + if (block.add(f.id, kbCode, f.topic_slug, f.content, turn)) added.push(f.id); + } + try { + block.save(); + } catch (e: any) { + return err('dynamic-kb-cache: save: ' + (e?.message || String(e))); + } + const missing = toFetch.filter((id) => !added.includes(id)); + return ok( + JSON.stringify({ + added: added.sort((a, b) => a - b), + already_cached: alreadyCached.sort((a, b) => a - b), + missing: missing.length ? missing.sort((a, b) => a - b) : undefined, + note: 'Newly cached facts are available starting your next turn.', + }), + ); + }, + }; +} + +export function createEvictTool(_deps: KBDeps) { + return { + name: 'dynamic-kb-evict', + description: "Remove cached facts from your kb-block. Takes effect next turn.", + parameters: { + type: 'object', + properties: { 'fact-ids': { type: 'array', items: { type: 'integer' } } }, + required: ['fact-ids'], + }, + async execute(_id: string, params: any, ctxArg?: ToolCtx): Promise { + const ctx = ctxArg ?? {}; + if (!ctx.agentId || !ctx.sessionId) { + return err('dynamic-kb-evict: no per-session context'); + } + const factIds = Array.isArray(params?.['fact-ids']) ? params['fact-ids'].map((x: any) => Number(x)) : []; + if (factIds.length === 0) return err('dynamic-kb-evict: fact-ids required'); + let block: KBBlock; + try { + block = KBBlock.open(ctx.agentId, ctx.sessionId); + } catch (e: any) { + return err('dynamic-kb-evict: open block: ' + (e?.message || String(e))); + } + const evicted = block.remove(factIds); + try { + block.save(); + } catch (e: any) { + return err('dynamic-kb-evict: save: ' + (e?.message || String(e))); + } + const notCached = factIds.filter((id: number) => !evicted.includes(id)); + return ok( + JSON.stringify({ + evicted: evicted.sort((a, b) => a - b), + not_cached: notCached.length ? notCached.sort((a: number, b: number) => a - b) : undefined, + note: 'Evictions take effect starting your next turn.', + }), + ); + }, + }; +} diff --git a/plugin/tools/kbblock.ts b/plugin/tools/kbblock.ts new file mode 100644 index 0000000..b820a6a --- /dev/null +++ b/plugin/tools/kbblock.ts @@ -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 +// 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 = `'); + }); + return lines.join('\n') + '\n'; + } +} diff --git a/plugin/tools/kbclient.ts b/plugin/tools/kbclient.ts new file mode 100644 index 0000000..ea1042c --- /dev/null +++ b/plugin/tools/kbclient.ts @@ -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=] 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; +}