- Use MoltbotPluginApi type (not ClawdbotPluginApi which doesn't exist) - Update package.json to use @moltbot/ namespace - Add moltbot.extensions array to package.json - Update version to match moltbot versioning scheme
401 lines
12 KiB
TypeScript
401 lines
12 KiB
TypeScript
/**
|
|
* 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 { MoltbotPluginApi } from "clawdbot/plugin-sdk";
|
|
import { writeFile, readdir, stat, mkdir } from "fs/promises";
|
|
import { join, basename } from "path";
|
|
import { homedir, tmpdir } from "os";
|
|
import { spawn } from "child_process";
|
|
import { existsSync } from "fs";
|
|
|
|
/**
|
|
* Find the latest claude-mem version in the plugin cache.
|
|
* Returns the path to worker-service.cjs or null if not found.
|
|
*/
|
|
async function findWorkerServicePath(): Promise<string | null> {
|
|
const cacheDir = join(homedir(), ".claude/plugins/cache/thedotmack/claude-mem");
|
|
|
|
if (!existsSync(cacheDir)) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const entries = await readdir(cacheDir);
|
|
|
|
// Get version directories with their modification times
|
|
const versionDirs: { version: string; mtime: number }[] = [];
|
|
for (const entry of entries) {
|
|
const fullPath = join(cacheDir, entry);
|
|
const workerPath = join(fullPath, "scripts/worker-service.cjs");
|
|
if (existsSync(workerPath)) {
|
|
const stats = await stat(fullPath);
|
|
versionDirs.push({ version: entry, mtime: stats.mtimeMs });
|
|
}
|
|
}
|
|
|
|
if (versionDirs.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
// Sort by modification time (newest first)
|
|
versionDirs.sort((a, b) => b.mtime - a.mtime);
|
|
|
|
return join(cacheDir, versionDirs[0].version, "scripts/worker-service.cjs");
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Will be initialized on plugin load
|
|
let WORKER_SERVICE_PATH: string | null = null;
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
interface ClaudeMemConfig {
|
|
syncMemoryFile: boolean;
|
|
project: string;
|
|
workerPath?: string; // Custom path to worker-service.cjs (for manual installs)
|
|
}
|
|
|
|
const DEFAULT_CONFIG: Omit<ClaudeMemConfig, "project"> = {
|
|
syncMemoryFile: true,
|
|
};
|
|
|
|
/**
|
|
* 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: MoltbotPluginApi) {
|
|
const userConfig = api.pluginConfig as Partial<ClaudeMemConfig>;
|
|
|
|
// Check for custom worker path first (for manual installs)
|
|
if (userConfig.workerPath) {
|
|
if (existsSync(userConfig.workerPath)) {
|
|
WORKER_SERVICE_PATH = userConfig.workerPath;
|
|
api.logger.debug?.(`claude-mem: using custom worker path: ${WORKER_SERVICE_PATH}`);
|
|
} else {
|
|
api.logger.warn?.(`claude-mem: custom workerPath not found: ${userConfig.workerPath} - plugin disabled`);
|
|
return;
|
|
}
|
|
} else {
|
|
// Auto-discover from Claude plugin cache
|
|
const cacheDir = join(homedir(), ".claude/plugins/cache/thedotmack/claude-mem");
|
|
if (!existsSync(cacheDir)) {
|
|
api.logger.warn?.("claude-mem: plugin cache not found and no workerPath configured - plugin disabled");
|
|
api.logger.warn?.("claude-mem: install via Claude Code or set plugins.entries.memory-claudemem.config.workerPath");
|
|
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 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);
|
|
|
|
// Extract the last assistant message from the conversation
|
|
let lastAssistantMessage = "";
|
|
if (Array.isArray(event.messages)) {
|
|
for (let i = event.messages.length - 1; i >= 0; i--) {
|
|
const msg = event.messages[i] as any;
|
|
if (msg?.role === "assistant") {
|
|
// Handle different content formats
|
|
if (typeof msg.content === "string") {
|
|
lastAssistantMessage = msg.content;
|
|
} else if (Array.isArray(msg.content)) {
|
|
const textParts = msg.content
|
|
.filter((c: any) => c.type === "text" && c.text)
|
|
.map((c: any) => c.text);
|
|
lastAssistantMessage = textParts.join("\n");
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fire-and-forget summary generation
|
|
callHookFireAndForget("summarize", {
|
|
session_id: contentSessionId,
|
|
last_assistant_message: lastAssistantMessage,
|
|
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)");
|
|
}
|