feat: ship OpenCode capture plugin in deploy/opencode/
This commit is contained in:
parent
47c4c760be
commit
8116a5643f
2 changed files with 163 additions and 2 deletions
40
README.md
40
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
|
- macOS (Apple Silicon tested) or Linux
|
||||||
- Python 3.11 or 3.12
|
- Python 3.11 or 3.12
|
||||||
- Node.js 18+
|
- 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
|
- ~500 MB free disk
|
||||||
|
|
||||||
### Install
|
### 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`.
|
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
|
### Verify
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -140,7 +174,7 @@ iai-mcp doctor
|
||||||
iai-mcp daemon status
|
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
|
```bash
|
||||||
tail ~/.iai-mcp/logs/capture-$(date -u +%Y-%m-%d).log
|
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.
|
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.
|
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.
|
Other MCP-over-stdio hosts speak the same protocol and should work in principle. Not tested.
|
||||||
|
|
|
||||||
125
deploy/opencode/iai-mcp-capture.js
Normal file
125
deploy/opencode/iai-mcp-capture.js
Normal file
|
|
@ -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":"<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}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
Loading…
Add table
Add a link
Reference in a new issue