openclaw/extensions/memory-claudemem/index.ts
Alex Newman 0cefba22fe feat(memory-claudemem): implement progressive disclosure tools
Add two memory tools following claude-mem's 3-layer pattern:
- memory_search: Layer 1, compact results (~50-100 tokens)
  Returns [#id] title for filtering
- memory_observations: Layer 3, full details (~500-1000 tokens)
  Returns narrative with files_modified

Both use @sinclair/typebox for schema definition.

Part of claude-mem integration (Phase 5/7).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 14:10:09 -05:00

165 lines
5.1 KiB
TypeScript

import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { Type } from "@sinclair/typebox";
import { claudeMemConfigSchema } from "./config.js";
import { ClaudeMemClient } from "./client.js";
const claudeMemPlugin = {
id: "memory-claudemem",
name: "Memory (Claude-Mem)",
description: "Real-time observation and memory via claude-mem worker",
kind: "memory" as const,
configSchema: claudeMemConfigSchema,
register(api: ClawdbotPluginApi) {
const cfg = claudeMemConfigSchema.parse(api.pluginConfig);
const client = new ClaudeMemClient(cfg.workerUrl, cfg.workerTimeout);
api.logger.info(
`memory-claudemem: plugin registered (worker: ${cfg.workerUrl})`,
);
// Hook: after_tool_call → Observe tool calls (fire-and-forget)
api.on("after_tool_call", async (event, ctx) => {
// Skip memory tools to prevent recursion
if (event.toolName.startsWith("memory_")) return;
try {
// Fire-and-forget: don't await, let it run in parallel
// Use sessionKey as the session identifier (may be undefined)
client.observe(
ctx.sessionKey ?? "unknown",
event.toolName,
event.params,
event.result,
);
} catch (err) {
api.logger.warn?.(`memory-claudemem: observation failed: ${err}`);
}
});
// Hook: before_agent_start → Context injection from memory search
api.on("before_agent_start", async (event) => {
// Skip if prompt is empty or too short
if (!event.prompt || event.prompt.length < 5) return;
try {
const results = await client.search(event.prompt, 5);
if (results.length === 0) return;
const memoryContext = results
.map((r) => `- [#${r.id}] ${r.title}: ${r.snippet}`)
.join("\n");
api.logger.info?.(
`memory-claudemem: injecting ${results.length} memories into context`,
);
return {
prependContext: `<claude-mem-context>\nThe following memories may be relevant:\n${memoryContext}\n</claude-mem-context>`,
};
} catch (err) {
api.logger.warn?.(`memory-claudemem: context injection failed: ${err}`);
}
});
// Phase 5: Tool registration
// memory_search - Layer 1: compact results (~50-100 tokens per result)
api.registerTool(
{
name: "memory_search",
label: "Memory Search",
description:
"Search past observations. Returns compact results with IDs. Use memory_observations for full details.",
parameters: Type.Object({
query: Type.String({ description: "Search query" }),
limit: Type.Optional(
Type.Number({ description: "Max results (default: 10)" }),
),
}),
async execute(_toolCallId, params) {
const { query, limit = 10 } = params as {
query: string;
limit?: number;
};
const results = await client.search(query, limit);
if (results.length === 0) {
return {
content: [{ type: "text", text: "No relevant memories found." }],
};
}
const text = results.map((r) => `[#${r.id}] ${r.title}`).join("\n");
return {
content: [
{
type: "text",
text: `Found ${results.length} memories:\n\n${text}`,
},
],
};
},
},
{ name: "memory_search" },
);
// memory_observations - Layer 3: full details (~500-1000 tokens per result)
api.registerTool(
{
name: "memory_observations",
label: "Memory Observations",
description:
"Get full details for specific observation IDs. Use after memory_search to filter.",
parameters: Type.Object({
ids: Type.Array(Type.Number(), {
description: "Array of observation IDs to fetch",
}),
}),
async execute(_toolCallId, params) {
const { ids } = params as { ids: number[] };
if (ids.length === 0) {
return {
content: [{ type: "text", text: "No observation IDs provided." }],
};
}
const observations = await client.getObservations(ids);
if (observations.length === 0) {
return {
content: [
{
type: "text",
text: "No observations found for the given IDs.",
},
],
};
}
const text = observations
.map((obs) => {
const filesSection =
obs.files_modified && obs.files_modified.length > 0
? `\n\nFiles: ${obs.files_modified.join(", ")}`
: "";
return `## #${obs.id}\n${obs.narrative}${filesSection}`;
})
.join("\n\n---\n\n");
return {
content: [{ type: "text", text }],
};
},
},
{ name: "memory_observations" },
);
// TODO: Phase 6 - CLI registration
},
};
export default claudeMemPlugin;