iai-mcp-opencode/deploy/opencode/iai-mcp-capture.js

126 lines
3.6 KiB
JavaScript
Raw Normal View History

/**
* 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":"<ISO>","session_id":"<id>","cwd":"<path>"}
* Lines 2..N: {"text":"<verbatim>","cue":"session <id> turn <N>","tier":"episodic","role":"user|assistant","ts":"<ISO>"}
*/
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}`);
}
},
};
};