/** * Memory tools exposed to OpenClaw agents. * * Each tool is a thin wrapper around a `MemoryCore` method. We use the * **factory form** of `registerTool` (see `openclaw/src/plugins/tool-types.ts`) * so each tool has access to the trusted `OpenClawPluginToolContext` * (agentId / sessionKey / sessionId / workspaceDir), which lets us scope * searches to the current agent or session on demand. * * Tool execution signature follows pi-agent-core's `AnyAgentTool`: * * execute(toolCallId: string, params: Static) => unknown * * Tools stay *stateless*: the bridge owns cursors, the core owns memory. * Each tool is idempotent and re-entrant. */ import { Type, type Static } from "@sinclair/typebox"; import type { AgentKind, RuntimeNamespace, SkillId, TraceId } from "../../agent-contract/dto.js"; import type { MemoryCore } from "../../agent-contract/memory-core.js"; import { bridgeSessionId } from "./bridge.js"; import type { AgentToolDescriptor, HostLogger, OpenClawPluginApi, OpenClawPluginToolContext, } from "./openclaw-api.js"; export interface ToolsOptions { agent: AgentKind; core?: MemoryCore; getCore?: () => MemoryCore | null | Promise; log: HostLogger; /** Cap on how many characters we return per snippet. */ maxBodyChars?: number; } const DEFAULT_BODY_CAP = 1200; // ─── Parameter schemas ───────────────────────────────────────────────────── const MemorySearchParams = Type.Object({ query: Type.String({ minLength: 1, description: "Free-text query (2–5 key words)." }), maxResults: Type.Optional(Type.Integer({ minimum: 1, maximum: 50 })), tier1topK: Type.Optional( Type.Integer({ minimum: 0, maximum: 100, description: "Override Skill (Tier 1) topK for this search only.", }), ), tier2topK: Type.Optional( Type.Integer({ minimum: 0, maximum: 100, description: "Override trace/episode (Tier 2) topK for this search only.", }), ), tier3topK: Type.Optional( Type.Integer({ minimum: 0, maximum: 100, description: "Override world-model (Tier 3) topK for this search only.", }), ), sessionScope: Type.Optional( Type.Boolean({ default: false, description: "Restrict results to the current session only.", }), ), }); type MemorySearchParamsT = Static; const MemoryGetParams = Type.Object({ id: Type.String({ minLength: 1 }), kind: Type.Optional( Type.Union( [Type.Literal("trace"), Type.Literal("policy"), Type.Literal("world_model")], { default: "trace" }, ), ), }); type MemoryGetParamsT = Static; const MemoryTimelineParams = Type.Object({ episodeId: Type.String({ minLength: 1 }), limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100, default: 20 })), }); type MemoryTimelineParamsT = Static; const SkillListParams = Type.Object({ status: Type.Optional( Type.Union([ Type.Literal("candidate"), Type.Literal("active"), Type.Literal("archived"), ]), ), limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 50, default: 10 })), }); type SkillListParamsT = Static; const SkillGetParams = Type.Object({ id: Type.String({ minLength: 1 }) }); type SkillGetParamsT = Static; const EnvironmentQueryParams = Type.Object({ query: Type.Optional( Type.String({ description: "Free-text keyword to narrow down (optional; omit to list all environments).", }), ), limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 30, default: 5 })), }); type EnvironmentQueryParamsT = Static; // ─── Helpers ─────────────────────────────────────────────────────────────── function clip(s: string | undefined, n: number): string { if (!s) return ""; return s.length > n ? s.slice(0, n) + "…" : s; } type TextToolContent = Array<{ type: "text"; text: string }>; function textToolResult>( details: T, text: string, ): T & { content: TextToolContent; details: T } { return { ...details, content: [{ type: "text", text }], details, }; } function formatHitList(hits: Array<{ refKind: string; refId: string; score: number; snippet: string }>): string { if (hits.length === 0) return "No relevant memories found."; const lines = hits.map((h, i) => { const snippet = h.snippet.trim() || "(empty snippet)"; return `${i + 1}. [${h.refKind}:${h.refId}] ${snippet} (score=${h.score.toFixed(3)})`; }); return `Found ${hits.length} memories:\n\n${lines.join("\n")}`; } function sessionFromCtx(ctx: OpenClawPluginToolContext | undefined): string | undefined { const sessionKey = ctx?.sessionKey; if (!sessionKey) return undefined; const agentId = ctx?.agentId ?? "main"; return bridgeSessionId(agentId, sessionKey); } function namespaceFromCtx(ctx: OpenClawPluginToolContext | undefined): RuntimeNamespace { const profileId = (ctx?.agentId || "main").trim() || "main"; return { agentKind: "openclaw", profileId, profileLabel: profileId, workspacePath: ctx?.workspaceDir || ctx?.agentDir, sessionKey: ctx?.sessionKey, }; } async function resolveCore(opts: ToolsOptions): Promise { const core = opts.core ?? (await opts.getCore?.()); if (!core) { throw new Error("MemOS Local runtime is not ready yet"); } return core; } // ─── Registration ────────────────────────────────────────────────────────── export function registerOpenClawTools(api: OpenClawPluginApi, opts: ToolsOptions): void { const bodyCap = opts.maxBodyChars ?? DEFAULT_BODY_CAP; // ── memos_search ── api.registerTool( (ctx: OpenClawPluginToolContext): AgentToolDescriptor => ({ name: "memos_search", label: "Memory Search", description: "Search MemOS memory (local traces + policies + world models + skills, plus connected Team Hub memories). " + "Returns a ranked list of grounded snippets. Prefer this before claiming prior context is unavailable.", parameters: MemorySearchParams, async execute(_toolCallId: string, params: MemorySearchParamsT) { const started = Date.now(); const core = await resolveCore(opts); const sessionId = params.sessionScope ? sessionFromCtx(ctx) : undefined; const maxResults = params.maxResults !== undefined ? Math.min(params.maxResults, 50) : undefined; const result = await core.searchMemory({ agent: opts.agent, namespace: namespaceFromCtx(ctx), sessionId: sessionId as never, query: params.query, topK: topKParams(params, maxResults), }); const details = { hits: result.hits.map((h) => ({ tier: h.tier, refKind: h.refKind, refId: h.refId, score: h.score, snippet: clip(h.snippet, bodyCap), })), totalMs: Date.now() - started, }; return textToolResult(details, formatHitList(details.hits)); }, }), { name: "memos_search" }, ); // ── memos_get ── api.registerTool( (ctx: OpenClawPluginToolContext): AgentToolDescriptor => ({ name: "memos_get", label: "Memory Get", description: 'Fetch the full body of a memory item by id. `kind` can be "trace" (default), ' + '"policy", or "world_model".', parameters: MemoryGetParams, async execute(_toolCallId: string, params: MemoryGetParamsT) { const core = await resolveCore(opts); const kind = params.kind ?? "trace"; if (kind === "trace") { const trace = await core.getTrace(params.id as TraceId, namespaceFromCtx(ctx)); if (!trace) { const details = { found: false, kind, id: params.id, body: "", meta: {} }; return textToolResult(details, `No ${kind} memory found for id "${params.id}".`); } const details = { found: true, kind, id: trace.id, body: clip(trace.agentText, bodyCap), meta: { episodeId: trace.episodeId, ts: trace.ts, value: trace.value, reflection: clip(trace.reflection, bodyCap), userText: clip(trace.userText, bodyCap), toolCalls: trace.toolCalls.map((tc) => ({ name: tc.name, success: !tc.errorCode, errorCode: tc.errorCode, })), }, }; return textToolResult(details, details.body || trace.summary || trace.userText || `Found trace ${trace.id}.`); } if (kind === "policy") { const policy = await core.getPolicy(params.id, namespaceFromCtx(ctx)); if (!policy) { const details = { found: false, kind, id: params.id, body: "", meta: {} }; return textToolResult(details, `No ${kind} memory found for id "${params.id}".`); } const details = { found: true, kind, id: policy.id, body: `${policy.title}\n\n${policy.procedure}`, meta: { trigger: policy.trigger, verification: policy.verification, boundary: policy.boundary, gain: policy.gain, support: policy.support, status: policy.status, }, }; return textToolResult(details, details.body); } const wm = await core.getWorldModel(params.id, namespaceFromCtx(ctx)); if (!wm) { const details = { found: false, kind, id: params.id, body: "", meta: {} }; return textToolResult(details, `No ${kind} memory found for id "${params.id}".`); } const details = { found: true, kind, id: wm.id, body: clip(wm.body, bodyCap), meta: { title: wm.title, policyIds: wm.policyIds }, }; return textToolResult(details, `${wm.title}\n\n${details.body}`.trim()); }, }), { name: "memos_get" }, ); // ── memos_timeline ── api.registerTool( (ctx: OpenClawPluginToolContext): AgentToolDescriptor => ({ name: "memos_timeline", label: "Memory Timeline", description: "Return the ordered traces inside a single episode. Useful for reconstructing " + "conversation flow and debugging.", parameters: MemoryTimelineParams, async execute(_toolCallId: string, params: MemoryTimelineParamsT) { const core = await resolveCore(opts); const traces = await core.timeline({ episodeId: params.episodeId as never, namespace: namespaceFromCtx(ctx) }); const limited = traces.slice(0, params.limit ?? 20); const details = { episodeId: params.episodeId, traces: limited.map((t) => ({ id: t.id, ts: t.ts, userText: clip(t.userText, bodyCap), agentText: clip(t.agentText, bodyCap), toolCalls: t.toolCalls.map((tc) => ({ name: tc.name, error: tc.errorCode })), value: t.value, })), }; const text = details.traces.length === 0 ? `No traces found for episode "${params.episodeId}".` : `Episode ${params.episodeId} timeline:\n\n` + details.traces .map((t, i) => `${i + 1}. ${t.userText || t.agentText || t.id}`) .join("\n"); return textToolResult(details, text); }, }), { name: "memos_timeline" }, ); // ── memos_skill_list ── api.registerTool( (ctx: OpenClawPluginToolContext): AgentToolDescriptor => ({ name: "memos_skill_list", label: "Skill List", description: "List callable skills the agent can invoke. Filter by status (candidate | active | archived).", parameters: SkillListParams, async execute(_toolCallId: string, params: SkillListParamsT) { const core = await resolveCore(opts); const skills = await core.listSkills({ status: params.status, limit: params.limit, namespace: namespaceFromCtx(ctx), }); const details = { skills: skills.map((s) => ({ id: s.id, name: s.name, status: s.status, eta: s.eta, support: s.support, gain: s.gain, invocationGuide: clip(s.invocationGuide, bodyCap), })), }; const text = details.skills.length === 0 ? "No skills found." : `Found ${details.skills.length} skills:\n\n` + details.skills.map((s, i) => `${i + 1}. ${s.name} (${s.id}, ${s.status})`).join("\n"); return textToolResult(details, text); }, }), { name: "memos_skill_list" }, ); // ── memos_environment ── // // Dedicated Tier-3 lookup. The turn-start injector already folds // environment knowledge into `prependContext`, but during a long // tool-driven chain the model may want to re-fetch a specific // domain ("what did we learn about this project's build system?") // without re-triggering a full tier1+2+3 search. This tool returns // only the world-model snippets so the agent can inject domain // knowledge on demand. api.registerTool( (ctx: OpenClawPluginToolContext): AgentToolDescriptor => ({ name: "memos_environment", label: "Environment Knowledge", description: "Return the agent's accumulated environment knowledge (L3 world models) — " + "structural facts, behavioural rules and constraints learnt across episodes. " + "Use this before deciding how to navigate an unfamiliar project: you already " + "know where code lives, which commands run, and what to avoid.", parameters: EnvironmentQueryParams, async execute(_toolCallId: string, params: EnvironmentQueryParamsT) { const query = (params.query ?? "").trim(); const cap = Math.min(Math.max(1, params.limit ?? 5), 30); // No query → return the most recently updated world models // directly. Avoids paying for an LLM filter pass when the // agent just wants a quick "what do we know about here?" // dump. const core = await resolveCore(opts); if (!query) { const rows = await core.listWorldModels({ limit: cap, offset: 0, namespace: namespaceFromCtx(ctx) }); const details = { worldModels: rows.map((w) => ({ id: w.id, title: w.title, body: clip(w.body, bodyCap), policyIds: w.policyIds, updatedAt: w.updatedAt, })), queried: false, }; const text = details.worldModels.length === 0 ? "No environment knowledge found." : `Environment knowledge:\n\n` + details.worldModels.map((w, i) => `${i + 1}. ${w.title}\n${w.body}`).join("\n\n"); return textToolResult(details, text); } // With a query, go through `searchMemory` so tag filters + // cosine ranking apply, then keep only the tier-3 hits. const res = await core.searchMemory({ agent: opts.agent, namespace: namespaceFromCtx(ctx), query, topK: { tier1: 0, tier2: 0, tier3: cap }, }); const tier3 = res.hits.filter((h) => h.tier === 3); const details = { worldModels: tier3.map((h) => ({ id: h.refId, title: (h.snippet ?? "").split("\n")[0]?.replace(/^World model:\s*/, "") ?? "", body: clip(h.snippet ?? "", bodyCap), policyIds: [], score: h.score, })), queried: true, }; const text = details.worldModels.length === 0 ? "No matching environment knowledge found." : `Environment knowledge for "${query}":\n\n` + details.worldModels.map((w, i) => `${i + 1}. ${w.title}\n${w.body}`).join("\n\n"); return textToolResult(details, text); }, }), { name: "memos_environment" }, ); // ── memos_skill_get ── api.registerTool( (ctx: OpenClawPluginToolContext): AgentToolDescriptor => ({ name: "memos_skill_get", label: "Skill Get", description: "Return the full invocation guide for a crystallized skill.", parameters: SkillGetParams, async execute(toolCallId: string, params: SkillGetParamsT) { const core = await resolveCore(opts); const skill = await core.getSkill(params.id as SkillId, { recordUse: true, recordTrial: true, sessionId: sessionFromCtx(ctx) as never, namespace: namespaceFromCtx(ctx), toolCallId, }); if (!skill) { const details = { found: false, skill: null }; return textToolResult(details, `No skill found for id "${params.id}".`); } const details = { found: true, skill: { id: skill.id, name: skill.name, status: skill.status, eta: skill.eta, gain: skill.gain, support: skill.support, invocationGuide: skill.invocationGuide, sourcePolicyIds: skill.sourcePolicyIds, sourceWorldModelIds: skill.sourceWorldModelIds, createdAt: skill.createdAt, updatedAt: skill.updatedAt, usageCount: skill.usageCount, lastUsedAt: skill.lastUsedAt, }, }; return textToolResult(details, `${skill.name}\n\n${skill.invocationGuide}`.trim()); }, }), { name: "memos_skill_get" }, ); } function topKParams( params: MemorySearchParamsT, maxResults: number | undefined, ): { tier1?: number; tier2?: number; tier3?: number } | undefined { if ( params.tier1topK === undefined && params.tier2topK === undefined && params.tier3topK === undefined && maxResults === undefined ) { return undefined; } return { tier1: params.tier1topK ?? maxResults, tier2: params.tier2topK ?? maxResults, tier3: params.tier3topK ?? maxResults, }; } /** Exposed for tests + documentation. */ export const TOOL_SCHEMAS = { memos_search: MemorySearchParams, memos_get: MemoryGetParams, memos_timeline: MemoryTimelineParams, memos_environment: EnvironmentQueryParams, memos_skill_list: SkillListParams, memos_skill_get: SkillGetParams, } as const;