refactor(memory-claudemem): switch to hook CLI implementation

- Replace HTTP client approach with direct hook CLI spawning
- Auto-discover worker-service.cjs from Claude plugin cache
- Sync MEMORY.md on session and gateway start
- Handle custom project names via temp directories
- Fire-and-forget observation recording for performance
- Remove unused client.ts, config.ts, types.ts
This commit is contained in:
Alex Newman 2026-01-28 23:29:08 -05:00
parent cc60285f49
commit 436656058f
6 changed files with 385 additions and 426 deletions

View File

@ -1,27 +0,0 @@
{
"id": "memory-claudemem",
"name": "Memory (Claude-Mem)",
"description": "Real-time observation and memory via claude-mem worker",
"kind": "memory",
"version": "0.0.1",
"uiHints": {
"workerUrl": {
"label": "Worker URL",
"placeholder": "http://localhost:37777",
"help": "URL of the claude-mem worker"
},
"workerTimeout": {
"label": "Timeout (ms)",
"placeholder": "10000",
"advanced": true
}
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"workerUrl": { "type": "string", "default": "http://localhost:37777" },
"workerTimeout": { "type": "number", "default": 10000 }
}
}
}

View File

@ -1,143 +0,0 @@
import type { SearchResult, Observation } from "./types.js";
export class ClaudeMemClient {
constructor(
private readonly baseUrl: string,
private readonly timeout: number,
) {}
/**
* Check if the claude-mem worker is healthy.
* Returns true if the worker responds to health check, false otherwise.
*/
async ping(): Promise<boolean> {
try {
const response = await fetch(`${this.baseUrl}/api/health`, {
method: "GET",
signal: AbortSignal.timeout(this.timeout),
});
return response.ok;
} catch {
return false;
}
}
/**
* Record an observation to the claude-mem worker.
* Fire-and-forget pattern: errors are logged but do not block.
*/
async observe(
claudeSessionId: string,
toolName: string,
toolInput: unknown,
toolResponse: unknown,
cwd?: string,
): Promise<void> {
try {
const body = {
claudeSessionId,
tool_name: toolName,
tool_input: toolInput,
tool_response: toolResponse,
...(cwd && { cwd }),
};
await fetch(`${this.baseUrl}/api/sessions/observations`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(this.timeout),
});
// Fire-and-forget: we don't check response status or throw on errors
} catch {
// Silently ignore errors - worker may be offline
// This is intentional: observations should never block the main flow
}
}
/**
* Search observations by query.
* Returns an array of search results with id, title, snippet, and score.
*/
async search(query: string, limit = 10): Promise<SearchResult[]> {
try {
const searchParams = new URLSearchParams({
query,
type: "observations",
format: "index",
limit: String(limit),
});
const response = await fetch(
`${this.baseUrl}/api/search?${searchParams.toString()}`,
{
method: "GET",
signal: AbortSignal.timeout(this.timeout),
},
);
if (!response.ok) {
return [];
}
const data = await response.json();
// The API returns results in an array format
if (Array.isArray(data)) {
return data as SearchResult[];
}
// Handle wrapped response format { results: [...] }
if (data && Array.isArray(data.results)) {
return data.results as SearchResult[];
}
return [];
} catch {
return [];
}
}
/**
* Fetch full observation details by IDs.
* Returns observations with narrative, files_modified, tool_name, tool_input, tool_response, and created_at_epoch.
*/
async getObservations(ids: number[]): Promise<Observation[]> {
if (ids.length === 0) {
return [];
}
try {
const response = await fetch(`${this.baseUrl}/api/observations/batch`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ ids }),
signal: AbortSignal.timeout(this.timeout),
});
if (!response.ok) {
return [];
}
const data = await response.json();
// Handle direct array response
if (Array.isArray(data)) {
return data as Observation[];
}
// Handle wrapped response format { observations: [...] }
if (data && Array.isArray(data.observations)) {
return data.observations as Observation[];
}
return [];
} catch {
return [];
}
}
}

View File

@ -1,49 +0,0 @@
export type ClaudeMemConfig = {
workerUrl: string;
workerTimeout: number;
};
const DEFAULT_WORKER_URL = "http://localhost:37777";
const DEFAULT_TIMEOUT = 10000;
function assertAllowedKeys(
value: Record<string, unknown>,
allowed: string[],
label: string,
) {
const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
if (unknown.length === 0) return;
throw new Error(`${label} has unknown keys: ${unknown.join(", ")}`);
}
export const claudeMemConfigSchema = {
parse(value: unknown): ClaudeMemConfig {
if (!value || typeof value !== "object" || Array.isArray(value)) {
// Return defaults if no config provided
return {
workerUrl: DEFAULT_WORKER_URL,
workerTimeout: DEFAULT_TIMEOUT,
};
}
const cfg = value as Record<string, unknown>;
assertAllowedKeys(cfg, ["workerUrl", "workerTimeout"], "claude-mem config");
return {
workerUrl: typeof cfg.workerUrl === "string" ? cfg.workerUrl : DEFAULT_WORKER_URL,
workerTimeout: typeof cfg.workerTimeout === "number" ? cfg.workerTimeout : DEFAULT_TIMEOUT,
};
},
uiHints: {
workerUrl: {
label: "Worker URL",
placeholder: DEFAULT_WORKER_URL,
help: "URL of the claude-mem worker",
},
workerTimeout: {
label: "Timeout (ms)",
placeholder: String(DEFAULT_TIMEOUT),
advanced: true,
help: "Request timeout in milliseconds",
},
},
};

View File

@ -1,195 +1,365 @@
/**
* claude-mem Moltbot Plugin
*
* Integrates claude-mem memory system using the official hook CLI.
* All operations go through the worker-service.cjs hook interface
* to ensure proper UI broadcasts and consistency with Claude Code.
*/
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { Type } from "@sinclair/typebox"; import { writeFile, readdir, stat, mkdir } from "fs/promises";
import { claudeMemConfigSchema } from "./config.js"; import { join, basename } from "path";
import { ClaudeMemClient } from "./client.js"; import { homedir, tmpdir } from "os";
import { spawn } from "child_process";
import { existsSync } from "fs";
const claudeMemPlugin = { /**
id: "memory-claudemem", * Find the latest claude-mem version in the plugin cache.
name: "Memory (Claude-Mem)", * Returns the path to worker-service.cjs or null if not found.
description: "Real-time observation and memory via claude-mem worker", */
kind: "memory" as const, async function findWorkerServicePath(): Promise<string | null> {
configSchema: claudeMemConfigSchema, const cacheDir = join(homedir(), ".claude/plugins/cache/thedotmack/claude-mem");
if (!existsSync(cacheDir)) {
return null;
}
register(api: ClawdbotPluginApi) { try {
const cfg = claudeMemConfigSchema.parse(api.pluginConfig); const entries = await readdir(cacheDir);
const client = new ClaudeMemClient(cfg.workerUrl, cfg.workerTimeout);
// Get version directories with their modification times
api.logger.info( const versionDirs: { version: string; mtime: number }[] = [];
`memory-claudemem: plugin registered (worker: ${cfg.workerUrl})`, for (const entry of entries) {
); const fullPath = join(cacheDir, entry);
const workerPath = join(fullPath, "scripts/worker-service.cjs");
// Hook: after_tool_call → Observe tool calls (fire-and-forget) if (existsSync(workerPath)) {
api.on("after_tool_call", async (event, ctx) => { const stats = await stat(fullPath);
// Skip memory tools to prevent recursion versionDirs.push({ version: entry, mtime: stats.mtimeMs });
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 if (versionDirs.length === 0) {
api.on("before_agent_start", async (event) => { return null;
// Skip if prompt is empty or too short }
if (!event.prompt || event.prompt.length < 5) return;
try { // Sort by modification time (newest first)
const results = await client.search(event.prompt, 5); versionDirs.sort((a, b) => b.mtime - a.mtime);
if (results.length === 0) return;
return join(cacheDir, versionDirs[0].version, "scripts/worker-service.cjs");
} catch {
return null;
}
}
const memoryContext = results // Will be initialized on plugin load
.map((r) => `- [#${r.id}] ${r.title}: ${r.snippet}`) let WORKER_SERVICE_PATH: string | null = null;
.join("\n");
api.logger.info?.( /**
`memory-claudemem: injecting ${results.length} memories into context`, * Get the cwd to use for claude-mem hooks.
); * Claude-mem derives project name from cwd basename, so if we have a custom
* project name, we need to use a directory with that name.
*/
async function getHookCwd(workspaceDir: string, project: string): Promise<string> {
const workspaceName = basename(workspaceDir);
// If project matches workspace name, use workspace directly
if (project === workspaceName) {
return workspaceDir;
}
// Create a temp directory with the project name for claude-mem to use
const projectDir = join(tmpdir(), "claude-mem-projects", project);
if (!existsSync(projectDir)) {
await mkdir(projectDir, { recursive: true });
}
return projectDir;
}
return { interface ClaudeMemConfig {
prependContext: `<claude-mem-context>\nThe following memories may be relevant:\n${memoryContext}\n</claude-mem-context>`, syncMemoryFile: boolean;
}; project: string;
} catch (err) { }
api.logger.warn?.(`memory-claudemem: context injection failed: ${err}`);
}
});
// Phase 5: Tool registration const DEFAULT_CONFIG: Omit<ClaudeMemConfig, "project"> = {
// memory_search - Layer 1: compact results (~50-100 tokens per result) syncMemoryFile: true,
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" },
);
// Phase 6: CLI registration
api.registerCli(
({ program }) => {
const claudeMem = program
.command("claude-mem")
.description("Claude-mem memory plugin commands");
claudeMem
.command("status")
.description("Check if the claude-mem worker is responding")
.action(async () => {
const isHealthy = await client.ping();
if (isHealthy) {
console.log(`✓ Worker running at ${cfg.workerUrl}`);
} else {
console.log(`✗ Worker not responding at ${cfg.workerUrl}`);
process.exitCode = 1;
}
});
claudeMem
.command("search")
.description("Search memories")
.argument("<query>", "Search query")
.option("--limit <n>", "Max results", "10")
.action(async (query, opts) => {
const results = await client.search(query, parseInt(opts.limit));
console.log(JSON.stringify(results, null, 2));
});
},
{ commands: ["claude-mem"] },
);
},
}; };
export default claudeMemPlugin; /**
* Call the claude-mem hook CLI with JSON piped to stdin.
* Returns the parsed JSON output (or null on failure).
*/
function callHook(
hookName: string,
data: Record<string, unknown>
): Promise<Record<string, unknown> | null> {
return new Promise((resolve) => {
if (!WORKER_SERVICE_PATH) {
console.error(`[claude-mem] hook ${hookName} failed: worker-service.cjs not found`);
resolve(null);
return;
}
try {
const proc = spawn("bun", [WORKER_SERVICE_PATH, "hook", "claude-code", hookName], {
stdio: ["pipe", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
proc.stdout.on("data", (chunk) => {
stdout += chunk.toString();
});
proc.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
proc.stdin.write(JSON.stringify(data));
proc.stdin.end();
proc.on("close", (code) => {
if (code !== 0) {
console.error(`[claude-mem] hook ${hookName} failed (code ${code}): ${stderr}`);
resolve(null);
return;
}
try {
resolve(JSON.parse(stdout));
} catch {
resolve(null);
}
});
proc.on("error", (err) => {
console.error(`[claude-mem] hook ${hookName} error:`, err);
resolve(null);
});
// Timeout after 30s
setTimeout(() => {
proc.kill();
resolve(null);
}, 30000);
} catch (err) {
console.error(`[claude-mem] hook ${hookName} spawn error:`, err);
resolve(null);
}
});
}
/**
* Fire-and-forget hook call (doesn't wait for output).
*/
function callHookFireAndForget(hookName: string, data: Record<string, unknown>): void {
if (!WORKER_SERVICE_PATH) {
console.error(`[claude-mem] hook ${hookName} failed: worker-service.cjs not found`);
return;
}
try {
const proc = spawn("bun", [WORKER_SERVICE_PATH, "hook", "claude-code", hookName], {
stdio: ["pipe", "ignore", "ignore"],
detached: true,
});
proc.stdin.write(JSON.stringify(data));
proc.stdin.end();
proc.unref();
} catch (err) {
console.error(`[claude-mem] hook ${hookName} fire-and-forget error:`, err);
}
}
export default function (api: ClawdbotPluginApi) {
// Find the latest claude-mem worker service (sync check)
const cacheDir = join(homedir(), ".claude/plugins/cache/thedotmack/claude-mem");
if (!existsSync(cacheDir)) {
api.logger.warn?.("claude-mem: plugin cache not found - plugin disabled");
return;
}
// Find latest version synchronously
try {
const entries = require("fs").readdirSync(cacheDir);
let latestVersion: string | null = null;
let latestMtime = 0;
for (const entry of entries) {
const fullPath = join(cacheDir, entry);
const workerPath = join(fullPath, "scripts/worker-service.cjs");
if (existsSync(workerPath)) {
const stats = require("fs").statSync(fullPath);
if (stats.mtimeMs > latestMtime) {
latestMtime = stats.mtimeMs;
latestVersion = entry;
}
}
}
if (latestVersion) {
WORKER_SERVICE_PATH = join(cacheDir, latestVersion, "scripts/worker-service.cjs");
}
} catch (err) {
api.logger.warn?.("claude-mem: failed to find worker service", err);
}
if (!WORKER_SERVICE_PATH) {
api.logger.warn?.("claude-mem: worker-service.cjs not found - plugin disabled");
return;
}
api.logger.debug?.(`claude-mem: using worker at ${WORKER_SERVICE_PATH}`);
const workspaceDir = api.workspaceDir || process.cwd();
const defaultProject = basename(workspaceDir);
const userConfig = api.pluginConfig as Partial<ClaudeMemConfig>;
const config: ClaudeMemConfig = {
...DEFAULT_CONFIG,
project: defaultProject,
...userConfig,
};
api.logger.info(`claude-mem: initializing (project: ${config.project})`);
// Get the cwd to use for hooks (handles custom project names)
// Create temp dir synchronously if needed
let hookCwd = workspaceDir;
if (config.project !== defaultProject) {
const projectDir = join(tmpdir(), "claude-mem-projects", config.project);
if (!existsSync(projectDir)) {
require("fs").mkdirSync(projectDir, { recursive: true });
}
hookCwd = projectDir;
}
api.logger.debug?.(`claude-mem: using hook cwd: ${hookCwd}`);
// Track contentSessionId per moltbot session
const sessionIds = new Map<string, string>();
// Track whether we've synced MEMORY.md for each session
const syncedSessions = new Set<string>();
function getContentSessionId(sessionKey: string | undefined): string {
const key = sessionKey || "default";
if (!sessionIds.has(key)) {
sessionIds.set(key, `moltbot-${key}-${Date.now()}`);
}
return sessionIds.get(key)!;
}
// before_agent_start → sync MEMORY.md (once per session) + record prompt
api.on("before_agent_start", async (event, ctx) => {
const sessionKey = ctx.sessionKey || "default";
const contentSessionId = getContentSessionId(ctx.sessionKey);
// Sync MEMORY.md once per session (on first agent start)
if (config.syncMemoryFile && !syncedSessions.has(sessionKey)) {
syncedSessions.add(sessionKey);
try {
const result = await callHook("context", { cwd: hookCwd });
if (result) {
const context =
(result as any)?.hookSpecificOutput?.additionalContext ||
(result as any)?.additionalContext;
if (context && typeof context === "string") {
const memoryPath = join(workspaceDir, "MEMORY.md");
await writeFile(memoryPath, context, "utf-8");
api.logger.info?.(`claude-mem: updated MEMORY.md`);
}
}
} catch (error) {
api.logger.warn?.("claude-mem: failed to sync MEMORY.md", error);
}
}
// Record prompt via session-init hook
if (!event.prompt || event.prompt.length < 10) return;
const result = await callHook("session-init", {
session_id: contentSessionId,
prompt: event.prompt,
cwd: hookCwd,
});
if (result) {
api.logger.debug?.(`claude-mem: prompt recorded for session ${contentSessionId}`);
}
});
// tool_result_persist → hook claude-code observation (record tool call)
api.on("tool_result_persist", (event, ctx) => {
const toolName = event.toolName;
if (!toolName) return;
// Skip memory tools to prevent recursion
const skipTools = new Set([
"memory_search",
"memory_status",
"memory_record",
"mcp__plugin_claude-mem_mcp-search__search",
"mcp__plugin_claude-mem_mcp-search__timeline",
"mcp__plugin_claude-mem_mcp-search__get_observations",
]);
if (skipTools.has(toolName)) return;
const contentSessionId = getContentSessionId(ctx.sessionKey);
// Extract tool result text
const message = event.message;
const content = message?.content;
let resultText: string | undefined;
if (Array.isArray(content)) {
const textBlock = content.find(
(c: any) => c.type === "tool_result" || c.type === "text"
);
if (textBlock && "text" in textBlock) {
resultText = String(textBlock.text).slice(0, 1000);
}
}
// Fire-and-forget observation recording
callHookFireAndForget("observation", {
session_id: contentSessionId,
tool_name: toolName,
tool_input: event.params || {},
tool_response: resultText || "",
cwd: hookCwd,
});
});
// agent_end → hook claude-code summarize (generate session summary)
api.on("agent_end", async (_event, ctx) => {
const contentSessionId = getContentSessionId(ctx.sessionKey);
// Fire-and-forget summary generation
callHookFireAndForget("summarize", {
session_id: contentSessionId,
cwd: hookCwd,
});
});
// gateway_start → sync MEMORY.md when Moltbot starts
api.on("gateway_start", async () => {
if (!config.syncMemoryFile) return;
try {
const result = await callHook("context", { cwd: hookCwd });
if (result) {
const context =
(result as any)?.hookSpecificOutput?.additionalContext ||
(result as any)?.additionalContext;
if (context && typeof context === "string") {
const memoryPath = join(workspaceDir, "MEMORY.md");
await writeFile(memoryPath, context, "utf-8");
api.logger.info?.(`claude-mem: synced MEMORY.md on gateway start`);
}
}
} catch (error) {
api.logger.warn?.("claude-mem: failed to sync MEMORY.md on gateway start", error);
}
});
api.logger.info("claude-mem: plugin registered (using hook CLI)");
}

View File

@ -0,0 +1,32 @@
{
"id": "memory-claudemem",
"name": "Memory (Claude-Mem)",
"description": "Integrates claude-mem memory system using the hook CLI for persistent context across sessions",
"kind": "memory",
"version": "0.1.0",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"project": {
"type": "string",
"description": "Project name for scoping observations (defaults to workspace directory name)"
},
"syncMemoryFile": {
"type": "boolean",
"default": true,
"description": "Sync MEMORY.md with claude-mem context on session start"
}
}
},
"uiHints": {
"project": {
"label": "Project Name",
"help": "Scopes observations to this project. Defaults to workspace directory name."
},
"syncMemoryFile": {
"label": "Sync MEMORY.md",
"help": "Update MEMORY.md with claude-mem context on session/gateway start"
}
}
}

View File

@ -1,24 +0,0 @@
/** Search result from claude-mem worker (index format) */
export type SearchResult = {
id: number;
title: string;
snippet?: string;
score?: number;
};
/** Full observation from claude-mem worker */
export type Observation = {
id: number;
narrative: string;
files_modified?: string[];
tool_name?: string;
tool_input?: unknown;
tool_response?: unknown;
created_at_epoch?: number;
};
/** Plugin configuration */
export type ClaudeMemConfig = {
workerUrl: string;
workerTimeout: number;
};