diff --git a/README.md b/README.md index 996c887..f2a1b47 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ I built this for myself. It worked. I've been running it daily for months, and n - macOS (Apple Silicon tested) or Linux - Python 3.11 or 3.12 - Node.js 18+ -- [Claude Code](https://docs.claude.com/en/docs/claude-code/overview) as the MCP host +- [Claude Code](https://docs.claude.com/en/docs/claude-code/overview) or [OpenCode](https://opencode.ai) as the MCP host - ~500 MB free disk ### Install @@ -133,6 +133,40 @@ Use the absolute path. `~` and `$HOME` won't expand here. For Claude Desktop (untested), edit `~/Library/Application Support/Claude/claude_desktop_config.json`. +### Install the OpenCode plugin + +This is the OpenCode equivalent of the Claude Code Stop hook. It listens for idle sessions and writes a deferred-capture JSONL file for the daemon to drain. + +```bash +mkdir -p ~/.config/opencode/plugins +cp deploy/opencode/iai-mcp-capture.js ~/.config/opencode/plugins/ +``` + +Make sure `@opencode-ai/plugin` is installed: + +```bash +cd ~/.config/opencode +npm install @opencode-ai/plugin +``` + +### Connect OpenCode + +Add the MCP server to `~/.config/opencode/config.json`: + +```json +{ + "mcp": { + "iai-mcp": { + "type": "local", + "command": ["node", "/absolute/path/to/iai-mcp/mcp-wrapper/dist/index.js"], + "enabled": true + } + } +} +``` + +Use the absolute path to `mcp-wrapper/dist/index.js`. The plugin file (`iai-mcp-capture.js`) must already be in `~/.config/opencode/plugins/`. + ### Verify ```bash @@ -140,7 +174,7 @@ iai-mcp doctor iai-mcp daemon status ``` -Restart Claude Code. Start a session, do some work, exit. Then: +Restart your MCP host (Claude Code or OpenCode). Start a session, do some work, exit. Then: ```bash tail ~/.iai-mcp/logs/capture-$(date -u +%Y-%m-%d).log @@ -328,6 +362,8 @@ Limitations worth knowing about: Claude Code is the primary host, validated in daily use. +OpenCode is supported via the `iai-mcp-capture.js` plugin (see Install the OpenCode plugin above) and MCP server config in `~/.config/opencode/config.json`. + Claude Desktop should work (uses `claude_desktop_config.json` instead of `~/.claude.json`) but hasn't been tested end to end. Other MCP-over-stdio hosts speak the same protocol and should work in principle. Not tested. diff --git a/deploy/opencode/iai-mcp-capture.js b/deploy/opencode/iai-mcp-capture.js new file mode 100644 index 0000000..b066e3c --- /dev/null +++ b/deploy/opencode/iai-mcp-capture.js @@ -0,0 +1,125 @@ +/** + * iai-mcp session capture plugin for OpenCode. + * + * Listens for `session.idle` events, reads the session transcript via the + * SDK client, and writes it as a deferred-capture JSONL file under + * `~/.iai-mcp/.deferred-captures/`. The iai-mcp daemon picks it up on its + * next tick (same path as the Claude Code Stop hook's `--no-spawn` mode). + * + * Fail-safe by design: any exception is caught and logged — the plugin + * never throws and never blocks OpenCode. + * + * Deferred JSONL format (v1, per D7.1-04): + * Line 1: {"version":1,"deferred_at":"","session_id":"","cwd":""} + * Lines 2..N: {"text":"","cue":"session turn ","tier":"episodic","role":"user|assistant","ts":""} + */ + +const DEFERRED_DIR = process.env.HOME + ? `${process.env.HOME}/.iai-mcp/.deferred-captures` + : `${process.cwd()}/.iai-mcp/.deferred-captures`; +const MAX_TURNS = 200; + +function extractText(parts) { + if (!parts || !Array.isArray(parts)) return ""; + const texts = parts + .filter((p) => p.type === "text" && p.text) + .map((p) => p.text); + return texts.join("\n").trim(); +} + +function nowISO() { + return new Date().toISOString(); +} + +async function writeDeferredCapture(client, sessionId, cwd) { + const fs = await import("node:fs"); + const path = await import("node:path"); + + // Ensure deferred directory exists + if (!fs.existsSync(DEFERRED_DIR)) { + fs.mkdirSync(DEFERRED_DIR, { recursive: true }); + } + + const outPath = path.join( + DEFERRED_DIR, + `${sessionId}-${Math.floor(Date.now() / 1000)}.jsonl` + ); + + const header = { + version: 1, + deferred_at: nowISO(), + session_id: sessionId, + cwd: cwd || process.cwd(), + }; + + const lines = [JSON.stringify(header, null, null)]; + + try { + // Fetch session messages via SDK + // The plugin client has the same API as the standalone SDK client + const resp = await client.session.messages({ + path: { id: sessionId }, + }); + const messages = resp.data || []; + + let turnCount = 0; + for (const msg of messages) { + if (turnCount >= MAX_TURNS) break; + turnCount++; + + const role = msg.info?.role; + if (role !== "user" && role !== "assistant") continue; + + const text = extractText(msg.parts); + if (!text) continue; + + const event = { + text, + cue: `session ${sessionId} turn ${turnCount}`, + tier: "episodic", + role, + ts: nowISO(), + }; + lines.push(JSON.stringify(event, null, null)); + } + } catch (err) { + // Session might have been deleted or SDK unavailable — write header only. + // The daemon drain treats header-only files as no-op. + console.error( + `[iai-mcp] session.messages failed for ${sessionId}: ${err.message}` + ); + } + + fs.writeFileSync(outPath, lines.join("\n") + "\n"); + return outPath; +} + +export const IaiMcpCapture = async ({ client, client: { session }, $, directory }) => { + return { + event: async ({ event }) => { + if (event.type !== "session.idle") return; + + const sessionId = event.properties?.sessionID + ? event.properties.sessionID + : event.properties?.sessionId + ? event.properties.sessionId + : event.properties?.session_id; + + if (!sessionId) { + console.error("[iai-mcp] session.idle event missing sessionId"); + return; + } + + try { + const outPath = await writeDeferredCapture( + client, + sessionId, + directory?.path || process.cwd() + ); + } catch (err) { + // NEVER throw — hook is best-effort + console.error(`[iai-mcp] capture failed for ${sessionId}: ${err.message}`); + } + }, + }; +};