diff --git a/scripts/build-hooks.js b/scripts/build-hooks.js index 9d978355e..fc597aeb6 100644 --- a/scripts/build-hooks.js +++ b/scripts/build-hooks.js @@ -257,6 +257,10 @@ async function buildHooks() { external: [ 'fs', 'fs/promises', 'path', 'os', 'child_process', 'url', 'crypto', 'http', 'https', 'net', 'stream', 'util', 'events', + // @opencode-ai/plugin is provided by the OpenCode runtime + '@opencode-ai/plugin', + '@opencode-ai/plugin/tool', + 'zod', ], }); diff --git a/src/integrations/opencode-plugin/index.ts b/src/integrations/opencode-plugin/index.ts index 2133a22ae..55e55d127 100644 --- a/src/integrations/opencode-plugin/index.ts +++ b/src/integrations/opencode-plugin/index.ts @@ -4,91 +4,27 @@ * 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 + * SDK compatibility: @opencode-ai/plugin >= 1.2.23 + * + * Hooks (flat string keys per SDK): + * - "tool.execute.after": Captures tool execution observations + * - "chat.message": Captures assistant responses after each conversation turn + * + * Events (SDK Event objects): + * - session.created: Initialize claude-mem content session + * - message.updated: Capture assistant message observations + * - session.compacted: Trigger session summarization + * - file.edited: Capture file edit observations + * - session.idle: Trigger session completion when a conversation turn finishes + * - session.deleted: Cleanup session on explicit deletion * * Custom tool: * - claude_mem_search: Search memory database from within OpenCode + * (For richer search, configure the claude-mem MCP server in opencode.json) */ -// ============================================================================ -// 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; - }; -} +import type { Plugin, PluginInput, Hooks } from "@opencode-ai/plugin"; +import { tool } from "@opencode-ai/plugin"; // ============================================================================ // Constants @@ -101,31 +37,6 @@ const MAX_TOOL_RESPONSE_LENGTH = 1000; // Worker HTTP Client // ============================================================================ -async function workerPost( - path: string, - body: Record, -): Promise | null> { - try { - const response = await fetch(`${WORKER_BASE_URL}${path}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - if (!response.ok) { - console.warn(`[claude-mem] Worker POST ${path} returned ${response.status}`); - return null; - } - return (await response.json()) as Record; - } 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; - } -} - function workerPostFireAndForget( path: string, body: Record, @@ -175,124 +86,126 @@ function getOrCreateContentSessionId(openCodeSessionId: string): string { return contentSessionIdsByOpenCodeSessionId.get(openCodeSessionId)!; } +function truncate(str: string, max: number = MAX_TOOL_RESPONSE_LENGTH): string { + return str.length > max ? str.slice(0, max) : str; +} + // ============================================================================ // Plugin Entry Point // ============================================================================ -export const ClaudeMemPlugin = async (ctx: OpenCodePluginContext) => { +export const ClaudeMemPlugin: Plugin = async (ctx: PluginInput) => { const projectName = ctx.project?.name || "opencode"; console.log(`[claude-mem] OpenCode plugin loading (project: ${projectName})`); return { // ------------------------------------------------------------------ - // Direct interceptor hooks + // Event handler: receives SDK Event objects + // + // The @opencode-ai/plugin SDK dispatches events as: + // event({ event: Event }) + // where Event has .type (string) and .properties (object). + // + // Event.properties vary by type — see inline comments below. // ------------------------------------------------------------------ - 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, - }); - }, - }, - }, - }, + async event({ event }) { + const type = event.type; + const props = event.properties as Record; - // ------------------------------------------------------------------ - // Bus event handlers - // ------------------------------------------------------------------ - event: (eventName: string, payload: unknown) => { - switch (eventName) { + switch (type) { case "session.created": { - const { event } = payload as SessionCreatedEvent; - const contentSessionId = getOrCreateContentSessionId(event.sessionID); + // props.info is a Session object with .id, .title, etc. + const info = props.info; + const contentSessionId = getOrCreateContentSessionId(info.id); + console.log(`[claude-mem] session.created: ${info.id} → ${contentSessionId}`); workerPostFireAndForget("/api/sessions/init", { contentSessionId, project: projectName, - prompt: "", + prompt: info.title || "", }); 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); - } - + // props.info is a Message object with .role, .id, etc. + // NOTE: assistant message events can be high-volume with low signal. + // If you experience worker queue congestion, consider disabling this + // handler. Tool executions and chat.message hook already capture + // the substantive content. + const info = props.info; + if (info.role !== "assistant") break; + + const contentSessionId = getOrCreateContentSessionId(props.sessionID); + // Message content may not be directly available on the event; + // use a placeholder. The chat.message hook captures full text. workerPostFireAndForget("/api/sessions/observations", { contentSessionId, tool_name: "assistant_message", tool_input: {}, - tool_response: messageText, + tool_response: truncate(String(info.content || "")), cwd: ctx.directory, }); break; } case "session.compacted": { - const { event } = payload as SessionCompactedEvent; - const contentSessionId = getOrCreateContentSessionId(event.sessionID); + // Experimental event: fired when session context is compacted. + // props.sessionID is available. + const contentSessionId = getOrCreateContentSessionId(props.sessionID); workerPostFireAndForget("/api/sessions/summarize", { contentSessionId, - last_assistant_message: event.summary || "", + last_assistant_message: "", }); break; } case "file.edited": { - const { event } = payload as FileEditedEvent; - const contentSessionId = getOrCreateContentSessionId(event.sessionID); + // props.file is the file path (string). + // This event doesn't carry a sessionID, so we skip it. + // File edits are already captured via tool.execute.after hook + // when the agent uses file editing tools. + break; + } - 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, - }); + case "session.idle": { + // Fired when a session transitions from busy → idle (conversation + // turn finished). This is the best signal for session completion + // in OpenCode, since there is no explicit "session.completed" event. + // + // WORKAROUND: OpenCode does not fire a "session.completed" event. + // session.idle fires after each conversation turn finishes, which + // is the closest equivalent. This may fire multiple times per + // session (once per turn), but the worker handles duplicate + // completion calls gracefully (idempotent). + const sid = props.sessionID; + const contentSessionId = contentSessionIdsByOpenCodeSessionId.get(sid); + if (contentSessionId) { + workerPostFireAndForget("/api/sessions/summarize", { + contentSessionId, + last_assistant_message: "", + }); + workerPostFireAndForget("/api/sessions/complete", { + contentSessionId, + }); + } break; } case "session.deleted": { - const { event } = payload as SessionDeletedEvent; + // props.info is a Session object with .id + const info = props.info; const contentSessionId = contentSessionIdsByOpenCodeSessionId.get( - event.sessionID, + info.id, ); if (contentSessionId) { workerPostFireAndForget("/api/sessions/complete", { contentSessionId, }); - contentSessionIdsByOpenCodeSessionId.delete(event.sessionID); + contentSessionIdsByOpenCodeSessionId.delete(info.id); } break; } @@ -300,28 +213,92 @@ export const ClaudeMemPlugin = async (ctx: OpenCodePluginContext) => { }, // ------------------------------------------------------------------ - // Custom tools + // Hook: tool.execute.after (flat string key per SDK) + // + // Captures every tool execution as an observation. + // ------------------------------------------------------------------ + "tool.execute.after": async (input, output) => { + const contentSessionId = getOrCreateContentSessionId(input.sessionID); + + workerPostFireAndForget("/api/sessions/observations", { + contentSessionId, + tool_name: input.tool, + tool_input: input.args || {}, + tool_response: truncate(output.output || ""), + cwd: ctx.directory, + }); + }, + + // ------------------------------------------------------------------ + // Hook: chat.message (flat string key per SDK) + // + // Captures the assistant's response after each conversation turn. + // This provides richer content than message.updated events since + // it includes the fully assembled response parts. + // ------------------------------------------------------------------ + "chat.message": async (input, output) => { + const contentSessionId = getOrCreateContentSessionId(input.sessionID); + + // Extract text from assistant response parts + const textParts = (output.parts || []) + .filter((p) => p.type === "text") + .map((p) => "text" in p ? String(p.text) : "") + .join("\n"); + + if (textParts) { + workerPostFireAndForget("/api/sessions/observations", { + contentSessionId, + tool_name: "assistant_response", + tool_input: { messageID: input.messageID }, + tool_response: truncate(textParts, 2000), + cwd: ctx.directory, + }); + } + + // Init session on first message if not already initialized + // (covers the case where session.created event was missed) + if (!contentSessionIdsByOpenCodeSessionId.has(input.sessionID)) { + getOrCreateContentSessionId(input.sessionID); + const userContent = output.message?.content; + const userText = typeof userContent === "string" + ? userContent + : Array.isArray(userContent) + ? userContent.filter((c: any) => c.type === "text").map((c: any) => c.text).join("\n") + : ""; + + workerPostFireAndForget("/api/sessions/init", { + contentSessionId, + project: projectName, + prompt: truncate(userText, 500), + }); + } + }, + + // ------------------------------------------------------------------ + // Custom tool: claude_mem_search + // + // Provides basic memory search directly within OpenCode. + // For a richer search experience with timeline navigation and + // observation details, configure the claude-mem MCP server + // in your opencode.json instead. // ------------------------------------------------------------------ tool: { - claude_mem_search: { + claude_mem_search: tool({ description: "Search claude-mem memory database for past observations, sessions, and context", args: { - query: { - type: "string", - description: "Search query for memory observations", - }, + query: tool.schema.string().describe( + "Search query for memory observations", + ), }, - async execute( - args: Record, - ): Promise { - const query = String(args.query || ""); + async execute(args, _context) { + const query = args.query; if (!query) { return "Please provide a search query."; } const text = await workerGetText( - `/api/search/observations?query=${encodeURIComponent(query)}&limit=10`, + `/api/search?query=${encodeURIComponent(query)}&limit=10`, ); if (!text) { @@ -330,6 +307,17 @@ export const ClaudeMemPlugin = async (ctx: OpenCodePluginContext) => { try { const data = JSON.parse(text); + + // Handle MCP format: { content: [{ type: 'text', text: '...' }] } + if (data.content && Array.isArray(data.content)) { + const resultText = data.content + .map((c: { text?: string }) => c.text || "") + .join("\n") + .trim(); + return resultText || `No results found for "${query}".`; + } + + // Handle legacy format: { items: [...] } const items = Array.isArray(data.items) ? data.items : []; if (items.length === 0) { return `No results found for "${query}".`; @@ -339,7 +327,9 @@ export const ClaudeMemPlugin = async (ctx: OpenCodePluginContext) => { .slice(0, 10) .map((item: Record, index: number) => { const title = String(item.title || item.subtitle || "Untitled"); - const project = item.project ? ` [${String(item.project)}]` : ""; + const project = item.project + ? ` [${String(item.project)}]` + : ""; return `${index + 1}. ${title}${project}`; }) .join("\n"); @@ -347,9 +337,9 @@ export const ClaudeMemPlugin = async (ctx: OpenCodePluginContext) => { return "Failed to parse search results."; } }, - } satisfies ToolDefinition, + }), }, - }; + } satisfies Hooks; }; export default ClaudeMemPlugin;