/** * 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}`); } }, }; };