/** * OpenCode Plugin for claude-mem * * Integrates claude-mem persistent memory with OpenCode (110k+ stars). * Runs inside OpenCode's Bun-based plugin runtime. * * Plugin hooks: * - tool.execute.after: Captures tool execution observations * - Bus events: session.created, message.updated, session.compacted, * file.edited, session.deleted * * Custom tool: * - claude_mem_search: Search memory database from within OpenCode */ // ============================================================================ // Minimal type declarations for OpenCode Plugin SDK // These match the runtime API provided by @opencode-ai/plugin // ============================================================================ interface OpenCodeProject { name?: string; path?: string; } interface OpenCodePluginContext { client: unknown; project: OpenCodeProject; directory: string; worktree: string; serverUrl: URL; $: unknown; // BunShell } interface ToolExecuteAfterInput { tool: string; sessionID: string; callID: string; args: Record; } interface ToolExecuteAfterOutput { title: string; output: string; metadata: Record; } interface ToolDefinition { description: string; args: Record; execute: (args: Record, context: unknown) => Promise; } // Bus event payloads interface SessionCreatedEvent { event: { sessionID: string; directory?: string; project?: string; }; } interface MessageUpdatedEvent { event: { sessionID: string; role: string; content: string; }; } interface SessionCompactedEvent { event: { sessionID: string; summary?: string; messageCount?: number; }; } interface FileEditedEvent { event: { sessionID: string; path: string; diff?: string; }; } interface SessionDeletedEvent { event: { sessionID: string; }; } // ============================================================================ // Constants // ============================================================================ const WORKER_BASE_URL = "http://127.0.0.1:37777"; const MAX_TOOL_RESPONSE_LENGTH = 1000; // ============================================================================ // Auth Token (reads from DATA_DIR/worker-auth-token) // ============================================================================ import { readFileSync, existsSync } from "fs"; import { join } from "path"; import { homedir } from "os"; let cachedAuthToken: string | null = null; function getAuthToken(): string | null { if (cachedAuthToken) return cachedAuthToken; const tokenPath = join( process.env.CLAUDE_MEM_DATA_DIR || join(homedir(), ".claude-mem"), "worker-auth-token", ); if (!existsSync(tokenPath)) return null; const token = readFileSync(tokenPath, "utf-8").trim(); if (token.length >= 32) { cachedAuthToken = token; return token; } return null; } function getAuthHeaders(): Record { const token = getAuthToken(); if (!token) return { "Content-Type": "application/json" }; return { "Content-Type": "application/json", Authorization: `Bearer ${token}` }; } // ============================================================================ // Worker HTTP Client // ============================================================================ async function workerPost( path: string, body: Record, ): Promise | null> { let response: Response; try { response = await fetch(`${WORKER_BASE_URL}${path}`, { method: "POST", headers: getAuthHeaders(), body: JSON.stringify(body), }); } catch (error: unknown) { // Gracefully handle ECONNREFUSED — worker may not be running const message = error instanceof Error ? error.message : String(error); if (!message.includes("ECONNREFUSED")) { console.warn(`[claude-mem] Worker POST ${path} failed: ${message}`); } return null; } if (!response.ok) { console.warn(`[claude-mem] Worker POST ${path} returned ${response.status}`); return null; } return (await response.json()) as Record; } function workerPostFireAndForget( path: string, body: Record, ): void { fetch(`${WORKER_BASE_URL}${path}`, { method: "POST", headers: getAuthHeaders(), body: JSON.stringify(body), }).catch((error: unknown) => { const message = error instanceof Error ? error.message : String(error); if (!message.includes("ECONNREFUSED")) { console.warn(`[claude-mem] Worker POST ${path} failed: ${message}`); } }); } async function workerGetText(path: string): Promise { try { const response = await fetch(`${WORKER_BASE_URL}${path}`, { headers: getAuthHeaders() }); if (!response.ok) { console.warn(`[claude-mem] Worker GET ${path} returned ${response.status}`); return null; } return await response.text(); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); if (!message.includes("ECONNREFUSED")) { console.warn(`[claude-mem] Worker GET ${path} failed: ${message}`); } return null; } } // ============================================================================ // Session tracking // ============================================================================ const contentSessionIdsByOpenCodeSessionId = new Map(); const MAX_SESSION_MAP_ENTRIES = 1000; function getOrCreateContentSessionId(openCodeSessionId: string): string { if (!contentSessionIdsByOpenCodeSessionId.has(openCodeSessionId)) { // Evict oldest entries when the map exceeds the cap (Map preserves insertion order) while (contentSessionIdsByOpenCodeSessionId.size >= MAX_SESSION_MAP_ENTRIES) { const oldestKey = contentSessionIdsByOpenCodeSessionId.keys().next().value; if (oldestKey !== undefined) { contentSessionIdsByOpenCodeSessionId.delete(oldestKey); } else { break; } } contentSessionIdsByOpenCodeSessionId.set( openCodeSessionId, `opencode-${openCodeSessionId}-${Date.now()}`, ); } return contentSessionIdsByOpenCodeSessionId.get(openCodeSessionId)!; } // ============================================================================ // Plugin Entry Point // ============================================================================ export const ClaudeMemPlugin = async (ctx: OpenCodePluginContext) => { const projectName = ctx.project?.name || "opencode"; console.log(`[claude-mem] OpenCode plugin loading (project: ${projectName})`); return { // ------------------------------------------------------------------ // Direct interceptor hooks // ------------------------------------------------------------------ hooks: { tool: { execute: { after: ( input: ToolExecuteAfterInput, output: ToolExecuteAfterOutput, ) => { const contentSessionId = getOrCreateContentSessionId(input.sessionID); // Truncate long tool output let toolResponseText = output.output || ""; if (toolResponseText.length > MAX_TOOL_RESPONSE_LENGTH) { toolResponseText = toolResponseText.slice(0, MAX_TOOL_RESPONSE_LENGTH); } workerPostFireAndForget("/api/sessions/observations", { contentSessionId, tool_name: input.tool, tool_input: input.args || {}, tool_response: toolResponseText, cwd: ctx.directory, }); }, }, }, }, // ------------------------------------------------------------------ // Bus event handlers // ------------------------------------------------------------------ event: (eventName: string, payload: unknown) => { switch (eventName) { case "session.created": { const { event } = payload as SessionCreatedEvent; const contentSessionId = getOrCreateContentSessionId(event.sessionID); workerPostFireAndForget("/api/sessions/init", { contentSessionId, project: projectName, prompt: "", }); break; } case "message.updated": { const { event } = payload as MessageUpdatedEvent; // Only capture assistant messages as observations if (event.role !== "assistant") break; const contentSessionId = getOrCreateContentSessionId(event.sessionID); let messageText = event.content || ""; if (messageText.length > MAX_TOOL_RESPONSE_LENGTH) { messageText = messageText.slice(0, MAX_TOOL_RESPONSE_LENGTH); } workerPostFireAndForget("/api/sessions/observations", { contentSessionId, tool_name: "assistant_message", tool_input: {}, tool_response: messageText, cwd: ctx.directory, }); break; } case "session.compacted": { const { event } = payload as SessionCompactedEvent; const contentSessionId = getOrCreateContentSessionId(event.sessionID); workerPostFireAndForget("/api/sessions/summarize", { contentSessionId, last_assistant_message: event.summary || "", }); break; } case "file.edited": { const { event } = payload as FileEditedEvent; const contentSessionId = getOrCreateContentSessionId(event.sessionID); workerPostFireAndForget("/api/sessions/observations", { contentSessionId, tool_name: "file_edit", tool_input: { path: event.path }, tool_response: event.diff ? event.diff.slice(0, MAX_TOOL_RESPONSE_LENGTH) : `File edited: ${event.path}`, cwd: ctx.directory, }); break; } case "session.deleted": { const { event } = payload as SessionDeletedEvent; const contentSessionId = contentSessionIdsByOpenCodeSessionId.get( event.sessionID, ); if (contentSessionId) { workerPostFireAndForget("/api/sessions/complete", { contentSessionId, }); contentSessionIdsByOpenCodeSessionId.delete(event.sessionID); } break; } } }, // ------------------------------------------------------------------ // Custom tools // ------------------------------------------------------------------ tool: { claude_mem_search: { description: "Search claude-mem memory database for past observations, sessions, and context", args: { query: { type: "string", description: "Search query for memory observations", }, }, async execute( args: Record, ): Promise { const query = String(args.query || ""); if (!query) { return "Please provide a search query."; } const text = await workerGetText( `/api/search/observations?query=${encodeURIComponent(query)}&limit=10`, ); if (!text) { return "claude-mem worker is not running. Start it with: npx claude-mem start"; } let data: any; try { data = JSON.parse(text); } catch (error: unknown) { console.warn('[claude-mem] Failed to parse search results:', error instanceof Error ? error.message : String(error)); return "Failed to parse search results."; } const items = Array.isArray(data.items) ? data.items : []; if (items.length === 0) { return `No results found for "${query}".`; } return items .slice(0, 10) .map((item: Record, index: number) => { const title = String(item.title || item.subtitle || "Untitled"); const project = item.project ? ` [${String(item.project)}]` : ""; return `${index + 1}. ${title}${project}`; }) .join("\n"); }, } satisfies ToolDefinition, }, }; }; export default ClaudeMemPlugin;