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:
parent
cc60285f49
commit
436656058f
@ -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 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -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)");
|
||||||
|
}
|
||||||
|
|||||||
32
extensions/memory-claudemem/moltbot.plugin.json
Normal file
32
extensions/memory-claudemem/moltbot.plugin.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
|
||||||
};
|
|
||||||
Loading…
Reference in New Issue
Block a user