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:
@@ -20,6 +20,16 @@ import { MonitorBridgeClient, type OpenClawMeta } from './core/monitor-bridge.js
|
|||||||
import type { OpenClawAgentInfo } from './core/openclaw-agents.js';
|
import type { OpenClawAgentInfo } from './core/openclaw-agents.js';
|
||||||
import { registerGatewayStartHook } from './hooks/gateway-start.js';
|
import { registerGatewayStartHook } from './hooks/gateway-start.js';
|
||||||
import { registerGatewayStopHook } from './hooks/gateway-stop.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 {
|
import {
|
||||||
createCalendarBridgeClient,
|
createCalendarBridgeClient,
|
||||||
CalendarScheduler,
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// <kb-block> 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
|
||||||
|
// <available_skills> 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: `<dynamic-block>\n<kb-block>\n${body}</kb-block>\n</dynamic-block>\n`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
logger.info('HarborForge kb-block subblock hook registered');
|
||||||
|
}
|
||||||
|
|
||||||
logger.info('HarborForge plugin registered (id: harbor-forge)');
|
logger.info('HarborForge plugin registered (id: harbor-forge)');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,12 @@
|
|||||||
"harborforge_calendar_abort",
|
"harborforge_calendar_abort",
|
||||||
"harborforge_calendar_pause",
|
"harborforge_calendar_pause",
|
||||||
"harborforge_calendar_resume",
|
"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": {
|
"configSchema": {
|
||||||
|
|||||||
278
plugin/tools/dynamic-kb.ts
Normal file
278
plugin/tools/dynamic-kb.ts
Normal file
@@ -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<string | null>) | 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<ToolResult> {
|
||||||
|
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: "<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<ToolResult> {
|
||||||
|
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: [<id>, ...]}) 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<ToolResult> {
|
||||||
|
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: [<id>, ...]}) 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<ToolResult> {
|
||||||
|
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<ToolResult> {
|
||||||
|
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.',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
166
plugin/tools/kbclient.ts
Normal file
166
plugin/tools/kbclient.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user