@yugabytedb/meko-mcp 2.2.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +106 -38
- package/assets/hooks-handlers/lib/capture-cleanup-cli.js +1473 -0
- package/assets/hooks-handlers/lib/capture-notices.js +131 -14
- package/assets/hooks-handlers/lib/capture-state.js +95 -1
- package/assets/hooks-handlers/lib/capture-status-cli.js +6 -2
- package/assets/hooks-handlers/lib/capture.js +1237 -137
- package/assets/hooks-handlers/lib/checkpoint-timer.js +51 -10
- package/assets/hooks-handlers/lib/journal-bridge.js +218 -0
- package/assets/hooks-handlers/lib/kiro-handler.js +26 -4
- package/assets/hooks-handlers/lib/opencode-handler.js +620 -0
- package/assets/hooks-handlers/lib/opencode-normalizer.js +145 -0
- package/assets/hooks-handlers/lib/terminal-handoff.js +10 -1
- package/assets/opencode-plugin/meko.js +213 -0
- package/assets/scripts/meko-statusline.sh +85 -18
- package/assets/skills/meko-mcp-tools/SKILL.md +57 -14
- package/assets/skills/meko-mcp-tools/references/_sections.md +2 -2
- package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +15 -7
- package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +86 -14
- package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +1 -1
- package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +29 -6
- package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +8 -4
- package/assets/skills/meko-mcp-tools/references/tools-overview.md +32 -22
- package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +14 -8
- package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +45 -1
- package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +6 -39
- package/assets/skills/meko-mcp-tools-desktop/SKILL.md +54 -18
- package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +1 -1
- package/assets/skills/meko-mcp-tools-desktop/references/_sections.md +1 -1
- package/assets/skills/meko-mcp-tools-desktop/references/tools-agent-id-conventions.md +8 -6
- package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +95 -33
- package/assets/skills/meko-mcp-tools-desktop/references/tools-known-limitations.md +22 -5
- package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +14 -10
- package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +35 -25
- package/assets/skills/meko-mcp-tools-desktop/references/tools-rag-workflow.md +14 -8
- package/assets/skills/meko-mcp-tools-desktop/references/tools-troubleshooting.md +67 -1
- package/assets/skills/meko-select-datapack-desktop/SKILL.md +4 -4
- package/assets/skills/meko-select-datapack-desktop/references/cookbook.md +5 -5
- package/assets/skills/meko-select-datapack-desktop/references/role-display-future.md +7 -5
- package/assets/skills/meko-select-datapack-desktop/references/troubleshooting.md +4 -4
- package/bin/create-meko-setup.mjs +70 -7
- package/lib/capture-cleanup.mjs +63 -0
- package/lib/clients/claude-code.mjs +1 -0
- package/lib/clients/codex.mjs +80 -35
- package/lib/clients/common.mjs +7 -0
- package/lib/clients/index.mjs +2 -0
- package/lib/clients/kiro.mjs +5 -5
- package/lib/clients/opencode.mjs +532 -0
- package/lib/config.mjs +68 -1
- package/lib/installer.mjs +18 -2
- package/lib/migrate/adapters/claude-code.mjs +11 -2
- package/lib/migrate/adapters/codex.mjs +9 -2
- package/lib/migrate/adapters/memory-md.mjs +27 -8
- package/lib/migrate/adapters/opencode.mjs +148 -0
- package/lib/migrate/claude-parser.mjs +153 -9
- package/lib/migrate/id.mjs +195 -13
- package/lib/migrate/mcp-client.mjs +25 -1
- package/lib/migrate/opencode-normalizer.mjs +132 -0
- package/lib/migrate/orchestrator.mjs +293 -77
- package/lib/migrate/progress.mjs +239 -5
- package/lib/migrate/registry.mjs +72 -0
- package/lib/migrate.mjs +96 -8
- package/package.json +1 -1
- package/scripts/bundle-assets.mjs +4 -0
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
const childProcess = require("child_process");
|
|
4
|
+
const crypto = require("crypto");
|
|
5
|
+
const fs = require("fs");
|
|
6
|
+
const path = require("path");
|
|
7
|
+
const captureState = require("./capture-state");
|
|
8
|
+
const journalBridge = require("./journal-bridge");
|
|
9
|
+
const DEFAULT_ENV_PATH = path.join(__dirname, "..", "..", "opencode-hook-env.json");
|
|
10
|
+
const EVENT_SUBDIR = "opencode-events";
|
|
11
|
+
const DEFAULT_RECOVERY_MAX_TIMERS = 16;
|
|
12
|
+
function captureLib() {
|
|
13
|
+
return require("./capture");
|
|
14
|
+
}
|
|
15
|
+
function loadHookEnvironment(filePath) {
|
|
16
|
+
const target = filePath || process.env.MEKO_OPENCODE_ENV_FILE || DEFAULT_ENV_PATH;
|
|
17
|
+
try {
|
|
18
|
+
const parsed = JSON.parse(fs.readFileSync(target, "utf8"));
|
|
19
|
+
for (const key of [
|
|
20
|
+
"MEKO_MCP_URL",
|
|
21
|
+
"MEKO_API_KEY",
|
|
22
|
+
"MEKO_AGENT_ID",
|
|
23
|
+
"MEKO_MCP_SERVER_NAME",
|
|
24
|
+
"MEKO_WATERMARK_DIR",
|
|
25
|
+
"MEKO_DATAPACK_ID",
|
|
26
|
+
"MEKO_DATAPACK_NAME",
|
|
27
|
+
]) {
|
|
28
|
+
if (typeof parsed[key] === "string" && parsed[key] !== "") {
|
|
29
|
+
process.env[key] = parsed[key];
|
|
30
|
+
} else if (key === "MEKO_API_KEY" && parsed[key] === "") {
|
|
31
|
+
delete process.env[key];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
} catch (err) {
|
|
35
|
+
if (!err || err.code !== "ENOENT") {
|
|
36
|
+
process.stderr.write(`[meko-opencode] Could not read hook environment: ${err.message}\n`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
process.env.MEKO_HOOK_CLIENT = "opencode";
|
|
40
|
+
return target;
|
|
41
|
+
}
|
|
42
|
+
function sha(value) {
|
|
43
|
+
return crypto.createHash("sha256").update(String(value)).digest("hex");
|
|
44
|
+
}
|
|
45
|
+
function safeSessionId(value) {
|
|
46
|
+
const raw = String(value || "").trim();
|
|
47
|
+
if (/^[A-Za-z0-9_.-]+$/.test(raw)) return raw;
|
|
48
|
+
const base = raw.replace(/[^A-Za-z0-9_.-]+/g, "_").replace(/^_+|_+$/g, "") || "opencode";
|
|
49
|
+
return `${base.slice(0, 80)}-${sha(raw).slice(0, 12)}`;
|
|
50
|
+
}
|
|
51
|
+
function eventDir() {
|
|
52
|
+
return path.join(captureState.watermarkDir(), EVENT_SUBDIR);
|
|
53
|
+
}
|
|
54
|
+
function eventPaths(sessionId, dir) {
|
|
55
|
+
const id = safeSessionId(sessionId);
|
|
56
|
+
const d = dir || eventDir();
|
|
57
|
+
return {
|
|
58
|
+
journal: path.join(d, `${id}.jsonl`),
|
|
59
|
+
state: path.join(d, `${id}.state.json`),
|
|
60
|
+
lock: path.join(d, `${id}.lock`),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function userRecord(sessionId, cwd, exchange, observedAtMs) {
|
|
64
|
+
return {
|
|
65
|
+
type: "user",
|
|
66
|
+
uuid: exchange.userId,
|
|
67
|
+
source_message_id: exchange.userId,
|
|
68
|
+
session_id: sessionId,
|
|
69
|
+
cwd,
|
|
70
|
+
timestamp: exchange.timestamp,
|
|
71
|
+
message: { role: "user", content: [{ type: "text", text: exchange.input }] },
|
|
72
|
+
opencode: { source: "snapshot" },
|
|
73
|
+
meko: { observed_at_ms: observedAtMs },
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function assistantRecord(sessionId, cwd, exchange, interrupted = false) {
|
|
77
|
+
const marker = captureLib().INTERRUPTED_ASSISTANT_OUTPUT;
|
|
78
|
+
const prior = typeof exchange.output === "string" ? exchange.output : "";
|
|
79
|
+
const text = interrupted
|
|
80
|
+
? (prior && !prior.includes("assistant response was interrupted")
|
|
81
|
+
? `${prior}\n\n${marker}`
|
|
82
|
+
: (prior || marker))
|
|
83
|
+
: exchange.output;
|
|
84
|
+
const assistantId = exchange.assistantId || `opencode-assistant-${sha(`${sessionId}:${exchange.userId}`).slice(0, 24)}`;
|
|
85
|
+
return {
|
|
86
|
+
type: "assistant",
|
|
87
|
+
uuid: assistantId,
|
|
88
|
+
source_message_id: assistantId,
|
|
89
|
+
session_id: sessionId,
|
|
90
|
+
cwd,
|
|
91
|
+
timestamp: exchange.timestamp,
|
|
92
|
+
message: { role: "assistant", content: [{ type: "text", text }] },
|
|
93
|
+
opencode: { source: "snapshot", interrupted },
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
// session.error holds a partial; idle completes it only if that assistant
|
|
97
|
+
// message is rewritten in place. Otherwise interrupt on a later user or settle.
|
|
98
|
+
const DEFAULT_SETTLE_MS = 30 * 60 * 1000;
|
|
99
|
+
function settleDeadlineMs() {
|
|
100
|
+
// Same-process test override. Other clients' timers keep 30 minutes.
|
|
101
|
+
const raw = Number(process.env.MEKO_OPENCODE_SETTLE_MS);
|
|
102
|
+
return Number.isFinite(raw) && raw >= 0 ? raw : DEFAULT_SETTLE_MS;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function appendCanonicalExchanges(sessionId, cwd, exchanges, opts = {}) {
|
|
106
|
+
sessionId = safeSessionId(sessionId);
|
|
107
|
+
const fromError = Boolean(opts.fromErrorObservation);
|
|
108
|
+
const nowMs = Number.isFinite(opts.nowMs) ? Number(opts.nowMs) : Date.now();
|
|
109
|
+
const dir = opts.eventDir || eventDir();
|
|
110
|
+
const paths = eventPaths(sessionId, dir);
|
|
111
|
+
journalBridge.acquireEventLock(paths.lock, dir);
|
|
112
|
+
try {
|
|
113
|
+
let sessionCreated = false;
|
|
114
|
+
if (journalBridge.journalEmpty(paths.journal)) {
|
|
115
|
+
journalBridge.appendJournal(paths.journal, {
|
|
116
|
+
type: "opencode_session",
|
|
117
|
+
session_id: sessionId,
|
|
118
|
+
cwd,
|
|
119
|
+
timestamp: new Date(nowMs).toISOString(),
|
|
120
|
+
client: "opencode",
|
|
121
|
+
}, dir);
|
|
122
|
+
sessionCreated = true;
|
|
123
|
+
}
|
|
124
|
+
const state = journalBridge.readEventState(paths.state, paths.journal, sessionId, dir);
|
|
125
|
+
if (state.pending && !Number.isFinite(state.pending.first_observed_at_ms)) {
|
|
126
|
+
try {
|
|
127
|
+
state.pending.first_observed_at_ms = fs.statSync(paths.journal).mtimeMs;
|
|
128
|
+
} catch { /* leave null */ }
|
|
129
|
+
}
|
|
130
|
+
const seen = new Set(state.seen_source_ids || []);
|
|
131
|
+
let appended = 0;
|
|
132
|
+
|
|
133
|
+
const finalizeTentativeAsInterrupted = (timestamp) => {
|
|
134
|
+
if (!state.pending) return false;
|
|
135
|
+
const tentative = state.tentative_observation;
|
|
136
|
+
journalBridge.appendJournal(
|
|
137
|
+
paths.journal,
|
|
138
|
+
assistantRecord(
|
|
139
|
+
sessionId,
|
|
140
|
+
cwd,
|
|
141
|
+
{
|
|
142
|
+
userId: state.pending.user_uuid,
|
|
143
|
+
assistantId: tentative ? (tentative.assistant_id || null) : null,
|
|
144
|
+
output: tentative ? (tentative.assistant_text || "") : "",
|
|
145
|
+
timestamp:
|
|
146
|
+
timestamp ||
|
|
147
|
+
(tentative && tentative.observed_at) ||
|
|
148
|
+
new Date(nowMs).toISOString(),
|
|
149
|
+
},
|
|
150
|
+
true,
|
|
151
|
+
),
|
|
152
|
+
dir,
|
|
153
|
+
);
|
|
154
|
+
if (tentative && tentative.assistant_id) seen.add(tentative.assistant_id);
|
|
155
|
+
state.pending = null;
|
|
156
|
+
state.tentative_observation = null;
|
|
157
|
+
return true;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const settleMs = settleDeadlineMs();
|
|
161
|
+
const pendingObservedAtMs =
|
|
162
|
+
state.pending && Number.isFinite(state.pending.first_observed_at_ms)
|
|
163
|
+
? state.pending.first_observed_at_ms
|
|
164
|
+
: null;
|
|
165
|
+
const tentativeObservedAtMs =
|
|
166
|
+
state.tentative_observation &&
|
|
167
|
+
Number.isFinite(state.tentative_observation.observed_at_ms)
|
|
168
|
+
? state.tentative_observation.observed_at_ms
|
|
169
|
+
: null;
|
|
170
|
+
|
|
171
|
+
// Pre-pass: finalize an aged pending turn before the rest of the batch.
|
|
172
|
+
const deadlineBase =
|
|
173
|
+
tentativeObservedAtMs == null ? pendingObservedAtMs
|
|
174
|
+
: pendingObservedAtMs == null ? tentativeObservedAtMs
|
|
175
|
+
: Math.min(tentativeObservedAtMs, pendingObservedAtMs);
|
|
176
|
+
const incomingCompletesPending = Boolean(
|
|
177
|
+
state.pending &&
|
|
178
|
+
exchanges.some((ex) => {
|
|
179
|
+
if (!ex || ex.userId !== state.pending.user_uuid || fromError) return false;
|
|
180
|
+
if (!(ex.output || ex.interrupted)) return false;
|
|
181
|
+
const t = state.tentative_observation;
|
|
182
|
+
if (
|
|
183
|
+
t &&
|
|
184
|
+
(ex.assistantId || null) === (t.assistant_id || null) &&
|
|
185
|
+
(ex.output || "") === (t.assistant_text || "")
|
|
186
|
+
) {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
return true;
|
|
190
|
+
}),
|
|
191
|
+
);
|
|
192
|
+
if (
|
|
193
|
+
state.pending &&
|
|
194
|
+
deadlineBase != null &&
|
|
195
|
+
nowMs - deadlineBase >= settleMs &&
|
|
196
|
+
!incomingCompletesPending
|
|
197
|
+
) {
|
|
198
|
+
if (finalizeTentativeAsInterrupted()) appended += 1;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
for (let i = 0; i < exchanges.length; i++) {
|
|
202
|
+
const exchange = exchanges[i];
|
|
203
|
+
if (!exchange?.userId) continue;
|
|
204
|
+
const userId = exchange.userId;
|
|
205
|
+
const hasAssistantContent = Boolean(exchange.output) || Boolean(exchange.interrupted);
|
|
206
|
+
const isPending = state.pending && state.pending.user_uuid === userId;
|
|
207
|
+
const errorHoldsAssistant = fromError && i === exchanges.length - 1;
|
|
208
|
+
|
|
209
|
+
if (!isPending && seen.has(userId)) continue;
|
|
210
|
+
|
|
211
|
+
if (isPending) {
|
|
212
|
+
if (!hasAssistantContent) {
|
|
213
|
+
if (!Number.isFinite(state.pending.first_observed_at_ms)) {
|
|
214
|
+
state.pending.first_observed_at_ms = nowMs;
|
|
215
|
+
}
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (errorHoldsAssistant) {
|
|
219
|
+
const priorObservedAtMs =
|
|
220
|
+
state.tentative_observation &&
|
|
221
|
+
Number.isFinite(state.tentative_observation.observed_at_ms)
|
|
222
|
+
? state.tentative_observation.observed_at_ms
|
|
223
|
+
: Number.isFinite(state.pending.first_observed_at_ms)
|
|
224
|
+
? state.pending.first_observed_at_ms
|
|
225
|
+
: nowMs;
|
|
226
|
+
state.tentative_observation = {
|
|
227
|
+
assistant_id: exchange.assistantId || null,
|
|
228
|
+
assistant_text: exchange.output || "",
|
|
229
|
+
observed_at: exchange.timestamp || new Date(nowMs).toISOString(),
|
|
230
|
+
observed_at_ms: priorObservedAtMs,
|
|
231
|
+
};
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const t = state.tentative_observation;
|
|
235
|
+
if (
|
|
236
|
+
t &&
|
|
237
|
+
(exchange.assistantId || null) === (t.assistant_id || null) &&
|
|
238
|
+
(exchange.output || "") === (t.assistant_text || "")
|
|
239
|
+
) {
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
journalBridge.appendJournal(
|
|
243
|
+
paths.journal,
|
|
244
|
+
assistantRecord(sessionId, cwd, exchange, Boolean(exchange.interrupted)),
|
|
245
|
+
dir,
|
|
246
|
+
);
|
|
247
|
+
if (exchange.assistantId) seen.add(exchange.assistantId);
|
|
248
|
+
state.pending = null;
|
|
249
|
+
state.tentative_observation = null;
|
|
250
|
+
appended += 1;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Next user interrupts the prior pending (omitted, or held as a matching session.error partial).
|
|
255
|
+
if (state.pending && state.pending.user_uuid !== userId) {
|
|
256
|
+
finalizeTentativeAsInterrupted(exchange.timestamp);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
journalBridge.appendJournal(paths.journal, userRecord(sessionId, cwd, exchange, nowMs), dir);
|
|
260
|
+
seen.add(userId);
|
|
261
|
+
state.turn_index += 1;
|
|
262
|
+
state.tentative_observation = null;
|
|
263
|
+
state.pending = {
|
|
264
|
+
user_uuid: userId,
|
|
265
|
+
source_message_id: userId,
|
|
266
|
+
turn_index: state.turn_index,
|
|
267
|
+
first_observed_at_ms: nowMs,
|
|
268
|
+
};
|
|
269
|
+
if (hasAssistantContent) {
|
|
270
|
+
if (errorHoldsAssistant) {
|
|
271
|
+
state.tentative_observation = {
|
|
272
|
+
assistant_id: exchange.assistantId || null,
|
|
273
|
+
assistant_text: exchange.output || "",
|
|
274
|
+
observed_at: exchange.timestamp || new Date(nowMs).toISOString(),
|
|
275
|
+
observed_at_ms: nowMs,
|
|
276
|
+
};
|
|
277
|
+
} else {
|
|
278
|
+
journalBridge.appendJournal(
|
|
279
|
+
paths.journal,
|
|
280
|
+
assistantRecord(sessionId, cwd, exchange, Boolean(exchange.interrupted)),
|
|
281
|
+
dir,
|
|
282
|
+
);
|
|
283
|
+
if (exchange.assistantId) seen.add(exchange.assistantId);
|
|
284
|
+
state.pending = null;
|
|
285
|
+
state.tentative_observation = null;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
appended += 1;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
state.seen_source_ids = [...seen];
|
|
292
|
+
state.updated_at = new Date(nowMs).toISOString();
|
|
293
|
+
journalBridge.atomicWriteJson(paths.state, state, dir);
|
|
294
|
+
return { paths, state, appended, sessionCreated };
|
|
295
|
+
} finally {
|
|
296
|
+
journalBridge.releaseEventLock(paths.lock);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function finalizeExpiredSettleForSession(sessionId, opts = {}) {
|
|
301
|
+
const dir = opts.eventDir || eventDir();
|
|
302
|
+
const journalPath = eventPaths(sessionId, dir).journal;
|
|
303
|
+
if (journalBridge.journalEmpty(journalPath)) return { appended: 0 };
|
|
304
|
+
let cwd = "";
|
|
305
|
+
try {
|
|
306
|
+
const rec = JSON.parse(fs.readFileSync(journalPath, "utf8").split("\n").find((l) => l.trim()));
|
|
307
|
+
if (rec && typeof rec.cwd === "string") cwd = rec.cwd;
|
|
308
|
+
} catch { /* keep "" */ }
|
|
309
|
+
return appendCanonicalExchanges(sessionId, cwd, [], opts);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Peek without a lock. Rebuild if missing, corrupt, or the journal is newer.
|
|
313
|
+
function peekPendingObservation(sessionId, dir) {
|
|
314
|
+
const statePath = path.join(dir, `${sessionId}.state.json`);
|
|
315
|
+
const journalPath = path.join(dir, `${sessionId}.jsonl`);
|
|
316
|
+
let sidecar = null;
|
|
317
|
+
try {
|
|
318
|
+
sidecar = JSON.parse(fs.readFileSync(statePath, "utf8"));
|
|
319
|
+
} catch { /* sidecar lost or corrupt; fall through to journal */ }
|
|
320
|
+
if (sidecar && sidecar.schema_version === 1) {
|
|
321
|
+
try {
|
|
322
|
+
if (fs.statSync(journalPath).mtimeMs > fs.statSync(statePath).mtimeMs) {
|
|
323
|
+
sidecar = null;
|
|
324
|
+
}
|
|
325
|
+
} catch { /* missing journal; trust sidecar */ }
|
|
326
|
+
}
|
|
327
|
+
if (sidecar && sidecar.schema_version === 1) {
|
|
328
|
+
if (!sidecar.pending) return null;
|
|
329
|
+
const tentative = sidecar.tentative_observation;
|
|
330
|
+
const t = tentative && Number.isFinite(tentative.observed_at_ms)
|
|
331
|
+
? tentative.observed_at_ms : null;
|
|
332
|
+
const p = Number.isFinite(sidecar.pending.first_observed_at_ms)
|
|
333
|
+
? sidecar.pending.first_observed_at_ms : null;
|
|
334
|
+
if (t != null && p != null) return Math.min(t, p);
|
|
335
|
+
if (t != null) return t;
|
|
336
|
+
if (p != null) return p;
|
|
337
|
+
}
|
|
338
|
+
const rebuilt = journalBridge.rebuildEventState(journalPath, sessionId);
|
|
339
|
+
if (rebuilt.pending && Number.isFinite(rebuilt.pending.first_observed_at_ms)) {
|
|
340
|
+
return rebuilt.pending.first_observed_at_ms;
|
|
341
|
+
}
|
|
342
|
+
if (rebuilt.pending) {
|
|
343
|
+
try { return fs.statSync(journalPath).mtimeMs; } catch { return null; }
|
|
344
|
+
}
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Skip a current completed sidecar. Rebuild if missing, corrupt, or stale.
|
|
349
|
+
function sweepExpiredSettles(opts = {}) {
|
|
350
|
+
const dir = opts.eventDir || eventDir();
|
|
351
|
+
const nowMs = Number.isFinite(opts.nowMs) ? Number(opts.nowMs) : Date.now();
|
|
352
|
+
const deadline = settleDeadlineMs();
|
|
353
|
+
const ids = new Set();
|
|
354
|
+
try {
|
|
355
|
+
for (const file of fs.readdirSync(dir)) {
|
|
356
|
+
if (file.endsWith(".state.json")) {
|
|
357
|
+
ids.add(file.slice(0, -".state.json".length));
|
|
358
|
+
} else if (file.endsWith(".jsonl")) {
|
|
359
|
+
ids.add(file.slice(0, -".jsonl".length));
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
} catch { return { swept: 0, promoted: 0, finalized: 0 }; }
|
|
363
|
+
let finalized = 0;
|
|
364
|
+
let promoted = 0;
|
|
365
|
+
for (const sessionId of ids) {
|
|
366
|
+
const observedAtMs = peekPendingObservation(sessionId, dir);
|
|
367
|
+
if (observedAtMs == null) continue;
|
|
368
|
+
if (nowMs - observedAtMs < deadline) continue;
|
|
369
|
+
promoted += 1;
|
|
370
|
+
try {
|
|
371
|
+
const out = finalizeExpiredSettleForSession(sessionId, { ...opts, nowMs });
|
|
372
|
+
if (out && out.appended > 0) finalized += 1;
|
|
373
|
+
} catch (err) {
|
|
374
|
+
process.stderr.write(
|
|
375
|
+
`[meko-opencode] Could not finalize expired settle for ${sessionId}: ${err.message}\n`,
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return { swept: ids.size, promoted, finalized };
|
|
380
|
+
}
|
|
381
|
+
function ensureCaptureIntent(sessionId, journalPath, cwd) {
|
|
382
|
+
const agentId = captureLib().deriveAgentId({
|
|
383
|
+
client: "opencode",
|
|
384
|
+
cwd,
|
|
385
|
+
envOverride: process.env.MEKO_AGENT_ID,
|
|
386
|
+
});
|
|
387
|
+
return captureState.writeSessionIntent(sessionId, {
|
|
388
|
+
client: "opencode",
|
|
389
|
+
agent_id: agentId,
|
|
390
|
+
agent_id_source: (process.env.MEKO_AGENT_ID || "").trim() ? "explicit" : "derived",
|
|
391
|
+
transcript_path: journalPath,
|
|
392
|
+
datapack_id: process.env.MEKO_DATAPACK_ID || null,
|
|
393
|
+
datapack_name: process.env.MEKO_DATAPACK_NAME || null,
|
|
394
|
+
pending_turn_policy: "resumable",
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
function launchCapture(mode, payload) {
|
|
398
|
+
const captureScript = path.join(__dirname, "capture.js");
|
|
399
|
+
const child = childProcess.spawn(process.execPath, [captureScript, mode], {
|
|
400
|
+
detached: true,
|
|
401
|
+
windowsHide: true,
|
|
402
|
+
stdio: ["pipe", "ignore", "ignore"],
|
|
403
|
+
env: { ...process.env, MEKO_HOOK_CLIENT: "opencode" },
|
|
404
|
+
});
|
|
405
|
+
child.stdin.on("error", () => {});
|
|
406
|
+
child.stdin.end(JSON.stringify(payload));
|
|
407
|
+
child.unref();
|
|
408
|
+
}
|
|
409
|
+
function launchRecoveryTimer(journalPath) {
|
|
410
|
+
const timerScript = path.join(__dirname, "checkpoint-timer.js");
|
|
411
|
+
const child = childProcess.spawn(process.execPath, [timerScript, journalPath], {
|
|
412
|
+
detached: true,
|
|
413
|
+
windowsHide: true,
|
|
414
|
+
stdio: "ignore",
|
|
415
|
+
env: { ...process.env, MEKO_HOOK_CLIENT: "opencode" },
|
|
416
|
+
});
|
|
417
|
+
child.unref();
|
|
418
|
+
}
|
|
419
|
+
function stagingDir() {
|
|
420
|
+
return path.join(captureState.watermarkDir(), "opencode-snapshots");
|
|
421
|
+
}
|
|
422
|
+
function legacyStagingDir() {
|
|
423
|
+
return path.join(__dirname, "..", "..", "snapshots");
|
|
424
|
+
}
|
|
425
|
+
function listStagedSnapshotFiles(root) {
|
|
426
|
+
try {
|
|
427
|
+
return fs.readdirSync(root)
|
|
428
|
+
.filter((file) => file.endsWith(".json"))
|
|
429
|
+
.map((file) => {
|
|
430
|
+
try {
|
|
431
|
+
return {
|
|
432
|
+
file,
|
|
433
|
+
root,
|
|
434
|
+
mtimeMs: fs.statSync(path.join(root, file)).mtimeMs,
|
|
435
|
+
};
|
|
436
|
+
} catch {
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
})
|
|
440
|
+
.filter(Boolean);
|
|
441
|
+
} catch {
|
|
442
|
+
return [];
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
async function scanRecovery(options = {}) {
|
|
446
|
+
const snapshotsRoot = options.stagingDir || stagingDir();
|
|
447
|
+
const legacyRoot =
|
|
448
|
+
options.legacyStagingDir !== undefined
|
|
449
|
+
? options.legacyStagingDir
|
|
450
|
+
: options.stagingDir
|
|
451
|
+
? null
|
|
452
|
+
: legacyStagingDir();
|
|
453
|
+
const listed = listStagedSnapshotFiles(snapshotsRoot);
|
|
454
|
+
if (legacyRoot) listed.push(...listStagedSnapshotFiles(legacyRoot));
|
|
455
|
+
const snapshots = listed
|
|
456
|
+
.sort((a, b) => a.mtimeMs - b.mtimeMs || a.file.localeCompare(b.file) || a.root.localeCompare(b.root))
|
|
457
|
+
.map(({ file, root }) => ({ file, root }));
|
|
458
|
+
const replay = options.handleStagedRequest || handleStagedRequest;
|
|
459
|
+
for (const { file, root } of snapshots) {
|
|
460
|
+
const snapshotPath = path.join(root, file);
|
|
461
|
+
try {
|
|
462
|
+
await replay({ snapshotPath }, options);
|
|
463
|
+
} catch (err) {
|
|
464
|
+
process.stderr.write(
|
|
465
|
+
`[meko-opencode] Could not replay staged snapshot ${file}: ${err.message}\n`,
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
const dir = options.eventDir || eventDir();
|
|
470
|
+
sweepExpiredSettles({ eventDir: dir });
|
|
471
|
+
const startRecoveryTimer =
|
|
472
|
+
options.launchRecoveryTimer || launchRecoveryTimer;
|
|
473
|
+
let files;
|
|
474
|
+
try {
|
|
475
|
+
files = fs.readdirSync(dir).filter((f) => f.endsWith(".jsonl"));
|
|
476
|
+
} catch {
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
const shouldRecover = options.shouldRecoverJournal || journalNeedsRecovery;
|
|
480
|
+
const maxTimers = Number.isInteger(options.maxRecoveryTimers)
|
|
481
|
+
? Math.max(0, options.maxRecoveryTimers)
|
|
482
|
+
: DEFAULT_RECOVERY_MAX_TIMERS;
|
|
483
|
+
files = files
|
|
484
|
+
.map((file) => {
|
|
485
|
+
try { return { file, mtimeMs: fs.statSync(path.join(dir, file)).mtimeMs }; }
|
|
486
|
+
catch { return null; }
|
|
487
|
+
})
|
|
488
|
+
.filter(Boolean)
|
|
489
|
+
.filter(({ file }) => shouldRecover(path.join(dir, file)))
|
|
490
|
+
.sort((a, b) => a.mtimeMs - b.mtimeMs)
|
|
491
|
+
.slice(0, maxTimers);
|
|
492
|
+
for (const { file } of files) {
|
|
493
|
+
startRecoveryTimer(path.join(dir, file));
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
function journalNeedsRecovery(journalPath) {
|
|
497
|
+
const sessionId = path.basename(journalPath, ".jsonl");
|
|
498
|
+
const read = captureState.readState(sessionId);
|
|
499
|
+
return !read.ok || captureState.sessionHealthBucket(read) !== "healthy";
|
|
500
|
+
}
|
|
501
|
+
async function handleSnapshot(payload, options = {}) {
|
|
502
|
+
const normalize = options.normalize || require("./opencode-normalizer.js").normalizeOpenCodeSession;
|
|
503
|
+
// session.error is an observation. A pending user stays until a later
|
|
504
|
+
// snapshot completes it, a later user interrupts, or MEKO_OPENCODE_SETTLE_MS.
|
|
505
|
+
const normalized = normalize(payload.export || payload, {
|
|
506
|
+
emitPendingAsExchange: true,
|
|
507
|
+
});
|
|
508
|
+
const sessionId = safeSessionId(normalized.sessionId);
|
|
509
|
+
const journaled = appendCanonicalExchanges(
|
|
510
|
+
sessionId,
|
|
511
|
+
normalized.cwd,
|
|
512
|
+
normalized.exchanges,
|
|
513
|
+
{ fromErrorObservation: Boolean(payload.terminalError) },
|
|
514
|
+
);
|
|
515
|
+
const intent = ensureCaptureIntent(
|
|
516
|
+
sessionId,
|
|
517
|
+
journaled.paths.journal,
|
|
518
|
+
normalized.cwd,
|
|
519
|
+
);
|
|
520
|
+
if (!intent.ok) {
|
|
521
|
+
return { ok: false, error: intent.error, journaled };
|
|
522
|
+
}
|
|
523
|
+
const checkpointPayload = {
|
|
524
|
+
session_id: sessionId,
|
|
525
|
+
transcript_path: journaled.paths.journal,
|
|
526
|
+
lifecycle: "active",
|
|
527
|
+
};
|
|
528
|
+
const startCapture = options.launchCapture || launchCapture;
|
|
529
|
+
const startRecoveryTimer = options.launchRecoveryTimer || launchRecoveryTimer;
|
|
530
|
+
if (journaled.sessionCreated || journaled.appended > 0) {
|
|
531
|
+
startCapture("checkpoint", checkpointPayload);
|
|
532
|
+
startRecoveryTimer(journaled.paths.journal);
|
|
533
|
+
}
|
|
534
|
+
return { ok: true, journaled, intent, normalized };
|
|
535
|
+
}
|
|
536
|
+
async function handleStagedRequest(request, options = {}) {
|
|
537
|
+
const snapshotPath =
|
|
538
|
+
typeof request?.snapshotPath === "string" ? request.snapshotPath : "";
|
|
539
|
+
let payload = request;
|
|
540
|
+
if (snapshotPath) {
|
|
541
|
+
let staged;
|
|
542
|
+
try {
|
|
543
|
+
staged = JSON.parse(fs.readFileSync(snapshotPath, "utf8"));
|
|
544
|
+
} catch (err) {
|
|
545
|
+
quarantineSnapshot(snapshotPath);
|
|
546
|
+
throw new Error(`invalid staged snapshot JSON: ${err.message}`);
|
|
547
|
+
}
|
|
548
|
+
const snapshot = staged?.export ?? staged;
|
|
549
|
+
if (!isValidStagedSnapshot(snapshot)) {
|
|
550
|
+
quarantineSnapshot(snapshotPath);
|
|
551
|
+
throw new Error("invalid staged snapshot shape");
|
|
552
|
+
}
|
|
553
|
+
payload = {
|
|
554
|
+
export: snapshot,
|
|
555
|
+
terminalError: staged?.export
|
|
556
|
+
? Boolean(staged.terminalError)
|
|
557
|
+
: Boolean(request.terminalError),
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
const handler = options.handleSnapshot || handleSnapshot;
|
|
561
|
+
const result = await handler(payload, options);
|
|
562
|
+
if (snapshotPath && result?.ok === true) {
|
|
563
|
+
try { fs.unlinkSync(snapshotPath); } catch { /* already acknowledged */ }
|
|
564
|
+
}
|
|
565
|
+
return result;
|
|
566
|
+
}
|
|
567
|
+
function isValidStagedSnapshot(snapshot) {
|
|
568
|
+
if (!snapshot || typeof snapshot !== "object") return false;
|
|
569
|
+
if (!snapshot.info || typeof snapshot.info !== "object") return false;
|
|
570
|
+
const sessionId =
|
|
571
|
+
snapshot.info.id || snapshot.info.sessionID || snapshot.info.sessionId;
|
|
572
|
+
return (
|
|
573
|
+
typeof sessionId === "string" &&
|
|
574
|
+
sessionId.length > 0 &&
|
|
575
|
+
Array.isArray(snapshot.messages)
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
function quarantineSnapshot(snapshotPath) {
|
|
579
|
+
const quarantineDir = path.join(path.dirname(snapshotPath), "quarantine");
|
|
580
|
+
fs.mkdirSync(quarantineDir, { recursive: true, mode: 0o700 });
|
|
581
|
+
try { fs.chmodSync(quarantineDir, 0o700); } catch { /* best effort */ }
|
|
582
|
+
const target = path.join(
|
|
583
|
+
quarantineDir,
|
|
584
|
+
`${path.basename(snapshotPath)}.${Date.now()}.invalid`,
|
|
585
|
+
);
|
|
586
|
+
fs.renameSync(snapshotPath, target);
|
|
587
|
+
return target;
|
|
588
|
+
}
|
|
589
|
+
async function main() {
|
|
590
|
+
loadHookEnvironment();
|
|
591
|
+
if (process.argv.includes("--recovery-scan")) {
|
|
592
|
+
await scanRecovery();
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
let request = {};
|
|
596
|
+
try {
|
|
597
|
+
request = JSON.parse(fs.readFileSync(0, "utf8") || "{}");
|
|
598
|
+
} catch (err) {
|
|
599
|
+
process.stderr.write(`[meko-opencode] Invalid handler input: ${err.message}\n`);
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
await handleStagedRequest(request);
|
|
603
|
+
}
|
|
604
|
+
module.exports = {
|
|
605
|
+
loadHookEnvironment,
|
|
606
|
+
eventPaths,
|
|
607
|
+
appendCanonicalExchanges,
|
|
608
|
+
finalizeExpiredSettleForSession,
|
|
609
|
+
sweepExpiredSettles,
|
|
610
|
+
handleSnapshot,
|
|
611
|
+
handleStagedRequest,
|
|
612
|
+
scanRecovery,
|
|
613
|
+
launchCapture,
|
|
614
|
+
launchRecoveryTimer,
|
|
615
|
+
};
|
|
616
|
+
if (require.main === module) {
|
|
617
|
+
main().catch((err) => {
|
|
618
|
+
process.stderr.write(`[meko-opencode] Handler failed: ${err.message}\n`);
|
|
619
|
+
});
|
|
620
|
+
}
|