@yugabytedb/meko-mcp 2.0.0 → 2.2.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 +183 -33
- package/assets/hooks-handlers/codex-session-end.sh +13 -0
- package/assets/hooks-handlers/cursor-session-start.sh +19 -1
- package/assets/hooks-handlers/lib/capture-notices.js +321 -0
- package/assets/hooks-handlers/lib/capture-state.js +2079 -0
- package/assets/hooks-handlers/lib/capture-status-cli.js +82 -0
- package/assets/hooks-handlers/lib/capture.js +1653 -225
- package/assets/hooks-handlers/lib/checkpoint-timer.js +455 -50
- package/assets/hooks-handlers/lib/kiro-handler.js +738 -0
- package/assets/hooks-handlers/lib/terminal-handoff.js +418 -0
- package/assets/hooks-handlers/session-end.sh +3 -24
- package/assets/hooks-handlers/session-start.sh +8 -1
- package/assets/hooks-handlers/test_checkpoint_timer.sh +162 -0
- package/assets/scripts/meko-statusline.sh +41 -1
- package/assets/scripts/package-desktop-skill.sh +32 -1
- package/assets/skills/meko-mcp-tools/SKILL.md +90 -137
- package/assets/skills/meko-mcp-tools/references/_sections.md +3 -5
- package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +31 -28
- package/assets/skills/meko-mcp-tools/references/tools-conversation-dedup.md +5 -7
- package/assets/skills/meko-mcp-tools/references/tools-conversation-reasoning.md +50 -0
- package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +69 -49
- package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +6 -6
- package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +29 -5
- package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +29 -92
- package/assets/skills/meko-mcp-tools/references/tools-overview.md +42 -40
- package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +1 -2
- package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +18 -11
- package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +10 -9
- package/assets/skills/meko-mcp-tools-desktop/SKILL.md +51 -44
- package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +6 -6
- package/assets/skills/meko-mcp-tools-desktop/references/_sections.md +2 -2
- package/assets/skills/meko-mcp-tools-desktop/references/tools-agent-id-conventions.md +34 -25
- package/assets/skills/meko-mcp-tools-desktop/references/tools-conversation-reasoning.md +50 -0
- package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +63 -37
- package/assets/skills/meko-mcp-tools-desktop/references/tools-known-limitations.md +4 -4
- package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +19 -22
- package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +42 -40
- package/assets/skills/meko-mcp-tools-desktop/references/tools-rag-workflow.md +1 -2
- package/assets/skills/meko-mcp-tools-desktop/references/tools-troubleshooting.md +3 -17
- package/assets/skills/meko-select-datapack-desktop/SKILL.md +31 -21
- package/assets/skills/meko-select-datapack-desktop/references/cookbook.md +8 -8
- package/assets/skills/meko-select-datapack-desktop/references/role-display-future.md +41 -0
- package/assets/skills/meko-select-datapack-desktop/references/troubleshooting.md +1 -1
- package/bin/create-meko-setup.mjs +93 -16
- package/lib/capture-status.mjs +67 -0
- package/lib/clients/aliases.mjs +5 -0
- package/lib/clients/claude-code.mjs +8 -4
- package/lib/clients/claude-desktop.mjs +133 -4
- package/lib/clients/codex.mjs +29 -11
- package/lib/clients/common.mjs +2 -0
- package/lib/clients/cursor.mjs +7 -2
- package/lib/clients/index.mjs +2 -0
- package/lib/clients/kiro.mjs +705 -0
- package/lib/config.mjs +90 -0
- package/lib/detect.mjs +7 -2
- package/lib/installer.mjs +1 -0
- package/lib/migrate/adapters/codex.mjs +490 -0
- package/lib/migrate/id.mjs +28 -3
- package/lib/migrate/orchestrator.mjs +34 -7
- package/lib/migrate.mjs +4 -2
- package/lib/prompt.mjs +17 -2
- package/lib/url-selection.mjs +55 -0
- package/package.json +4 -4
- package/assets/skills/meko-mcp-tools/references/tools-scope-permissions.md +0 -71
- package/assets/skills/meko-mcp-tools-desktop/references/tools-scope-permissions.md +0 -71
|
@@ -1,14 +1,29 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* capture.js — Unified conversation capture for Meko MCP plugin.
|
|
3
|
+
* capture.js — Unified conversation capture for Meko MCP plugin (Capture V2).
|
|
4
4
|
*
|
|
5
|
-
* Handles transcript parsing,
|
|
6
|
-
*
|
|
5
|
+
* Handles transcript parsing, durable outbox state, and MCP API calls.
|
|
6
|
+
* Drain engine (`drainSession`) is shared by checkpoint, pre-compact,
|
|
7
|
+
* session-end, and CLI recovery. State/leases live in `./capture-state`.
|
|
7
8
|
*
|
|
8
|
-
* Usage (called by hook shell wrappers):
|
|
9
|
-
* node capture.js session-start
|
|
10
|
-
* node capture.js pre-compact
|
|
11
|
-
* node capture.js session-end
|
|
9
|
+
* Usage (called by hook shell wrappers / Workstream C scheduler):
|
|
10
|
+
* node capture.js session-start # intent-first create + watermark
|
|
11
|
+
* node capture.js pre-compact # stdin hook JSON → drainSession
|
|
12
|
+
* node capture.js session-end # stdin hook JSON → drainSession (closing)
|
|
13
|
+
* node capture.js checkpoint # stdin hook JSON → drainSession
|
|
14
|
+
* node capture.js drain # stdin JSON → drain one session; stdout DrainResult
|
|
15
|
+
* node capture.js recover # alias of drain (single-session recovery)
|
|
16
|
+
*
|
|
17
|
+
* drain / recover stdin contract (Workstream C):
|
|
18
|
+
* {
|
|
19
|
+
* "session_id": "<id>", // required (or derived from transcript_path)
|
|
20
|
+
* "transcript_path": "<path>", // optional if state already has it
|
|
21
|
+
* "lifecycle": "active|closing|closed", // optional; session-end forces closing
|
|
22
|
+
* "confirmed_dead": true, // optional; finalize interrupted trailing turn
|
|
23
|
+
* "recorded_owner_only": true, // optional; no rebucket (recovery)
|
|
24
|
+
* "max_exchanges": <int> // optional; else MEKO_MAX_CAPTURE_BATCH_SIZE
|
|
25
|
+
* }
|
|
26
|
+
* stdout: one JSON DrainResult object (see docs/plans/meko-capture-v2-contracts.md)
|
|
12
27
|
*
|
|
13
28
|
* Environment:
|
|
14
29
|
* MEKO_MCP_URL MCP server URL (default: http://localhost:8000/mcp)
|
|
@@ -18,6 +33,7 @@
|
|
|
18
33
|
* MEKO_API_KEY API key for Cloud Meko auth (optional, omit for local)
|
|
19
34
|
* MEKO_API_TIMEOUT Request timeout in seconds (default: 10)
|
|
20
35
|
* MEKO_WATERMARK_DIR Watermark directory (default: ~/.claude/meko-capture)
|
|
36
|
+
* MEKO_MAX_CAPTURE_BATCH_SIZE Max exchanges per drain (default 5, min 1)
|
|
21
37
|
*/
|
|
22
38
|
|
|
23
39
|
const fs = require("fs");
|
|
@@ -25,6 +41,7 @@ const os = require("os");
|
|
|
25
41
|
const path = require("path");
|
|
26
42
|
const http = require("http");
|
|
27
43
|
const https = require("https");
|
|
44
|
+
const captureState = require("./capture-state");
|
|
28
45
|
|
|
29
46
|
// --- Config ---
|
|
30
47
|
|
|
@@ -41,7 +58,7 @@ const AGENT_ID_MAX_LEN = 64;
|
|
|
41
58
|
* in a separate package with no module dependencies on the installer.
|
|
42
59
|
*
|
|
43
60
|
* - `envOverride` (typically MEKO_AGENT_ID) wins if non-empty.
|
|
44
|
-
* - Coding clients (claude_code, cursor) → `<client>:<repo-basename>`,
|
|
61
|
+
* - Coding clients (claude_code, cursor, codex, kiro) → `<client>:<repo-basename>`,
|
|
45
62
|
* with the basename lowercased and non-`[a-z0-9-]` runs collapsed to `-`.
|
|
46
63
|
* Outside a repo → bare client name (keeps coding-agent traffic out of
|
|
47
64
|
* the common bucket).
|
|
@@ -53,7 +70,14 @@ function deriveAgentId(opts) {
|
|
|
53
70
|
if (override) return override;
|
|
54
71
|
const client = (opts && opts.client ? String(opts.client) : "").trim();
|
|
55
72
|
if (client === "claude-desktop" || client === "claude_desktop") return "claude_desktop";
|
|
56
|
-
if (
|
|
73
|
+
if (
|
|
74
|
+
client !== "claude_code" &&
|
|
75
|
+
client !== "cursor" &&
|
|
76
|
+
client !== "codex" &&
|
|
77
|
+
client !== "kiro"
|
|
78
|
+
) {
|
|
79
|
+
return COMMON_BUCKET_AGENT_ID;
|
|
80
|
+
}
|
|
57
81
|
const rawBase = opts && opts.cwd ? path.basename(opts.cwd) : "";
|
|
58
82
|
if (!rawBase || rawBase === "." || rawBase === "/") return client;
|
|
59
83
|
const project = rawBase.toLowerCase()
|
|
@@ -64,6 +88,20 @@ function deriveAgentId(opts) {
|
|
|
64
88
|
return `${client}:${project}`;
|
|
65
89
|
}
|
|
66
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Map MEKO_HOOK_CLIENT to a known coding-client identity. The installer
|
|
93
|
+
* prefixes each client's hook commands with `MEKO_HOOK_CLIENT=<client>`;
|
|
94
|
+
* cursor and codex are first-class coding clients, everything else (including
|
|
95
|
+
* an unset value) defaults to claude_code. Keeping this in one place means
|
|
96
|
+
* every call path — SessionStart, watermark write, PreCompact/SessionEnd
|
|
97
|
+
* re-derivation — resolves the same identity for a given client.
|
|
98
|
+
*/
|
|
99
|
+
function resolveHookClient() {
|
|
100
|
+
const client = (process.env.MEKO_HOOK_CLIENT || "").trim();
|
|
101
|
+
if (client === "cursor" || client === "codex" || client === "kiro") return client;
|
|
102
|
+
return "claude_code";
|
|
103
|
+
}
|
|
104
|
+
|
|
67
105
|
/**
|
|
68
106
|
* Resolve the agent_id for the current session. Frozen at SessionStart;
|
|
69
107
|
* subsequent hook invocations re-derive from the watermark + env, not
|
|
@@ -101,9 +139,134 @@ function resolveSessionAgentId(transcriptPath, hookInput = null) {
|
|
|
101
139
|
hookInput?.cwd ||
|
|
102
140
|
nested.cwd ||
|
|
103
141
|
process.cwd();
|
|
104
|
-
const client =
|
|
142
|
+
const client = resolveHookClient();
|
|
105
143
|
return deriveAgentId({ client, cwd, envOverride });
|
|
106
144
|
}
|
|
145
|
+
|
|
146
|
+
// Coding clients that derive a `<client>:<repo>` agent_id. Kept in
|
|
147
|
+
// sync with deriveAgentId's coding-client branch.
|
|
148
|
+
const CODING_CLIENTS = ["claude_code", "cursor", "codex", "kiro"];
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Extract the client segment of an agent_id: the part before the first `:`,
|
|
152
|
+
* or the whole string for a bare client name. `codex:my-repo` → `codex`,
|
|
153
|
+
* `claude_code` → `claude_code`, `custom_bucket` → `custom_bucket`.
|
|
154
|
+
*/
|
|
155
|
+
function clientOfAgentId(agentId) {
|
|
156
|
+
const id = (typeof agentId === "string" ? agentId : "").trim();
|
|
157
|
+
return id.includes(":") ? id.slice(0, id.indexOf(":")) : id;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* True if `agentId` looks like an auto-derived coding-client identity, i.e.
|
|
162
|
+
* `<coding-client>:<something>` or the bare client name. Used to tell a
|
|
163
|
+
* stale auto-derived value (safe to re-derive) apart from a user-chosen
|
|
164
|
+
* bucket (must be preserved).
|
|
165
|
+
*/
|
|
166
|
+
function isDerivedCodingAgentId(agentId) {
|
|
167
|
+
return CODING_CLIENTS.includes(clientOfAgentId(agentId));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Reconcile a cached watermark agent_id with the current hook invocation.
|
|
172
|
+
*
|
|
173
|
+
* Upgrade path (MEKO-385): a watermark written by the OLD Codex hook holds
|
|
174
|
+
* `claude_code:<repo>` because the pre-fix hook collapsed every non-cursor
|
|
175
|
+
* client to claude_code. After upgrade, a SessionStart re-fire (/clear,
|
|
176
|
+
* /compact) or a checkpoint/PreCompact/SessionEnd hook must NOT keep writing
|
|
177
|
+
* into that wrong namespace. So when the current client derives a different
|
|
178
|
+
* coding-client identity than the cached one, and the cached value is a
|
|
179
|
+
* plain auto-derived coding id (not a user bucket), we re-derive.
|
|
180
|
+
*
|
|
181
|
+
* Resolution order:
|
|
182
|
+
* 1. Current MEKO_AGENT_ID override wins verbatim — it is the live source of
|
|
183
|
+
* truth and equals `derivedAgentId` (resolveSessionAgentId returns the
|
|
184
|
+
* override first). This applies regardless of what the watermark cached,
|
|
185
|
+
* including a source-less legacy watermark whose stale value must NOT be
|
|
186
|
+
* preserved when a differing override is set now.
|
|
187
|
+
* 2. No usable cached value, or the legacy ghost literal "agent" → fresh
|
|
188
|
+
* derivation.
|
|
189
|
+
* 3. A previously-recorded explicit override (agent_id_source==="explicit",
|
|
190
|
+
* with no current env override to supersede it) → preserved verbatim.
|
|
191
|
+
* 4. Auto-derived cached value → re-derive ONLY when the CLIENT segment is
|
|
192
|
+
* stale for the current client (claude_code:<repo> under a now-codex
|
|
193
|
+
* session). We compare the client, not the whole id: a differing repo
|
|
194
|
+
* basename alone (same client) is NOT a reason to re-derive — that would
|
|
195
|
+
* clobber a legitimately preserved project bucket on resume.
|
|
196
|
+
*
|
|
197
|
+
* The returned agent_id may differ from the conversation's current owner; when
|
|
198
|
+
* it does the caller must open a NEW conversation under it (see
|
|
199
|
+
* needsRebucket / rebucketConversation) because the server pins the owner at
|
|
200
|
+
* creation and rejects mismatched agent_id on an existing conversation.
|
|
201
|
+
*
|
|
202
|
+
* @param {string} cachedAgentId agent_id read from the watermark.
|
|
203
|
+
* @param {string} cachedSource watermark.agent_id_source ("" if legacy).
|
|
204
|
+
* @param {string} derivedAgentId freshly derived identity for this session.
|
|
205
|
+
* @returns {{agentId: string, source: "explicit"|"derived"}}
|
|
206
|
+
*/
|
|
207
|
+
function reconcileCachedAgentId(cachedAgentId, cachedSource, derivedAgentId) {
|
|
208
|
+
const cached = (typeof cachedAgentId === "string" ? cachedAgentId : "").trim();
|
|
209
|
+
const envOverride = (process.env.MEKO_AGENT_ID || "").trim();
|
|
210
|
+
|
|
211
|
+
// (1) A current env override always wins. derivedAgentId already equals the
|
|
212
|
+
// override (resolveSessionAgentId honors MEKO_AGENT_ID first), so applying it
|
|
213
|
+
// here never preserves a stale cached value against the live override.
|
|
214
|
+
if (envOverride) {
|
|
215
|
+
return { agentId: derivedAgentId, source: "explicit" };
|
|
216
|
+
}
|
|
217
|
+
// (2) No usable cached value, or the legacy ghost literal → fresh derivation.
|
|
218
|
+
if (!cached || cached === "agent") {
|
|
219
|
+
return { agentId: derivedAgentId, source: "derived" };
|
|
220
|
+
}
|
|
221
|
+
// (3) A previously-recorded explicit override (no current env override to
|
|
222
|
+
// supersede it) is preserved verbatim.
|
|
223
|
+
if (cachedSource === "explicit") {
|
|
224
|
+
return { agentId: cached, source: "explicit" };
|
|
225
|
+
}
|
|
226
|
+
// (4) Auto-derived cached value: re-derive only on a stale CLIENT segment.
|
|
227
|
+
if (
|
|
228
|
+
isDerivedCodingAgentId(cached)
|
|
229
|
+
&& isDerivedCodingAgentId(derivedAgentId)
|
|
230
|
+
&& clientOfAgentId(cached) !== clientOfAgentId(derivedAgentId)
|
|
231
|
+
) {
|
|
232
|
+
return { agentId: derivedAgentId, source: "derived" };
|
|
233
|
+
}
|
|
234
|
+
// Otherwise keep the cached derived value (non-coding custom bucket, or
|
|
235
|
+
// already the right client).
|
|
236
|
+
return { agentId: cached, source: "derived" };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* True when the existing conversation may NOT be owned by the agent_id we
|
|
241
|
+
* resolved for this invocation, so the caller must open a fresh conversation
|
|
242
|
+
* under the resolved id before writing. The server pins the owner at creation
|
|
243
|
+
* and rejects a mismatched agent_id on an existing conversation (src/tools.py:
|
|
244
|
+
* conversation_add_message returns `agent_id_mismatch`), so reusing a
|
|
245
|
+
* wrong-owner conversation would make every future write fail closed and wedge
|
|
246
|
+
* the watermark forever.
|
|
247
|
+
*
|
|
248
|
+
* Two triggering cases, given a non-empty conversation_id:
|
|
249
|
+
* - the cached owner differs from the resolved id (a real client-mismatch
|
|
250
|
+
* upgrade, e.g. claude_code:<repo> → codex:<repo>); or
|
|
251
|
+
* - the cached owner is EMPTY/unknown. A legacy or partially-written
|
|
252
|
+
* watermark can carry a conversation_id with no agent_id; we don't know
|
|
253
|
+
* who owns that conversation, and silently reusing it under the resolved
|
|
254
|
+
* id risks agent_id_mismatch on every write with no way to self-heal. So
|
|
255
|
+
* unknown-owner is treated as "needs a conversation we know we own".
|
|
256
|
+
*
|
|
257
|
+
* @param {string} cachedAgentId watermark.agent_id (the conversation owner).
|
|
258
|
+
* @param {string} resolvedAgentId agent_id resolved for this invocation.
|
|
259
|
+
* @param {string} convId watermark.conversation_id (may be empty).
|
|
260
|
+
* @returns {boolean}
|
|
261
|
+
*/
|
|
262
|
+
function ownerChanged(cachedAgentId, resolvedAgentId, convId) {
|
|
263
|
+
const conv = (typeof convId === "string" ? convId : "").trim();
|
|
264
|
+
if (!conv) return false; // no existing conversation → nothing to reconcile
|
|
265
|
+
const cached = (typeof cachedAgentId === "string" ? cachedAgentId : "").trim();
|
|
266
|
+
if (!cached) return true; // conversation exists but owner unknown → reconcile
|
|
267
|
+
return cached !== resolvedAgentId;
|
|
268
|
+
}
|
|
269
|
+
|
|
107
270
|
const MEKO_API_KEY = process.env.MEKO_API_KEY || "";
|
|
108
271
|
const MEKO_API_TIMEOUT = parseInt(process.env.MEKO_API_TIMEOUT || "10", 10) * 1000;
|
|
109
272
|
const WATERMARK_DIR =
|
|
@@ -113,6 +276,10 @@ const SESSION_CACHE_DIR =
|
|
|
113
276
|
process.env.MEKO_SESSION_CACHE_DIR ||
|
|
114
277
|
path.join(os.homedir(), ".cursor", "meko-session-cache");
|
|
115
278
|
|
|
279
|
+
/** Exact interrupted-turn assistant marker (contracts). */
|
|
280
|
+
const INTERRUPTED_ASSISTANT_OUTPUT =
|
|
281
|
+
"[Meko capture: assistant response was interrupted before completion.]";
|
|
282
|
+
|
|
116
283
|
let mcpRequestId = 0;
|
|
117
284
|
let mcpSessionId = null; // MCP Streamable HTTP session ID
|
|
118
285
|
const inflightRequests = new Set(); // tracked so preload timeouts can abort
|
|
@@ -230,56 +397,231 @@ function extractToolResultContent(msg) {
|
|
|
230
397
|
|
|
231
398
|
// --- Turn-assembly exchange extraction ---
|
|
232
399
|
|
|
233
|
-
|
|
400
|
+
// Codex turn-completion boundary discriminator.
|
|
401
|
+
//
|
|
402
|
+
// A single Codex task emits MULTIPLE assistant `response_item` messages within
|
|
403
|
+
// one turn: `phase: "commentary"` progress updates stream first, and the turn
|
|
404
|
+
// ends with the `phase: "final_answer"` reply, after which Codex writes an
|
|
405
|
+
// `event_msg` whose `payload.type` is `"task_complete"`. A checkpoint can fire
|
|
406
|
+
// after commentary but before the final answer, so a mid-stream turn is only
|
|
407
|
+
// safe to consume once one of these boundaries has arrived — otherwise the
|
|
408
|
+
// trailing (still-streaming) assistant messages would be read on a later run
|
|
409
|
+
// without their user and dropped.
|
|
410
|
+
//
|
|
411
|
+
// The in-progress SIGNAL is a `commentary`-phase assistant message with no
|
|
412
|
+
// following completion boundary. A turn whose assistant message carries no
|
|
413
|
+
// phase at all is the legacy/simple shape (no streaming): it's complete as
|
|
414
|
+
// soon as any assistant message exists, exactly as before — so we never hold
|
|
415
|
+
// such a turn forever waiting for a boundary it will never emit.
|
|
416
|
+
//
|
|
417
|
+
// These are named constants so the Codex migrator adapter (PR #232,
|
|
418
|
+
// installer/lib/migrate/adapters/codex.mjs) can mirror the SAME definition:
|
|
419
|
+
// in-progress = a CODEX_COMMENTARY_PHASE assistant message with no boundary;
|
|
420
|
+
// boundary = an assistant message with phase === CODEX_FINAL_ANSWER_PHASE, or
|
|
421
|
+
// an event_msg with payload.type === CODEX_TASK_COMPLETE_TYPE.
|
|
422
|
+
const CODEX_COMMENTARY_PHASE = "commentary";
|
|
423
|
+
const CODEX_FINAL_ANSWER_PHASE = "final_answer";
|
|
424
|
+
const CODEX_TASK_COMPLETE_TYPE = "task_complete";
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Normalize a Codex rollout record into the Claude-shaped entry the
|
|
428
|
+
* turn-assembly below expects. Codex wraps each turn in a `response_item`
|
|
429
|
+
* envelope with `payload.type === "message"` and role-specific content
|
|
430
|
+
* blocks (`input_text` for the user, `output_text` for the assistant).
|
|
431
|
+
*
|
|
432
|
+
* Two record kinds are surfaced:
|
|
433
|
+
* - `response_item` messages → user/assistant entries (assistant entries
|
|
434
|
+
* carry `codexPhase` so turn-assembly can tell an in-progress `commentary`
|
|
435
|
+
* message from the `final_answer` that ends the turn).
|
|
436
|
+
* - the `event_msg` whose `payload.type` is `task_complete` → a lightweight
|
|
437
|
+
* `{ type: "codex_task_complete" }` boundary marker, so turn-assembly
|
|
438
|
+
* knows the trailing assistant sequence is finished.
|
|
439
|
+
*
|
|
440
|
+
* Every other `event_msg` mirrors `response_item` text as a UI event and is
|
|
441
|
+
* dropped (returns null), so each turn is captured exactly once. Returns null
|
|
442
|
+
* for non-Codex input too, so Claude Code / Cursor records fall through to
|
|
443
|
+
* their own branch in parseTranscriptEntries untouched.
|
|
444
|
+
*/
|
|
445
|
+
function normalizeCodexEntry(obj, lineIndex) {
|
|
446
|
+
if (!obj || typeof obj !== "object") return null;
|
|
447
|
+
if (obj.type === "event_msg") {
|
|
448
|
+
const p = obj.payload;
|
|
449
|
+
if (p && p.type === CODEX_TASK_COMPLETE_TYPE) {
|
|
450
|
+
return { type: "codex_task_complete" };
|
|
451
|
+
}
|
|
452
|
+
return null;
|
|
453
|
+
}
|
|
454
|
+
if (obj.type !== "response_item") return null;
|
|
455
|
+
const payload = obj.payload;
|
|
456
|
+
if (!payload || payload.type !== "message") return null;
|
|
457
|
+
const role = payload.role;
|
|
458
|
+
if (role !== "user" && role !== "assistant") return null;
|
|
459
|
+
const wantType = role === "user" ? "input_text" : "output_text";
|
|
460
|
+
const blocks = Array.isArray(payload.content) ? payload.content : [];
|
|
461
|
+
const text = blocks
|
|
462
|
+
.filter((b) => b && (b.type === wantType || b.type === "text"))
|
|
463
|
+
.map((b) => (typeof b.text === "string" ? b.text : ""))
|
|
464
|
+
.filter(Boolean)
|
|
465
|
+
.join("\n");
|
|
466
|
+
const explicitId = obj.id || payload.id || obj.uuid;
|
|
467
|
+
const timestamp = obj.timestamp || payload.timestamp || "";
|
|
468
|
+
// Real Codex rollout records frequently omit id/payload.id/uuid on
|
|
469
|
+
// response_item messages. Falling back to a constant ("unknown") gave every
|
|
470
|
+
// user turn the same addMessage dedup seed (`${convId}:unknown`), so all but
|
|
471
|
+
// the first were collapsed as duplicates. When no explicit id exists, derive
|
|
472
|
+
// a stable, unique-per-turn id from fields real records DO carry: the
|
|
473
|
+
// record's timestamp plus its line position in the transcript. Position is
|
|
474
|
+
// deterministic across re-parses of the same file (so PreCompact/SessionEnd
|
|
475
|
+
// retries stay idempotent) yet distinct per turn (so turns aren't collapsed).
|
|
476
|
+
const uuid = explicitId || `codex-${timestamp || "no-ts"}-line${lineIndex}`;
|
|
477
|
+
const entry = {
|
|
478
|
+
type: role,
|
|
479
|
+
uuid,
|
|
480
|
+
timestamp,
|
|
481
|
+
codex: true,
|
|
482
|
+
message: { role, content: [{ type: "text", text }] },
|
|
483
|
+
};
|
|
484
|
+
if (role === "assistant") {
|
|
485
|
+
// Codex may put the phase on the payload or the envelope; check both.
|
|
486
|
+
const phase = payload.phase || obj.phase;
|
|
487
|
+
entry.codexPhase = typeof phase === "string" ? phase : "";
|
|
488
|
+
}
|
|
489
|
+
return entry;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Parse the transcript lines after `startLine` into the user/assistant
|
|
494
|
+
* entries turn-assembly consumes. Recognizes both Claude Code / Cursor
|
|
495
|
+
* records (top-level `type` of `user`/`assistant`) and Codex `response_item`
|
|
496
|
+
* message records (via normalizeCodexEntry). Each element is
|
|
497
|
+
* `{ entry, line }`, pairing the normalized entry with its 0-based line index
|
|
498
|
+
* in the transcript so buildExchanges can report the last safely-consumed
|
|
499
|
+
* line. Returns the raw entry list so callers can distinguish "no eligible
|
|
500
|
+
* records" from "records present but zero exchanges" when deciding whether to
|
|
501
|
+
* advance the watermark.
|
|
502
|
+
*/
|
|
503
|
+
function parseTranscriptEntries(filePath, startLine) {
|
|
234
504
|
const data = fs.readFileSync(filePath, "utf-8");
|
|
235
505
|
const lines = data.split("\n");
|
|
236
506
|
|
|
237
|
-
// Step 1: Parse relevant lines
|
|
238
507
|
const entries = [];
|
|
239
508
|
for (let i = startLine; i < lines.length; i++) {
|
|
240
509
|
const line = lines[i].trim();
|
|
241
510
|
if (!line) continue;
|
|
511
|
+
let obj;
|
|
242
512
|
try {
|
|
243
|
-
|
|
244
|
-
if (obj.type === "user" || obj.type === "assistant") {
|
|
245
|
-
entries.push(obj);
|
|
246
|
-
}
|
|
513
|
+
obj = JSON.parse(line);
|
|
247
514
|
} catch {
|
|
248
515
|
continue;
|
|
249
516
|
}
|
|
517
|
+
if (obj.type === "user" || obj.type === "assistant") {
|
|
518
|
+
entries.push({ entry: obj, line: i });
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
const codexEntry = normalizeCodexEntry(obj, i);
|
|
522
|
+
if (codexEntry) entries.push({ entry: codexEntry, line: i });
|
|
250
523
|
}
|
|
524
|
+
return entries;
|
|
525
|
+
}
|
|
251
526
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
527
|
+
/**
|
|
528
|
+
* Group parsed entries into exchanges using turn-assembly, then render each
|
|
529
|
+
* exchange to the { input, output, reasoning } shape addMessage sends.
|
|
530
|
+
*
|
|
531
|
+
* A real user message (no toolUseResult) starts a new exchange. All
|
|
532
|
+
* subsequent assistant lines and tool-result user lines belong to that
|
|
533
|
+
* exchange until the next real user message.
|
|
534
|
+
*
|
|
535
|
+
* `items` is the `{ entry, line }` list from parseTranscriptEntries. Returns
|
|
536
|
+
* `{ exchanges, pendingFromLine }`:
|
|
537
|
+
* - `exchanges` — the renderable exchange list (unchanged contract). All
|
|
538
|
+
* assistant messages of a turn (Codex `commentary` … `final_answer`) are
|
|
539
|
+
* folded into the single assistant side, not just the first.
|
|
540
|
+
* - `pendingFromLine` — the 0-based transcript line index of a trailing
|
|
541
|
+
* user turn that is renderable but NOT yet safe to consume, or `null`
|
|
542
|
+
* otherwise. A turn is not safe to consume when it is still awaiting its
|
|
543
|
+
* assistant reply, or — for Codex — when its assistant sequence has not
|
|
544
|
+
* reached the completion boundary yet (no `phase: final_answer` message
|
|
545
|
+
* and no `task_complete` marker). Callers advance the watermark only up to
|
|
546
|
+
* this line so the in-progress turn is re-read and captured whole once it
|
|
547
|
+
* finishes. `null` for a filtered/never-renderable trailing turn (e.g.
|
|
548
|
+
* `<system-reminder>` only) so the watermark can advance past it, and for a
|
|
549
|
+
* complete trailing turn, which advances fully.
|
|
550
|
+
*/
|
|
551
|
+
function buildExchanges(items) {
|
|
552
|
+
const skipPattern =
|
|
553
|
+
/^<(system-reminder|command-|local-command|available-deferred)/;
|
|
554
|
+
const turns = [];
|
|
257
555
|
let current = null;
|
|
258
556
|
|
|
259
|
-
for (const entry of
|
|
557
|
+
for (const { entry, line } of items) {
|
|
260
558
|
if (entry.type === "user" && !isToolResultMessage(entry)) {
|
|
261
|
-
if (current)
|
|
559
|
+
if (current) turns.push(current);
|
|
262
560
|
current = {
|
|
263
561
|
userMsg: entry,
|
|
562
|
+
userLine: line,
|
|
264
563
|
assistantEntries: [],
|
|
265
564
|
toolResultEntries: [],
|
|
565
|
+
// Codex streaming turns end at a completion boundary (phase:
|
|
566
|
+
// final_answer, or a task_complete event_msg). `codexStreaming` marks a
|
|
567
|
+
// turn that emitted commentary and is therefore mid-stream until a
|
|
568
|
+
// boundary arrives. A Codex turn with no phase info at all is the
|
|
569
|
+
// legacy/simple shape — complete as soon as any assistant message
|
|
570
|
+
// exists, like non-Codex turns.
|
|
571
|
+
codexStreaming: false,
|
|
572
|
+
codexComplete: false,
|
|
266
573
|
};
|
|
267
574
|
} else if (current) {
|
|
268
575
|
if (entry.type === "assistant") {
|
|
269
576
|
current.assistantEntries.push(entry);
|
|
577
|
+
if (entry.codexPhase === CODEX_FINAL_ANSWER_PHASE) {
|
|
578
|
+
current.codexComplete = true;
|
|
579
|
+
} else if (entry.codexPhase === CODEX_COMMENTARY_PHASE) {
|
|
580
|
+
current.codexStreaming = true;
|
|
581
|
+
}
|
|
582
|
+
} else if (entry.type === "codex_task_complete") {
|
|
583
|
+
current.codexComplete = true;
|
|
270
584
|
} else if (entry.type === "user" && isToolResultMessage(entry)) {
|
|
271
585
|
current.toolResultEntries.push(entry);
|
|
272
586
|
}
|
|
273
587
|
}
|
|
274
588
|
}
|
|
275
|
-
if (current)
|
|
589
|
+
if (current) turns.push(current);
|
|
590
|
+
|
|
591
|
+
// Decide whether the TRAILING turn is safe to consume. A checkpoint can fire
|
|
592
|
+
// mid-turn (after the user, after some assistant commentary, but before the
|
|
593
|
+
// final answer / task_complete). Consuming it then would advance the
|
|
594
|
+
// watermark past the in-progress turn, so the later assistant messages get
|
|
595
|
+
// read without their user and dropped. A trailing turn is NOT safe to consume
|
|
596
|
+
// when it has no assistant reply yet, OR when it is a Codex turn still
|
|
597
|
+
// streaming commentary with no completion boundary. Hold only when that turn
|
|
598
|
+
// is actually renderable (real input, not a filtered marker); a filtered or
|
|
599
|
+
// never-renderable trailing turn produces no exchange regardless, so we let
|
|
600
|
+
// the watermark advance past it rather than reparse it forever.
|
|
601
|
+
let pendingFromLine = null;
|
|
602
|
+
const last = turns.length > 0 ? turns[turns.length - 1] : null;
|
|
603
|
+
const lastIncomplete = last
|
|
604
|
+
? last.assistantEntries.length === 0 ||
|
|
605
|
+
(last.codexStreaming && !last.codexComplete)
|
|
606
|
+
: false;
|
|
607
|
+
if (last && lastIncomplete) {
|
|
608
|
+
const lastInput = extractText((last.userMsg.message || {}).content);
|
|
609
|
+
if (lastInput && !skipPattern.test(lastInput)) {
|
|
610
|
+
pendingFromLine = last.userLine;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
276
613
|
|
|
277
|
-
//
|
|
278
|
-
|
|
279
|
-
|
|
614
|
+
// Build output for each COMPLETE turn. The trailing turn is skipped while it
|
|
615
|
+
// is still pending (pendingFromLine set) so its partial assistant content is
|
|
616
|
+
// not captured early — it is captured whole on a later run.
|
|
280
617
|
const results = [];
|
|
281
618
|
|
|
282
|
-
for (
|
|
619
|
+
for (let t = 0; t < turns.length; t++) {
|
|
620
|
+
const ex = turns[t];
|
|
621
|
+
const isPendingTrailing =
|
|
622
|
+
pendingFromLine !== null && t === turns.length - 1;
|
|
623
|
+
if (isPendingTrailing) continue;
|
|
624
|
+
|
|
283
625
|
const input = extractText((ex.userMsg.message || {}).content);
|
|
284
626
|
if (!input || skipPattern.test(input)) continue;
|
|
285
627
|
if (ex.assistantEntries.length === 0) continue;
|
|
@@ -308,6 +650,13 @@ function extractExchanges(filePath, startLine) {
|
|
|
308
650
|
|
|
309
651
|
results.push({
|
|
310
652
|
user_uuid: ex.userMsg.uuid || "unknown",
|
|
653
|
+
// PR #262: line-keyed boundary so missing/duplicate uuids cannot skip
|
|
654
|
+
// remaining exchanges. Prefer this over uuid/index for cursor advances.
|
|
655
|
+
user_line: ex.userLine,
|
|
656
|
+
// PR #254: next user turn line, or null for the final complete turn
|
|
657
|
+
// (caller substitutes safeLine when draining the full suffix).
|
|
658
|
+
next_line_number:
|
|
659
|
+
t + 1 < turns.length ? turns[t + 1].userLine : null,
|
|
311
660
|
input: input,
|
|
312
661
|
output: outputParts.join("\n\n"),
|
|
313
662
|
reasoning: reasoningParts.join("\n---\n"),
|
|
@@ -318,41 +667,202 @@ function extractExchanges(filePath, startLine) {
|
|
|
318
667
|
});
|
|
319
668
|
}
|
|
320
669
|
|
|
321
|
-
return results;
|
|
670
|
+
return { exchanges: results, pendingFromLine, turns };
|
|
322
671
|
}
|
|
323
672
|
|
|
324
|
-
|
|
673
|
+
/**
|
|
674
|
+
* Extract renderable exchanges from a transcript starting at `startLine`.
|
|
675
|
+
* Thin wrapper over parseTranscriptEntries + buildExchanges kept for the
|
|
676
|
+
* callers that only need the exchange list.
|
|
677
|
+
*/
|
|
678
|
+
function extractExchanges(filePath, startLine) {
|
|
679
|
+
return buildExchanges(parseTranscriptEntries(filePath, startLine)).exchanges;
|
|
680
|
+
}
|
|
325
681
|
|
|
682
|
+
// --- Watermark / outbox (bridged to capture-state v2) ---
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Compatibility reader used by SessionStart resume/rebucket paths.
|
|
686
|
+
* Returns a flat object with the classic watermark fields. Corrupt files are
|
|
687
|
+
* surfaced via `__corrupt` so callers never invent a zero cursor.
|
|
688
|
+
*/
|
|
326
689
|
function readWatermark(wmPath) {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
690
|
+
const sessionId = path.basename(wmPath, ".watermark.json");
|
|
691
|
+
const result = captureState.readState(sessionId);
|
|
692
|
+
if (result.ok && result.state) {
|
|
693
|
+
const s = result.state;
|
|
694
|
+
return {
|
|
695
|
+
conversation_id: s.conversation_id || "",
|
|
696
|
+
last_line_number: s.last_line_number || 0,
|
|
697
|
+
agent_id: s.agent_id || "",
|
|
698
|
+
agent_id_source: s.agent_id_source || "",
|
|
699
|
+
datapack_id: s.datapack_id || null,
|
|
700
|
+
datapack_name: s.datapack_name || null,
|
|
701
|
+
updated_at: s.updated_at || "",
|
|
702
|
+
lifecycle: s.lifecycle,
|
|
703
|
+
delivery: s.delivery,
|
|
704
|
+
schema_version: s.schema_version,
|
|
705
|
+
session_id: s.session_id,
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
if (result.missing) {
|
|
709
|
+
return {
|
|
710
|
+
conversation_id: "",
|
|
711
|
+
last_line_number: 0,
|
|
712
|
+
agent_id: "",
|
|
713
|
+
agent_id_source: "",
|
|
714
|
+
};
|
|
331
715
|
}
|
|
716
|
+
return {
|
|
717
|
+
__corrupt: true,
|
|
718
|
+
failure_class: result.failure_class || "state_corrupt",
|
|
719
|
+
error: result.error || "corrupt watermark",
|
|
720
|
+
conversation_id: "",
|
|
721
|
+
last_line_number: 0,
|
|
722
|
+
agent_id: "",
|
|
723
|
+
agent_id_source: "",
|
|
724
|
+
};
|
|
332
725
|
}
|
|
333
726
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
727
|
+
/**
|
|
728
|
+
* Persist outbox fields via capture-state. Never silently replaces a corrupt
|
|
729
|
+
* file. Conversation-id changes go through rebucket() so the epoch resets
|
|
730
|
+
* correctly instead of a non-monotonic cursor write.
|
|
731
|
+
*/
|
|
732
|
+
function writeWatermark(wmPath, convId, lineNum, agentId, datapackPin = null, agentIdSource = null) {
|
|
733
|
+
const sessionId = path.basename(wmPath, ".watermark.json");
|
|
734
|
+
const existing = captureState.readState(sessionId);
|
|
735
|
+
if (!existing.ok && !existing.missing && existing.failure_class === "state_corrupt") {
|
|
736
|
+
process.stderr.write(
|
|
737
|
+
`[meko-capture] refusing to overwrite corrupt watermark for ${sessionId}: ${existing.error}\n`,
|
|
738
|
+
);
|
|
739
|
+
return existing;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const now = new Date().toISOString();
|
|
743
|
+
let state = existing.ok
|
|
744
|
+
? { ...existing.state }
|
|
745
|
+
: {
|
|
746
|
+
schema_version: 2,
|
|
747
|
+
session_id: sessionId,
|
|
748
|
+
client: resolveHookClient() || "",
|
|
749
|
+
agent_id: "",
|
|
750
|
+
agent_id_source: "",
|
|
751
|
+
transcript_path: null,
|
|
752
|
+
datapack_id: null,
|
|
753
|
+
datapack_name: null,
|
|
754
|
+
created_at: now,
|
|
755
|
+
updated_at: now,
|
|
756
|
+
last_activity_at: now,
|
|
757
|
+
conversation_id: null,
|
|
758
|
+
conversation_epoch: 0,
|
|
759
|
+
last_line_number: 0,
|
|
760
|
+
in_flight: { seed: null, user_line: null, user_turn_id: null },
|
|
761
|
+
lifecycle: "active",
|
|
762
|
+
delivery: "needs_conversation",
|
|
763
|
+
queued_exchanges: 0,
|
|
764
|
+
attempt_count: 0,
|
|
765
|
+
next_retry_at: null,
|
|
766
|
+
failure_class: null,
|
|
767
|
+
last_error: null,
|
|
768
|
+
last_success_at: null,
|
|
769
|
+
blocked_reason: null,
|
|
770
|
+
};
|
|
771
|
+
|
|
337
772
|
const existingPin =
|
|
338
|
-
|
|
339
|
-
? {
|
|
340
|
-
datapack_id: existing.datapack_id,
|
|
341
|
-
datapack_name: existing.datapack_name,
|
|
342
|
-
}
|
|
773
|
+
state.datapack_id && state.datapack_name
|
|
774
|
+
? { datapack_id: state.datapack_id, datapack_name: state.datapack_name }
|
|
343
775
|
: null;
|
|
344
776
|
const pin = datapackPin || existingPin;
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
777
|
+
const source =
|
|
778
|
+
agentIdSource != null ? agentIdSource : state.agent_id_source || "";
|
|
779
|
+
|
|
780
|
+
const prevConv = state.conversation_id || "";
|
|
781
|
+
const nextConv = convId || "";
|
|
782
|
+
const conversationChanged = Boolean(nextConv) && nextConv !== prevConv;
|
|
783
|
+
|
|
784
|
+
if (conversationChanged && prevConv) {
|
|
785
|
+
const rb = captureState.rebucket(state, nextConv);
|
|
786
|
+
if (!rb.ok) {
|
|
787
|
+
process.stderr.write(
|
|
788
|
+
`[meko-capture] rebucket failed for ${sessionId}: ${rb.error}\n`,
|
|
789
|
+
);
|
|
790
|
+
return rb;
|
|
791
|
+
}
|
|
792
|
+
state = rb.state;
|
|
793
|
+
if (lineNum > 0) {
|
|
794
|
+
const adv = captureState.advanceCursor(state, lineNum, { clearSeed: true });
|
|
795
|
+
if (adv.ok) state = adv.state;
|
|
796
|
+
}
|
|
797
|
+
} else {
|
|
798
|
+
state.conversation_id = nextConv || null;
|
|
799
|
+
const targetLine = Number(lineNum) || 0;
|
|
800
|
+
if (targetLine > state.last_line_number) {
|
|
801
|
+
const adv = captureState.advanceCursor(state, targetLine, { clearSeed: true });
|
|
802
|
+
if (adv.ok) state = adv.state;
|
|
803
|
+
} else if (targetLine === state.last_line_number) {
|
|
804
|
+
// no-op cursor; still refresh identity/datapack below
|
|
805
|
+
} else if (!existing.ok || existing.missing) {
|
|
806
|
+
state.last_line_number = targetLine;
|
|
807
|
+
} else if (conversationChanged && !prevConv) {
|
|
808
|
+
// First conversation bind on an intent-only outbox — cursor may be 0.
|
|
809
|
+
state.last_line_number = targetLine;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
state.agent_id = agentId != null ? agentId : state.agent_id || "";
|
|
814
|
+
state.agent_id_source = source;
|
|
815
|
+
state.datapack_id = pin ? pin.datapack_id : null;
|
|
816
|
+
state.datapack_name = pin ? pin.datapack_name : null;
|
|
817
|
+
if (state.conversation_id) {
|
|
818
|
+
if (state.delivery === "needs_conversation" || !state.delivery) {
|
|
819
|
+
state.delivery = "idle";
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
state.last_activity_at = now;
|
|
823
|
+
state.updated_at = now;
|
|
824
|
+
|
|
825
|
+
return captureState.writeState(sessionId, state);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/** Real user-turn line indexes from a parseTranscriptEntries result (PR #262). */
|
|
829
|
+
function realUserTurnLines(entries) {
|
|
830
|
+
const lines = [];
|
|
831
|
+
for (const { entry, line } of entries) {
|
|
832
|
+
if (entry.type === "user" && !isToolResultMessage(entry)) {
|
|
833
|
+
lines.push(line);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
return lines;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* First line that must remain unread after the exchange opening at `userLine`
|
|
841
|
+
* is durably captured — the next real user turn — or null when none remains.
|
|
842
|
+
*/
|
|
843
|
+
function boundaryAfterExchange(turnLines, userLine) {
|
|
844
|
+
if (typeof userLine !== "number") return null;
|
|
845
|
+
for (const line of turnLines) {
|
|
846
|
+
if (line > userLine) return line;
|
|
847
|
+
}
|
|
848
|
+
return null;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function makeDrainResult(partial) {
|
|
852
|
+
return {
|
|
853
|
+
status: partial.status || "noop",
|
|
854
|
+
session_id: partial.session_id || "",
|
|
855
|
+
captured: partial.captured || 0,
|
|
856
|
+
queued_remaining: partial.queued_remaining || 0,
|
|
857
|
+
failure_class: partial.failure_class != null ? partial.failure_class : null,
|
|
858
|
+
error: partial.error != null ? partial.error : null,
|
|
859
|
+
cursor_advanced: Boolean(partial.cursor_advanced),
|
|
860
|
+
last_line_number:
|
|
861
|
+
partial.last_line_number != null ? partial.last_line_number : 0,
|
|
862
|
+
lifecycle: partial.lifecycle || "active",
|
|
863
|
+
delivery: partial.delivery || "idle",
|
|
864
|
+
next_retry_at: partial.next_retry_at || null,
|
|
865
|
+
};
|
|
356
866
|
}
|
|
357
867
|
|
|
358
868
|
/**
|
|
@@ -409,7 +919,7 @@ function buildActiveDatapackBlock(pin) {
|
|
|
409
919
|
|
|
410
920
|
### Active datapack
|
|
411
921
|
|
|
412
|
-
The user pinned datapack **${pin.datapack_name}** (\`${pin.datapack_id}\`) for this project via the \`meko-select-datapack\` skill. Pass \`datapack_id="${pin.datapack_id}"\` to every Meko MCP tool call (memory_*,
|
|
922
|
+
The user pinned datapack **${pin.datapack_name}** (\`${pin.datapack_id}\`) for this project via the \`meko-select-datapack\` skill. Pass \`datapack_id="${pin.datapack_id}"\` to every Meko MCP tool call that accepts it (memory_*, knowledgebase_search, conversation_*, artifact_*) unless the user explicitly overrides for a single call. The pin is project-scoped (keyed by \`agent_id\`) and survives \`/clear\`, \`/compact\`, and Claude Code restart. Automatic capture keeps the datapack selected when this conversation was created; switching or clearing takes effect for automatic capture on the next new session.`;
|
|
413
923
|
}
|
|
414
924
|
|
|
415
925
|
// note: Subagents cannot auto-discover the parent's watermark today.
|
|
@@ -470,6 +980,16 @@ function mcpPost(jsonBody) {
|
|
|
470
980
|
res.on("data", (chunk) => (data += chunk));
|
|
471
981
|
res.on("end", () => {
|
|
472
982
|
inflightRequests.delete(req);
|
|
983
|
+
if (res.statusCode >= 400) {
|
|
984
|
+
const snippet = data.trim().replace(/\s+/g, " ").slice(0, 200);
|
|
985
|
+
const err = new Error(
|
|
986
|
+
`MCP HTTP ${res.statusCode}${snippet ? `: ${snippet}` : ""}`,
|
|
987
|
+
);
|
|
988
|
+
err.statusCode = res.statusCode;
|
|
989
|
+
err.responseBody = data.slice(0, 1000);
|
|
990
|
+
reject(err);
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
473
993
|
// Notifications return 202/204 with no body — that's OK
|
|
474
994
|
if (!data.trim()) {
|
|
475
995
|
resolve({ body: null, headers: res.headers });
|
|
@@ -535,6 +1055,51 @@ function parseMcpResponseBody(data) {
|
|
|
535
1055
|
return JSON.parse(payload);
|
|
536
1056
|
}
|
|
537
1057
|
|
|
1058
|
+
function classifyPersistentCaptureFailure(err) {
|
|
1059
|
+
const message = err && err.message ? String(err.message) : String(err || "");
|
|
1060
|
+
const normalized = message.toLowerCase();
|
|
1061
|
+
const statusCode = Number(err && err.statusCode);
|
|
1062
|
+
const statusMatch = normalized.match(/\bmcp http (402|403)\b/);
|
|
1063
|
+
let persistentStatus = null;
|
|
1064
|
+
if (statusCode === 402 || statusCode === 403) {
|
|
1065
|
+
persistentStatus = statusCode;
|
|
1066
|
+
} else if (statusMatch) {
|
|
1067
|
+
persistentStatus = Number(statusMatch[1]);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
if (persistentStatus) {
|
|
1071
|
+
return {
|
|
1072
|
+
code: `http_${persistentStatus}`,
|
|
1073
|
+
reason: `Meko rejected capture with HTTP ${persistentStatus}`,
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
const persistentPatterns = [
|
|
1078
|
+
["free_tier_limit_reached", /free[_\s-]?tier.*limit|free_tier_limit_reached/],
|
|
1079
|
+
["quota_exceeded", /quota[_\s-]?(reached|exceeded)|(?:reached|exceeded).*quota/],
|
|
1080
|
+
["usage_limit_reached", /usage[_\s-]?limit[_\s-]?(reached|exceeded)/],
|
|
1081
|
+
["plan_limit_reached", /plan[_\s-]?limit[_\s-]?(reached|exceeded)/],
|
|
1082
|
+
["payment_required", /payment[_\s-]?required/],
|
|
1083
|
+
["subscription_required", /subscription[_\s-]?(required|inactive|expired)/],
|
|
1084
|
+
["entitlement_denied", /entitlement.*(denied|required|missing)|(?:denied|required|missing).*entitlement/],
|
|
1085
|
+
];
|
|
1086
|
+
for (const [code, pattern] of persistentPatterns) {
|
|
1087
|
+
if (pattern.test(normalized)) {
|
|
1088
|
+
const reasons = {
|
|
1089
|
+
free_tier_limit_reached: "the free-tier capture limit was reached",
|
|
1090
|
+
quota_exceeded: "the capture quota was exceeded",
|
|
1091
|
+
usage_limit_reached: "the capture usage limit was reached",
|
|
1092
|
+
plan_limit_reached: "the current plan's capture limit was reached",
|
|
1093
|
+
payment_required: "capture requires payment",
|
|
1094
|
+
subscription_required: "capture requires an active subscription",
|
|
1095
|
+
entitlement_denied: "the account is not entitled to capture conversations",
|
|
1096
|
+
};
|
|
1097
|
+
return { code, reason: reasons[code] };
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
return null;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
538
1103
|
/**
|
|
539
1104
|
* Abort all pending MCP requests. Used by the preload timeout path so the
|
|
540
1105
|
* SessionStart hook can return promptly instead of waiting for a slow server
|
|
@@ -654,7 +1219,6 @@ function peekTranscriptMetadata(transcriptPath) {
|
|
|
654
1219
|
*/
|
|
655
1220
|
async function fetchRecentMemories(agentId, datapackId = null, { limit = 10, budget = 2000 } = {}) {
|
|
656
1221
|
const payload = {
|
|
657
|
-
scope: "read",
|
|
658
1222
|
agent_id: agentId,
|
|
659
1223
|
conversation_id: "00000000-0000-0000-0000-000000000000",
|
|
660
1224
|
limit,
|
|
@@ -679,10 +1243,15 @@ async function fetchRecentMemories(agentId, datapackId = null, { limit = 10, bud
|
|
|
679
1243
|
}
|
|
680
1244
|
|
|
681
1245
|
async function createConversation(sessionId, agentId, metadata = null, datapackId = null) {
|
|
1246
|
+
const clientLabel = {
|
|
1247
|
+
claude_code: "Claude Code",
|
|
1248
|
+
cursor: "Cursor",
|
|
1249
|
+
codex: "Codex",
|
|
1250
|
+
kiro: "Kiro",
|
|
1251
|
+
}[resolveHookClient()] || "Coding agent";
|
|
682
1252
|
const payload = {
|
|
683
|
-
scope: "write",
|
|
684
1253
|
agent_id: agentId,
|
|
685
|
-
title:
|
|
1254
|
+
title: `${clientLabel} session (auto-captured)`,
|
|
686
1255
|
session_id: sessionId,
|
|
687
1256
|
};
|
|
688
1257
|
if (metadata) {
|
|
@@ -699,10 +1268,103 @@ async function createConversation(sessionId, agentId, metadata = null, datapackI
|
|
|
699
1268
|
return result && result.id ? result.id : null;
|
|
700
1269
|
}
|
|
701
1270
|
|
|
1271
|
+
/**
|
|
1272
|
+
* Ensure the conversation we are about to write to is owned by `resolvedAgentId`.
|
|
1273
|
+
*
|
|
1274
|
+
* The server pins a conversation's owner at `conversation_create` and rejects
|
|
1275
|
+
* `conversation_add_message` / `conversation_update` whose agent_id differs
|
|
1276
|
+
* (src/tools.py → `agent_id_mismatch`). There is no owner-reassign tool. So
|
|
1277
|
+
* when MEKO-385 reconciliation changes the identity (e.g. a conversation the
|
|
1278
|
+
* OLD Codex hook created under `claude_code:<repo>` must now be `codex:<repo>`),
|
|
1279
|
+
* we CANNOT keep writing to the old conversation — every future write would
|
|
1280
|
+
* fail closed and wedge the watermark. Instead we open a NEW conversation
|
|
1281
|
+
* under the corrected agent_id and repoint the watermark at it (approach (a);
|
|
1282
|
+
* no server reassign exists).
|
|
1283
|
+
*
|
|
1284
|
+
* The invariant this preserves: a watermark's `agent_id` always equals the
|
|
1285
|
+
* owner of its `conversation_id`. So callers can detect a stale owner simply
|
|
1286
|
+
* by comparing the cached agent_id against the freshly resolved one.
|
|
1287
|
+
*
|
|
1288
|
+
* On rebucket-create FAILURE the fallback depends on WHY the rebucket was
|
|
1289
|
+
* demanded:
|
|
1290
|
+
* - EXPLICIT override (resolvedSource === "explicit", i.e. the user set
|
|
1291
|
+
* MEKO_AGENT_ID): do NOT fall back to capturing under the stale owner —
|
|
1292
|
+
* that would silently attribute turns to the wrong namespace in direct
|
|
1293
|
+
* violation of the explicit override. Instead HOLD: return `{ hold: true }`
|
|
1294
|
+
* so the caller sends no turns and does not advance the watermark, and a
|
|
1295
|
+
* later hook retries the rebucket. A visible diagnostic is emitted.
|
|
1296
|
+
* - DERIVED (auto-derived id): fall back to the ORIGINAL owner (old
|
|
1297
|
+
* conversation + old agent_id) so writes still succeed and the watermark
|
|
1298
|
+
* advances — strictly better than failing closed; a later hook retries.
|
|
1299
|
+
*
|
|
1300
|
+
* The invariant this preserves: a watermark's `agent_id` always equals the
|
|
1301
|
+
* owner of its `conversation_id`.
|
|
1302
|
+
*
|
|
1303
|
+
* @returns {Promise<{convId: string, agentId: string, source: string,
|
|
1304
|
+
* rebucketed: boolean, hold?: boolean}>}
|
|
1305
|
+
*/
|
|
1306
|
+
async function ensureConversationOwner({
|
|
1307
|
+
convId,
|
|
1308
|
+
cachedOwner,
|
|
1309
|
+
resolvedAgentId,
|
|
1310
|
+
resolvedSource,
|
|
1311
|
+
sessionId,
|
|
1312
|
+
metadata = null,
|
|
1313
|
+
datapackId = null,
|
|
1314
|
+
mcpReady = false,
|
|
1315
|
+
}) {
|
|
1316
|
+
if (!ownerChanged(cachedOwner, resolvedAgentId, convId)) {
|
|
1317
|
+
return { convId, agentId: resolvedAgentId, source: resolvedSource, rebucketed: false };
|
|
1318
|
+
}
|
|
1319
|
+
try {
|
|
1320
|
+
if (!mcpReady) await mcpInitialize();
|
|
1321
|
+
const newId = await createConversation(
|
|
1322
|
+
sessionId || "unknown",
|
|
1323
|
+
resolvedAgentId,
|
|
1324
|
+
metadata,
|
|
1325
|
+
datapackId,
|
|
1326
|
+
);
|
|
1327
|
+
if (newId) {
|
|
1328
|
+
process.stderr.write(
|
|
1329
|
+
`[meko-capture] Rebucketed conversation ${convId} (owner ${cachedOwner || "unknown"}) -> ` +
|
|
1330
|
+
`new ${newId} owned by ${resolvedAgentId} (server pins owner at create).\n`,
|
|
1331
|
+
);
|
|
1332
|
+
return { convId: newId, agentId: resolvedAgentId, source: resolvedSource, rebucketed: true };
|
|
1333
|
+
}
|
|
1334
|
+
process.stderr.write("[meko-capture] Rebucket conversation_create returned no ID.\n");
|
|
1335
|
+
} catch (err) {
|
|
1336
|
+
process.stderr.write(`[meko-capture] Rebucket conversation_create failed (${err.message}).\n`);
|
|
1337
|
+
}
|
|
1338
|
+
// Create failed. An EXPLICIT override must never be captured under the stale
|
|
1339
|
+
// owner — hold and retry rather than write to the wrong namespace.
|
|
1340
|
+
if (resolvedSource === "explicit") {
|
|
1341
|
+
process.stderr.write(
|
|
1342
|
+
`[meko-capture] Explicit MEKO_AGENT_ID override requires rebucketing to ` +
|
|
1343
|
+
`${resolvedAgentId}, but conversation_create failed; HOLDING (no turns sent, ` +
|
|
1344
|
+
`watermark not advanced) so a later hook retries — will not capture under the ` +
|
|
1345
|
+
`stale owner ${cachedOwner || "unknown"}.\n`,
|
|
1346
|
+
);
|
|
1347
|
+
return { convId, agentId: resolvedAgentId, source: resolvedSource, rebucketed: false, hold: true };
|
|
1348
|
+
}
|
|
1349
|
+
// Derived case: fall back to the original owner so writes still succeed.
|
|
1350
|
+
// (When the owner is unknown/empty there is nothing safe to fall back to —
|
|
1351
|
+
// hold instead, since writing under an unknown owner risks agent_id_mismatch.)
|
|
1352
|
+
if (!(cachedOwner || "").trim()) {
|
|
1353
|
+
process.stderr.write(
|
|
1354
|
+
"[meko-capture] Unknown-owner conversation and rebucket create failed; " +
|
|
1355
|
+
"HOLDING (no turns sent) so a later hook retries.\n",
|
|
1356
|
+
);
|
|
1357
|
+
return { convId, agentId: resolvedAgentId, source: resolvedSource, rebucketed: false, hold: true };
|
|
1358
|
+
}
|
|
1359
|
+
process.stderr.write(
|
|
1360
|
+
`[meko-capture] Keeping original owner ${cachedOwner} so writes still succeed.\n`,
|
|
1361
|
+
);
|
|
1362
|
+
return { convId, agentId: cachedOwner, source: "derived", rebucketed: false };
|
|
1363
|
+
}
|
|
1364
|
+
|
|
702
1365
|
async function addMessage(convId, agentId, exchange, datapackId = null) {
|
|
703
1366
|
const seed = `${convId}:${exchange.user_uuid}`;
|
|
704
1367
|
const payload = {
|
|
705
|
-
scope: "write",
|
|
706
1368
|
conversation_id: convId,
|
|
707
1369
|
agent_id: agentId,
|
|
708
1370
|
input: exchange.input,
|
|
@@ -711,6 +1373,12 @@ async function addMessage(convId, agentId, exchange, datapackId = null) {
|
|
|
711
1373
|
seed: seed,
|
|
712
1374
|
};
|
|
713
1375
|
if (datapackId) payload.datapack_id = datapackId;
|
|
1376
|
+
if (exchange.metadata != null) {
|
|
1377
|
+
payload.metadata =
|
|
1378
|
+
typeof exchange.metadata === "string"
|
|
1379
|
+
? exchange.metadata
|
|
1380
|
+
: JSON.stringify(exchange.metadata);
|
|
1381
|
+
}
|
|
714
1382
|
const response = await mcpCall("conversation_add_message", payload);
|
|
715
1383
|
// FAIL CLOSED. The MCP transport resolves successfully even when the tool
|
|
716
1384
|
// failed, and failures take several shapes: an `isError` result envelope, a
|
|
@@ -734,6 +1402,19 @@ async function addMessage(convId, agentId, exchange, datapackId = null) {
|
|
|
734
1402
|
if (result.error) {
|
|
735
1403
|
throw new Error(`conversation_add_message failed: ${JSON.stringify(result.error)}`);
|
|
736
1404
|
}
|
|
1405
|
+
// Contracts: advance only after a structurally valid accepted response that
|
|
1406
|
+
// contains a message ID (server returns message_id; tolerate `id` aliases).
|
|
1407
|
+
const messageId = result.message_id || result.id || null;
|
|
1408
|
+
if (!messageId) {
|
|
1409
|
+
throw new Error(
|
|
1410
|
+
"conversation_add_message: accepted response missing message_id (fail-closed)",
|
|
1411
|
+
);
|
|
1412
|
+
}
|
|
1413
|
+
if (result.status !== "accepted") {
|
|
1414
|
+
throw new Error(
|
|
1415
|
+
`conversation_add_message: unexpected status ${JSON.stringify(result.status)}`,
|
|
1416
|
+
);
|
|
1417
|
+
}
|
|
737
1418
|
return result;
|
|
738
1419
|
}
|
|
739
1420
|
|
|
@@ -745,14 +1426,20 @@ function hookOutput(additionalContext) {
|
|
|
745
1426
|
// emitting it for lifecycle capture hooks makes Codex reject the JSON.
|
|
746
1427
|
if (process.argv[2] !== "session-start") return "{}";
|
|
747
1428
|
|
|
748
|
-
//
|
|
749
|
-
//
|
|
1429
|
+
// Codex rejects unknown top-level fields, so its SessionStart response must
|
|
1430
|
+
// contain only the hook-specific output defined by the Codex wire schema.
|
|
750
1431
|
const output = {
|
|
751
1432
|
hookSpecificOutput: {
|
|
752
1433
|
hookEventName: "SessionStart",
|
|
753
1434
|
additionalContext: additionalContext || "",
|
|
754
1435
|
},
|
|
755
1436
|
};
|
|
1437
|
+
if (process.env.MEKO_HOOK_CLIENT === "codex") {
|
|
1438
|
+
return JSON.stringify(output);
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
// Preserve the native coding-agent harness field for clients that consume
|
|
1442
|
+
// it alongside the nested Claude-compatible shape.
|
|
756
1443
|
output.additional_context = additionalContext || "";
|
|
757
1444
|
return JSON.stringify(output);
|
|
758
1445
|
}
|
|
@@ -792,17 +1479,28 @@ function sessionCachePath(sessionId) {
|
|
|
792
1479
|
function writeSessionCache(sessionId, payload) {
|
|
793
1480
|
const filePath = sessionCachePath(sessionId);
|
|
794
1481
|
if (!filePath) return;
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
1482
|
+
try {
|
|
1483
|
+
const workspaceKey = payload.workspace_key || "";
|
|
1484
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
1485
|
+
const record = {
|
|
1486
|
+
session_id: sessionId,
|
|
1487
|
+
updated_at: new Date().toISOString(),
|
|
1488
|
+
...payload,
|
|
1489
|
+
};
|
|
1490
|
+
fs.writeFileSync(filePath, JSON.stringify(record));
|
|
1491
|
+
if (workspaceKey) {
|
|
1492
|
+
const safe = workspaceKey.replace(/[^A-Za-z0-9_.-]/g, "_");
|
|
1493
|
+
fs.writeFileSync(
|
|
1494
|
+
path.join(SESSION_CACHE_DIR, `workspace-${safe}.json`),
|
|
1495
|
+
JSON.stringify(record),
|
|
1496
|
+
);
|
|
1497
|
+
}
|
|
1498
|
+
} catch (err) {
|
|
1499
|
+
// Cursor's session cache is a rebuildable convenience; it must never turn
|
|
1500
|
+
// a valid SessionStart context into an empty fallback response.
|
|
1501
|
+
process.stderr.write(
|
|
1502
|
+
`[meko-capture] SessionStart cache write skipped: ${err.message}\n`,
|
|
1503
|
+
);
|
|
806
1504
|
}
|
|
807
1505
|
}
|
|
808
1506
|
|
|
@@ -819,8 +1517,6 @@ function workspaceKeyFromHookInput(hookInput, transcriptPath) {
|
|
|
819
1517
|
return cwd || process.cwd();
|
|
820
1518
|
}
|
|
821
1519
|
|
|
822
|
-
// --- Main ---
|
|
823
|
-
|
|
824
1520
|
// --- SessionStart: create conversation + watermark deterministically ---
|
|
825
1521
|
|
|
826
1522
|
function findMostRecentProjectWatermark(transcriptPath, currentSessionId) {
|
|
@@ -841,6 +1537,7 @@ function findMostRecentProjectWatermark(transcriptPath, currentSessionId) {
|
|
|
841
1537
|
sessionId: sid,
|
|
842
1538
|
conversation_id: wm.conversation_id,
|
|
843
1539
|
agent_id: wm.agent_id || "",
|
|
1540
|
+
agent_id_source: wm.agent_id_source || "",
|
|
844
1541
|
datapack_id: wm.datapack_id || null,
|
|
845
1542
|
datapack_name: wm.datapack_name || null,
|
|
846
1543
|
updated_at: ts,
|
|
@@ -859,8 +1556,8 @@ function buildSessionStartContext(convId, sessionId, agentId, memories, opts) {
|
|
|
859
1556
|
const resumed = Boolean(opts && opts.resumed);
|
|
860
1557
|
const pin = opts && opts.datapackPin ? opts.datapackPin : null;
|
|
861
1558
|
const opening = resumed
|
|
862
|
-
? `Resuming Meko conversation **${convId}** from a prior Claude Code session. The watermark file is written — PreCompact and SessionEnd hooks will append this session's new exchanges to the same conversation.`
|
|
863
|
-
: `Meko conversation **${convId}** was created automatically by the SessionStart hook. The watermark file is written — PreCompact and SessionEnd hooks
|
|
1559
|
+
? `Resuming Meko conversation **${convId}** from a prior Claude Code session. The watermark file is written — PreCompact and SessionEnd hooks will attempt to append this session's new exchanges to the same conversation.`
|
|
1560
|
+
: `Meko conversation **${convId}** was created automatically by the SessionStart hook. The watermark file is written — PreCompact and SessionEnd hooks are configured to capture this session's transcript.`;
|
|
864
1561
|
const memoryBlock =
|
|
865
1562
|
Array.isArray(memories) && memories.length > 0
|
|
866
1563
|
? `
|
|
@@ -881,11 +1578,10 @@ ${opening}
|
|
|
881
1578
|
|
|
882
1579
|
- **agent_id**: use "${resolvedAgentId}" verbatim for every Meko MCP tool call in this session. This was derived from the cwd as \`<client>:<repo-basename>\` so memories stay scoped to this project. For genuinely cross-project facts (user identity, global preferences) pass agent_id="${COMMON_BUCKET_AGENT_ID}" — that's the common bucket any agent can read regardless of project.
|
|
883
1580
|
- **conversation_id**: Use "${convId}" for all MCP tool calls that accept it
|
|
884
|
-
- **scope**: Only "read", "write", or "admin"
|
|
885
1581
|
|
|
886
|
-
### Memory capture is automatic — do NOT proactively call memory_add
|
|
1582
|
+
### Memory capture is automatic only when hook delivery succeeds — do NOT proactively call memory_add
|
|
887
1583
|
|
|
888
|
-
|
|
1584
|
+
The hooks above attempt to capture this session's turns, and the server extracts durable memories from successfully delivered turns on its own. Do not call memory_add for facts the user says out loud; when hook delivery succeeds, that would duplicate what extraction already stores. If the warning above is present, tell the user instead of pretending an explicit memory write will bypass the same server rejection.
|
|
889
1585
|
|
|
890
1586
|
Call memory_add (with the session's agent_id + conversation_id) ONLY for the narrow cases extraction cannot reach:
|
|
891
1587
|
- The user explicitly says "remember this" / "save this to memory".
|
|
@@ -913,7 +1609,6 @@ The SessionStart hook could not reach the Meko MCP server to create a conversati
|
|
|
913
1609
|
|
|
914
1610
|
- **agent_id**: use "${resolvedAgentId}" for project-scoped writes; use "${COMMON_BUCKET_AGENT_ID}" for cross-project common facts
|
|
915
1611
|
- **conversation_id**: Use nil UUID "00000000-0000-0000-0000-000000000000" as placeholder
|
|
916
|
-
- **scope**: Only "read", "write", or "admin"
|
|
917
1612
|
|
|
918
1613
|
### Proactive storage (capture is down — do NOT wait to be asked)
|
|
919
1614
|
|
|
@@ -970,6 +1665,18 @@ function handleBeforeSubmitPrompt(hookInput) {
|
|
|
970
1665
|
process.stdout.write(beforeSubmitOutput("allow"));
|
|
971
1666
|
}
|
|
972
1667
|
|
|
1668
|
+
function withHealthNotice(context) {
|
|
1669
|
+
try {
|
|
1670
|
+
const { injectHealthNotice } = require("./capture-notices");
|
|
1671
|
+
return injectHealthNotice(context);
|
|
1672
|
+
} catch (err) {
|
|
1673
|
+
process.stderr.write(
|
|
1674
|
+
`[meko-capture] SessionStart health notice skipped: ${err && err.message ? err.message : err}\n`,
|
|
1675
|
+
);
|
|
1676
|
+
return context;
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
|
|
973
1680
|
async function handleSessionStart(hookInput) {
|
|
974
1681
|
const transcriptPath =
|
|
975
1682
|
hookInput.transcript_path ||
|
|
@@ -985,27 +1692,91 @@ async function handleSessionStart(hookInput) {
|
|
|
985
1692
|
if (sessionId) {
|
|
986
1693
|
const wmPath = path.join(WATERMARK_DIR, `${sessionId}.watermark.json`);
|
|
987
1694
|
const existing = readWatermark(wmPath);
|
|
1695
|
+
if (existing.__corrupt) {
|
|
1696
|
+
process.stderr.write(
|
|
1697
|
+
`[meko-capture] SessionStart: corrupt watermark for ${sessionId} (${existing.error}); blocking.\n`,
|
|
1698
|
+
);
|
|
1699
|
+
const context = withHealthNotice(buildSessionStartFallbackContext(derivedAgentId, {
|
|
1700
|
+
datapackPin: readDatapackPin(derivedAgentId),
|
|
1701
|
+
}));
|
|
1702
|
+
process.stdout.write(hookOutput(context));
|
|
1703
|
+
return;
|
|
1704
|
+
}
|
|
988
1705
|
if (existing.conversation_id) {
|
|
989
1706
|
process.stderr.write(`[meko-capture] Session ${sessionId}: reusing existing conversation ${existing.conversation_id}\n`);
|
|
990
1707
|
// Prefer the watermark's cached agent_id so a user-set bucket survives
|
|
991
|
-
// re-fires of SessionStart (e.g. /clear, /compact)
|
|
992
|
-
// the
|
|
993
|
-
//
|
|
994
|
-
//
|
|
995
|
-
|
|
996
|
-
|
|
1708
|
+
// re-fires of SessionStart (e.g. /clear, /compact), BUT re-derive when
|
|
1709
|
+
// the cached value is a stale auto-derived coding identity that no
|
|
1710
|
+
// longer matches the current client — e.g. an old Codex hook wrote
|
|
1711
|
+
// `claude_code:<repo>` before MEKO-385. reconcileCachedAgentId honors
|
|
1712
|
+
// an explicit MEKO_AGENT_ID override (agent_id_source) and upgrades the
|
|
1713
|
+
// legacy literal "agent". See its doc for existing-conversation
|
|
1714
|
+
// consistency (same conversation_id; only future turns re-namespace).
|
|
1715
|
+
const reconciled = reconcileCachedAgentId(
|
|
1716
|
+
existing.agent_id,
|
|
1717
|
+
existing.agent_id_source || "",
|
|
1718
|
+
derivedAgentId,
|
|
1719
|
+
);
|
|
997
1720
|
const existingDatapackPin = datapackPinFromWatermark(existing);
|
|
1721
|
+
// If the reconciled identity differs from the conversation's owner (the
|
|
1722
|
+
// cached agent_id), open a NEW conversation under it — the server pins
|
|
1723
|
+
// the owner at create and rejects mismatched writes, so keeping the old
|
|
1724
|
+
// conversation_id would wedge every future capture. ensureConversationOwner
|
|
1725
|
+
// is a no-op (no MCP) when the owner is unchanged.
|
|
1726
|
+
const owned = await ensureConversationOwner({
|
|
1727
|
+
convId: existing.conversation_id,
|
|
1728
|
+
cachedOwner: existing.agent_id,
|
|
1729
|
+
resolvedAgentId: reconciled.agentId,
|
|
1730
|
+
resolvedSource: reconciled.source,
|
|
1731
|
+
sessionId,
|
|
1732
|
+
metadata: peekTranscriptMetadata(transcriptPath),
|
|
1733
|
+
datapackId: existingDatapackPin && existingDatapackPin.datapack_id,
|
|
1734
|
+
});
|
|
1735
|
+
// HOLD: rebucket required but conversation_create failed. Leave the
|
|
1736
|
+
// existing watermark untouched (agent_id still equals the owner of its
|
|
1737
|
+
// conversation_id) and inject context under the actual owner so nothing
|
|
1738
|
+
// is written to the wrong namespace; a later hook retries the rebucket.
|
|
1739
|
+
if (owned.hold) {
|
|
1740
|
+
const holdContext = buildSessionStartContext(
|
|
1741
|
+
existing.conversation_id,
|
|
1742
|
+
sessionId,
|
|
1743
|
+
existing.agent_id,
|
|
1744
|
+
null,
|
|
1745
|
+
{
|
|
1746
|
+
datapackPin: existingDatapackPin,
|
|
1747
|
+
},
|
|
1748
|
+
);
|
|
1749
|
+
process.stdout.write(hookOutput(withHealthNotice(holdContext)));
|
|
1750
|
+
return;
|
|
1751
|
+
}
|
|
1752
|
+
const resumedAgentId = owned.agentId;
|
|
1753
|
+
const resumedConvId = owned.convId;
|
|
1754
|
+
// Persist the corrected identity + conversation (+ provenance) so the
|
|
1755
|
+
// checkpoint / PreCompact / SessionEnd hooks read a consistent watermark
|
|
1756
|
+
// (agent_id always equals the owner of conversation_id). On a rebucket
|
|
1757
|
+
// reset last_line_number to 0 so this session's turns replay into the
|
|
1758
|
+
// new conversation; otherwise preserve the prior line count.
|
|
1759
|
+
writeWatermark(
|
|
1760
|
+
wmPath,
|
|
1761
|
+
resumedConvId,
|
|
1762
|
+
owned.rebucketed ? 0 : (existing.last_line_number || 0),
|
|
1763
|
+
resumedAgentId,
|
|
1764
|
+
existingDatapackPin,
|
|
1765
|
+
owned.source,
|
|
1766
|
+
);
|
|
998
1767
|
// Skip preload on resume (we don't know what's already in context).
|
|
999
|
-
|
|
1000
|
-
|
|
1768
|
+
let context = withHealthNotice(buildSessionStartContext(
|
|
1769
|
+
resumedConvId,
|
|
1001
1770
|
sessionId,
|
|
1002
1771
|
resumedAgentId,
|
|
1003
1772
|
null,
|
|
1004
|
-
{
|
|
1005
|
-
|
|
1773
|
+
{
|
|
1774
|
+
datapackPin: existingDatapackPin,
|
|
1775
|
+
},
|
|
1776
|
+
));
|
|
1006
1777
|
writeSessionCache(sessionId, {
|
|
1007
1778
|
status: "ready",
|
|
1008
|
-
conversation_id:
|
|
1779
|
+
conversation_id: resumedConvId,
|
|
1009
1780
|
agent_id: resumedAgentId,
|
|
1010
1781
|
workspace_key: workspaceKey,
|
|
1011
1782
|
additional_context: context,
|
|
@@ -1025,34 +1796,73 @@ async function handleSessionStart(hookInput) {
|
|
|
1025
1796
|
if (prior && prior.conversation_id) {
|
|
1026
1797
|
const currentLineCount = fs.existsSync(transcriptPath) ? countLines(transcriptPath) : 0;
|
|
1027
1798
|
const wmPath = path.join(WATERMARK_DIR, `${sessionId}.watermark.json`);
|
|
1028
|
-
// Preserve a
|
|
1029
|
-
//
|
|
1030
|
-
//
|
|
1031
|
-
//
|
|
1032
|
-
// literal "agent"
|
|
1033
|
-
//
|
|
1034
|
-
const
|
|
1035
|
-
|
|
1036
|
-
|
|
1799
|
+
// Preserve a user-set bucket (explicit MEKO_AGENT_ID, or a project-scoped
|
|
1800
|
+
// id from the original session) across the resume, but re-derive a stale
|
|
1801
|
+
// auto-derived coding identity that no longer matches the current client
|
|
1802
|
+
// (e.g. an old Codex hook wrote claude_code:<repo> pre-MEKO-385) and
|
|
1803
|
+
// upgrade the legacy literal "agent". Mirrors the same-session reuse
|
|
1804
|
+
// branch above via reconcileCachedAgentId.
|
|
1805
|
+
const reconciled = reconcileCachedAgentId(
|
|
1806
|
+
prior.agent_id,
|
|
1807
|
+
prior.agent_id_source || "",
|
|
1808
|
+
agentId,
|
|
1809
|
+
);
|
|
1037
1810
|
const priorDatapackPin = datapackPinFromWatermark(prior);
|
|
1811
|
+
// Rebucket to a new conversation when the reconciled identity differs
|
|
1812
|
+
// from the prior conversation's owner (server pins owner at create).
|
|
1813
|
+
const owned = await ensureConversationOwner({
|
|
1814
|
+
convId: prior.conversation_id,
|
|
1815
|
+
cachedOwner: prior.agent_id,
|
|
1816
|
+
resolvedAgentId: reconciled.agentId,
|
|
1817
|
+
resolvedSource: reconciled.source,
|
|
1818
|
+
sessionId,
|
|
1819
|
+
metadata: peekTranscriptMetadata(transcriptPath),
|
|
1820
|
+
datapackId: priorDatapackPin && priorDatapackPin.datapack_id,
|
|
1821
|
+
});
|
|
1822
|
+
// HOLD: rebucket required but conversation_create failed. Do not write a
|
|
1823
|
+
// watermark for this resumed session (no owner we can safely write under)
|
|
1824
|
+
// — inject context under the prior owner and let a later hook retry.
|
|
1825
|
+
if (owned.hold) {
|
|
1826
|
+
const holdContext = buildSessionStartContext(
|
|
1827
|
+
prior.conversation_id,
|
|
1828
|
+
sessionId,
|
|
1829
|
+
prior.agent_id,
|
|
1830
|
+
null,
|
|
1831
|
+
{
|
|
1832
|
+
resumed: true,
|
|
1833
|
+
datapackPin: priorDatapackPin,
|
|
1834
|
+
},
|
|
1835
|
+
);
|
|
1836
|
+
process.stdout.write(hookOutput(withHealthNotice(holdContext)));
|
|
1837
|
+
return;
|
|
1838
|
+
}
|
|
1839
|
+
const resumedAgentId = owned.agentId;
|
|
1840
|
+
const resumedConvId = owned.convId;
|
|
1841
|
+
// On a fresh rebucketed conversation, start the watermark at 0 so this
|
|
1842
|
+
// session's turns replay into it. Otherwise keep currentLineCount so the
|
|
1843
|
+
// replayed copied history isn't re-ingested.
|
|
1038
1844
|
writeWatermark(
|
|
1039
1845
|
wmPath,
|
|
1040
|
-
|
|
1041
|
-
currentLineCount,
|
|
1846
|
+
resumedConvId,
|
|
1847
|
+
owned.rebucketed ? 0 : currentLineCount,
|
|
1042
1848
|
resumedAgentId,
|
|
1043
1849
|
priorDatapackPin,
|
|
1850
|
+
owned.source,
|
|
1044
1851
|
);
|
|
1045
|
-
process.stderr.write(`[meko-capture] Session ${sessionId}: resuming conversation ${
|
|
1046
|
-
|
|
1047
|
-
|
|
1852
|
+
process.stderr.write(`[meko-capture] Session ${sessionId}: resuming conversation ${resumedConvId} from ${prior.sessionId}, skipping ${owned.rebucketed ? 0 : currentLineCount} copied lines\n`);
|
|
1853
|
+
let context = withHealthNotice(buildSessionStartContext(
|
|
1854
|
+
resumedConvId,
|
|
1048
1855
|
sessionId,
|
|
1049
1856
|
resumedAgentId,
|
|
1050
1857
|
null,
|
|
1051
|
-
{
|
|
1052
|
-
|
|
1858
|
+
{
|
|
1859
|
+
resumed: true,
|
|
1860
|
+
datapackPin: priorDatapackPin,
|
|
1861
|
+
},
|
|
1862
|
+
));
|
|
1053
1863
|
writeSessionCache(sessionId, {
|
|
1054
1864
|
status: "ready",
|
|
1055
|
-
conversation_id:
|
|
1865
|
+
conversation_id: resumedConvId,
|
|
1056
1866
|
agent_id: resumedAgentId,
|
|
1057
1867
|
workspace_key: workspaceKey,
|
|
1058
1868
|
additional_context: context,
|
|
@@ -1065,8 +1875,29 @@ async function handleSessionStart(hookInput) {
|
|
|
1065
1875
|
|
|
1066
1876
|
// Create conversation via MCP. Attach session metadata (cwd / gitBranch /
|
|
1067
1877
|
// startedAt) from the transcript header so hook traces match the migrator.
|
|
1878
|
+
// Intent-first: durable outbox BEFORE any network call so create failures
|
|
1879
|
+
// leave a recoverable needs_conversation state (Capture V2).
|
|
1068
1880
|
const transcriptMetadata = peekTranscriptMetadata(transcriptPath);
|
|
1069
1881
|
const datapackPin = readDatapackPin(agentId);
|
|
1882
|
+
const agentIdSource = (process.env.MEKO_AGENT_ID || "").trim() ? "explicit" : "derived";
|
|
1883
|
+
if (sessionId) {
|
|
1884
|
+
const intent = captureState.writeSessionIntent(sessionId, {
|
|
1885
|
+
client: resolveHookClient() || "",
|
|
1886
|
+
agent_id: agentId,
|
|
1887
|
+
agent_id_source: agentIdSource,
|
|
1888
|
+
transcript_path: transcriptPath || null,
|
|
1889
|
+
datapack_id: datapackPin ? datapackPin.datapack_id : null,
|
|
1890
|
+
datapack_name: datapackPin ? datapackPin.datapack_name : null,
|
|
1891
|
+
});
|
|
1892
|
+
if (!intent.ok) {
|
|
1893
|
+
process.stderr.write(
|
|
1894
|
+
`[meko-capture] SessionStart: writeSessionIntent failed (${intent.failure_class}: ${intent.error}).\n`,
|
|
1895
|
+
);
|
|
1896
|
+
const context = withHealthNotice(buildSessionStartFallbackContext(agentId, { datapackPin }));
|
|
1897
|
+
process.stdout.write(hookOutput(context));
|
|
1898
|
+
return;
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1070
1901
|
let convId = null;
|
|
1071
1902
|
try {
|
|
1072
1903
|
await mcpInitialize();
|
|
@@ -1078,15 +1909,19 @@ async function handleSessionStart(hookInput) {
|
|
|
1078
1909
|
);
|
|
1079
1910
|
} catch (err) {
|
|
1080
1911
|
process.stderr.write(`[meko-capture] SessionStart: MCP unavailable (${err.message}). Falling back to agent-driven setup.\n`);
|
|
1081
|
-
|
|
1912
|
+
let context = withHealthNotice(buildSessionStartFallbackContext(agentId, { datapackPin: readDatapackPin(agentId) }));
|
|
1082
1913
|
if (sessionId) {
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1914
|
+
try {
|
|
1915
|
+
writeSessionCache(sessionId, {
|
|
1916
|
+
status: "error",
|
|
1917
|
+
agent_id: agentId,
|
|
1918
|
+
workspace_key: workspaceKey,
|
|
1919
|
+
error: err && err.message ? String(err.message).slice(0, 240) : "MCP unavailable",
|
|
1920
|
+
additional_context: context,
|
|
1921
|
+
});
|
|
1922
|
+
} catch (cacheErr) {
|
|
1923
|
+
process.stderr.write(`[meko-capture] SessionStart cache write skipped: ${cacheErr.message}\n`);
|
|
1924
|
+
}
|
|
1090
1925
|
}
|
|
1091
1926
|
process.stdout.write(hookOutput(context));
|
|
1092
1927
|
return;
|
|
@@ -1094,15 +1929,19 @@ async function handleSessionStart(hookInput) {
|
|
|
1094
1929
|
|
|
1095
1930
|
if (!convId) {
|
|
1096
1931
|
process.stderr.write("[meko-capture] SessionStart: conversation_create returned no ID.\n");
|
|
1097
|
-
|
|
1932
|
+
let context = withHealthNotice(buildSessionStartFallbackContext(agentId, { datapackPin: readDatapackPin(agentId) }));
|
|
1098
1933
|
if (sessionId) {
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1934
|
+
try {
|
|
1935
|
+
writeSessionCache(sessionId, {
|
|
1936
|
+
status: "error",
|
|
1937
|
+
agent_id: agentId,
|
|
1938
|
+
workspace_key: workspaceKey,
|
|
1939
|
+
error: "conversation_create returned no ID",
|
|
1940
|
+
additional_context: context,
|
|
1941
|
+
});
|
|
1942
|
+
} catch (cacheErr) {
|
|
1943
|
+
process.stderr.write(`[meko-capture] SessionStart cache write skipped: ${cacheErr.message}\n`);
|
|
1944
|
+
}
|
|
1106
1945
|
}
|
|
1107
1946
|
process.stdout.write(hookOutput(context));
|
|
1108
1947
|
return;
|
|
@@ -1121,48 +1960,665 @@ async function handleSessionStart(hookInput) {
|
|
|
1121
1960
|
|
|
1122
1961
|
if (sessionId) {
|
|
1123
1962
|
const wmPath = path.join(WATERMARK_DIR, `${sessionId}.watermark.json`);
|
|
1124
|
-
writeWatermark(wmPath, convId, 0, agentId, datapackPin);
|
|
1963
|
+
writeWatermark(wmPath, convId, 0, agentId, datapackPin, agentIdSource);
|
|
1125
1964
|
process.stderr.write(
|
|
1126
1965
|
`[meko-capture] SessionStart: created conversation ${convId}, agent_id=${agentId}, preloaded ${memories.length} memories, watermark at ${wmPath}\n`,
|
|
1127
1966
|
);
|
|
1128
1967
|
}
|
|
1129
1968
|
|
|
1130
|
-
|
|
1969
|
+
let context = withHealthNotice(buildSessionStartContext(convId, sessionId, agentId, memories, {
|
|
1131
1970
|
datapackPin,
|
|
1132
|
-
});
|
|
1971
|
+
}));
|
|
1133
1972
|
if (sessionId) {
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1973
|
+
try {
|
|
1974
|
+
writeSessionCache(sessionId, {
|
|
1975
|
+
status: "ready",
|
|
1976
|
+
conversation_id: convId,
|
|
1977
|
+
agent_id: agentId,
|
|
1978
|
+
workspace_key: workspaceKey,
|
|
1979
|
+
additional_context: context,
|
|
1980
|
+
});
|
|
1981
|
+
} catch (cacheErr) {
|
|
1982
|
+
process.stderr.write(`[meko-capture] SessionStart cache write skipped: ${cacheErr.message}\n`);
|
|
1983
|
+
}
|
|
1141
1984
|
}
|
|
1142
1985
|
process.stdout.write(hookOutput(context));
|
|
1143
1986
|
}
|
|
1144
1987
|
|
|
1988
|
+
// --- Drain engine (Capture V2) ---
|
|
1989
|
+
|
|
1990
|
+
/**
|
|
1991
|
+
* Persist a post-accept cursor advance. Returns writeState result.
|
|
1992
|
+
* Used after structurally valid addMessage acceptance only.
|
|
1993
|
+
*/
|
|
1994
|
+
function persistExchangeCheckpoint(sessionId, state, nextLine, extras) {
|
|
1995
|
+
const adv = captureState.advanceCursor(state, nextLine, { clearSeed: true });
|
|
1996
|
+
if (!adv.ok) return adv;
|
|
1997
|
+
const next = {
|
|
1998
|
+
...adv.state,
|
|
1999
|
+
delivery: extras.delivery != null ? extras.delivery : "pending",
|
|
2000
|
+
queued_exchanges:
|
|
2001
|
+
extras.queued_exchanges != null
|
|
2002
|
+
? extras.queued_exchanges
|
|
2003
|
+
: Math.max(0, (state.queued_exchanges || 0) - 1),
|
|
2004
|
+
attempt_count: 0,
|
|
2005
|
+
next_retry_at: null,
|
|
2006
|
+
failure_class: null,
|
|
2007
|
+
last_error: null,
|
|
2008
|
+
blocked_reason: null,
|
|
2009
|
+
last_success_at: new Date().toISOString(),
|
|
2010
|
+
in_flight: { seed: null, user_line: null, user_turn_id: null },
|
|
2011
|
+
};
|
|
2012
|
+
if (extras.lifecycle) next.lifecycle = extras.lifecycle;
|
|
2013
|
+
if (extras.agent_id != null) next.agent_id = extras.agent_id;
|
|
2014
|
+
if (extras.agent_id_source != null) next.agent_id_source = extras.agent_id_source;
|
|
2015
|
+
if (extras.conversation_id != null) next.conversation_id = extras.conversation_id;
|
|
2016
|
+
if (extras.datapack_id !== undefined) next.datapack_id = extras.datapack_id;
|
|
2017
|
+
if (extras.datapack_name !== undefined) next.datapack_name = extras.datapack_name;
|
|
2018
|
+
// Crash-injection hook for tests: server accepted, local checkpoint skipped.
|
|
2019
|
+
if (process.env.MEKO_CAPTURE_TEST_CRASH_AFTER_ACCEPT === "1") {
|
|
2020
|
+
return {
|
|
2021
|
+
ok: false,
|
|
2022
|
+
failure_class: "transient",
|
|
2023
|
+
error: "MEKO_CAPTURE_TEST_CRASH_AFTER_ACCEPT",
|
|
2024
|
+
state: next,
|
|
2025
|
+
crashed_after_accept: true,
|
|
2026
|
+
};
|
|
2027
|
+
}
|
|
2028
|
+
return captureState.writeState(sessionId, next);
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
/**
|
|
2032
|
+
* Build a synthetic interrupted exchange for a trailing incomplete user turn.
|
|
2033
|
+
*/
|
|
2034
|
+
function buildInterruptedExchange(turn, sessionId) {
|
|
2035
|
+
const input = extractText((turn.userMsg.message || {}).content) || "";
|
|
2036
|
+
return {
|
|
2037
|
+
user_uuid: turn.userMsg.uuid || "unknown",
|
|
2038
|
+
user_line: turn.userLine,
|
|
2039
|
+
next_line_number: null,
|
|
2040
|
+
input,
|
|
2041
|
+
output: INTERRUPTED_ASSISTANT_OUTPUT,
|
|
2042
|
+
reasoning: "",
|
|
2043
|
+
metadata: {
|
|
2044
|
+
capture_status: "interrupted",
|
|
2045
|
+
session_id: sessionId,
|
|
2046
|
+
user_line: turn.userLine,
|
|
2047
|
+
},
|
|
2048
|
+
timestamp: turn.userMsg.timestamp || "",
|
|
2049
|
+
};
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
/**
|
|
2053
|
+
* Unified drain entry used by checkpoint, pre-compact, session-end, and CLI
|
|
2054
|
+
* recover/drain. Acquires a drain lease, delivers sequentially with batch
|
|
2055
|
+
* limits, and advances the cursor only after valid accepted message IDs.
|
|
2056
|
+
*
|
|
2057
|
+
* @param {object} options
|
|
2058
|
+
* @param {string} options.session_id
|
|
2059
|
+
* @param {string} [options.transcript_path]
|
|
2060
|
+
* @param {"checkpoint"|"pre-compact"|"session-end"|"drain"|"recover"} [options.mode]
|
|
2061
|
+
* @param {"active"|"closing"|"closed"} [options.lifecycle]
|
|
2062
|
+
* @param {boolean} [options.confirmed_dead]
|
|
2063
|
+
* @param {boolean} [options.recorded_owner_only]
|
|
2064
|
+
* @param {number} [options.max_exchanges]
|
|
2065
|
+
* @param {boolean} [options.force_retry] One-shot daemon-start override for
|
|
2066
|
+
* persistent failures only.
|
|
2067
|
+
* @returns {Promise<object>} DrainResult
|
|
2068
|
+
*/
|
|
2069
|
+
async function drainSession(options) {
|
|
2070
|
+
const opts = options && typeof options === "object" ? options : {};
|
|
2071
|
+
const sessionId = String(opts.session_id || "").trim();
|
|
2072
|
+
if (!sessionId) {
|
|
2073
|
+
return makeDrainResult({
|
|
2074
|
+
status: "blocked",
|
|
2075
|
+
failure_class: "persistent",
|
|
2076
|
+
error: "session_id required",
|
|
2077
|
+
});
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
const mode = opts.mode || "drain";
|
|
2081
|
+
const configuredBatch = captureState.maxCaptureBatchSize();
|
|
2082
|
+
const maxBatch =
|
|
2083
|
+
opts.max_exchanges != null
|
|
2084
|
+
? Math.min(
|
|
2085
|
+
configuredBatch,
|
|
2086
|
+
Math.max(1, Number(opts.max_exchanges) || 1),
|
|
2087
|
+
)
|
|
2088
|
+
: configuredBatch;
|
|
2089
|
+
const recordedOwnerOnly = Boolean(opts.recorded_owner_only);
|
|
2090
|
+
const finalizeInterrupted =
|
|
2091
|
+
Boolean(opts.confirmed_dead) ||
|
|
2092
|
+
opts.lifecycle === "closing" ||
|
|
2093
|
+
opts.lifecycle === "closed" ||
|
|
2094
|
+
mode === "session-end";
|
|
2095
|
+
|
|
2096
|
+
const leaseAcq = captureState.acquireLease(sessionId);
|
|
2097
|
+
if (!leaseAcq.ok) {
|
|
2098
|
+
return makeDrainResult({
|
|
2099
|
+
status: "retry_wait",
|
|
2100
|
+
session_id: sessionId,
|
|
2101
|
+
failure_class: leaseAcq.failure_class || "ownership",
|
|
2102
|
+
error: leaseAcq.error || "lease held",
|
|
2103
|
+
delivery: "retry_wait",
|
|
2104
|
+
});
|
|
2105
|
+
}
|
|
2106
|
+
const leaseToken = leaseAcq.lease && leaseAcq.lease.token;
|
|
2107
|
+
|
|
2108
|
+
try {
|
|
2109
|
+
const loaded = captureState.readState(sessionId);
|
|
2110
|
+
if (!loaded.ok && !loaded.missing) {
|
|
2111
|
+
return makeDrainResult({
|
|
2112
|
+
status: "blocked",
|
|
2113
|
+
session_id: sessionId,
|
|
2114
|
+
failure_class: loaded.failure_class || "state_corrupt",
|
|
2115
|
+
error: loaded.error || "corrupt state",
|
|
2116
|
+
delivery: "blocked",
|
|
2117
|
+
});
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
const stateExisted = Boolean(loaded.ok);
|
|
2121
|
+
const nowIso = new Date().toISOString();
|
|
2122
|
+
let state = loaded.ok
|
|
2123
|
+
? { ...loaded.state }
|
|
2124
|
+
: {
|
|
2125
|
+
schema_version: 2,
|
|
2126
|
+
session_id: sessionId,
|
|
2127
|
+
client: "",
|
|
2128
|
+
agent_id: "",
|
|
2129
|
+
agent_id_source: "",
|
|
2130
|
+
transcript_path: null,
|
|
2131
|
+
datapack_id: null,
|
|
2132
|
+
datapack_name: null,
|
|
2133
|
+
created_at: nowIso,
|
|
2134
|
+
updated_at: nowIso,
|
|
2135
|
+
last_activity_at: nowIso,
|
|
2136
|
+
conversation_id: null,
|
|
2137
|
+
conversation_epoch: 0,
|
|
2138
|
+
last_line_number: 0,
|
|
2139
|
+
in_flight: { seed: null, user_line: null, user_turn_id: null },
|
|
2140
|
+
lifecycle: "active",
|
|
2141
|
+
delivery: "needs_conversation",
|
|
2142
|
+
queued_exchanges: 0,
|
|
2143
|
+
attempt_count: 0,
|
|
2144
|
+
next_retry_at: null,
|
|
2145
|
+
failure_class: null,
|
|
2146
|
+
last_error: null,
|
|
2147
|
+
last_success_at: null,
|
|
2148
|
+
blocked_reason: null,
|
|
2149
|
+
};
|
|
2150
|
+
const transcriptPath =
|
|
2151
|
+
opts.transcript_path ||
|
|
2152
|
+
state.transcript_path ||
|
|
2153
|
+
null;
|
|
2154
|
+
|
|
2155
|
+
if (opts.lifecycle && opts.lifecycle !== state.lifecycle) {
|
|
2156
|
+
state.lifecycle = opts.lifecycle;
|
|
2157
|
+
} else if (mode === "session-end" && state.lifecycle === "active") {
|
|
2158
|
+
state.lifecycle = "closing";
|
|
2159
|
+
}
|
|
2160
|
+
if (transcriptPath && !state.transcript_path) {
|
|
2161
|
+
state.transcript_path = transcriptPath;
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
const retryAtMs = state.next_retry_at
|
|
2165
|
+
? Date.parse(state.next_retry_at)
|
|
2166
|
+
: NaN;
|
|
2167
|
+
if (
|
|
2168
|
+
state.failure_class &&
|
|
2169
|
+
Number.isFinite(retryAtMs) &&
|
|
2170
|
+
retryAtMs > Date.now() &&
|
|
2171
|
+
!(Boolean(opts.force_retry) && state.failure_class === "persistent")
|
|
2172
|
+
) {
|
|
2173
|
+
return makeDrainResult({
|
|
2174
|
+
status: "retry_wait",
|
|
2175
|
+
session_id: sessionId,
|
|
2176
|
+
queued_remaining: state.queued_exchanges || 0,
|
|
2177
|
+
failure_class: state.failure_class,
|
|
2178
|
+
error: state.last_error || "retry not due",
|
|
2179
|
+
last_line_number: state.last_line_number || 0,
|
|
2180
|
+
lifecycle: state.lifecycle,
|
|
2181
|
+
delivery: state.delivery,
|
|
2182
|
+
next_retry_at: state.next_retry_at,
|
|
2183
|
+
});
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
if (!transcriptPath || !fs.existsSync(transcriptPath)) {
|
|
2187
|
+
if (stateExisted) captureState.writeState(sessionId, state);
|
|
2188
|
+
return makeDrainResult({
|
|
2189
|
+
status: "noop",
|
|
2190
|
+
session_id: sessionId,
|
|
2191
|
+
last_line_number: state.last_line_number || 0,
|
|
2192
|
+
lifecycle: state.lifecycle,
|
|
2193
|
+
delivery: state.delivery,
|
|
2194
|
+
error: "transcript missing",
|
|
2195
|
+
});
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
let convId = state.conversation_id || "";
|
|
2199
|
+
let lastLine = state.last_line_number || 0;
|
|
2200
|
+
const currentLines = countLines(transcriptPath);
|
|
2201
|
+
const datapackPin = datapackPinFromWatermark({
|
|
2202
|
+
datapack_id: state.datapack_id,
|
|
2203
|
+
datapack_name: state.datapack_name,
|
|
2204
|
+
});
|
|
2205
|
+
|
|
2206
|
+
let effectiveAgentId = state.agent_id || "";
|
|
2207
|
+
let effectiveSource = state.agent_id_source || "";
|
|
2208
|
+
|
|
2209
|
+
if (!recordedOwnerOnly) {
|
|
2210
|
+
const reconciled = reconcileCachedAgentId(
|
|
2211
|
+
state.agent_id,
|
|
2212
|
+
state.agent_id_source || "",
|
|
2213
|
+
resolveSessionAgentId(transcriptPath),
|
|
2214
|
+
);
|
|
2215
|
+
effectiveAgentId = reconciled.agentId;
|
|
2216
|
+
effectiveSource = reconciled.source;
|
|
2217
|
+
} else if (!(state.agent_id || "").trim()) {
|
|
2218
|
+
return makeDrainResult({
|
|
2219
|
+
status: "noop",
|
|
2220
|
+
session_id: sessionId,
|
|
2221
|
+
last_line_number: lastLine,
|
|
2222
|
+
lifecycle: state.lifecycle,
|
|
2223
|
+
delivery: state.delivery,
|
|
2224
|
+
error: "recorded owner missing",
|
|
2225
|
+
});
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
// Nothing-new early exit BEFORE network (preserve quiet skip for EOF hooks).
|
|
2229
|
+
if (convId && currentLines <= lastLine && !finalizeInterrupted) {
|
|
2230
|
+
if (
|
|
2231
|
+
stateExisted &&
|
|
2232
|
+
!recordedOwnerOnly &&
|
|
2233
|
+
effectiveAgentId !== state.agent_id
|
|
2234
|
+
) {
|
|
2235
|
+
state.agent_id = effectiveAgentId;
|
|
2236
|
+
state.agent_id_source = effectiveSource;
|
|
2237
|
+
captureState.writeState(sessionId, state);
|
|
2238
|
+
}
|
|
2239
|
+
return makeDrainResult({
|
|
2240
|
+
status: "noop",
|
|
2241
|
+
session_id: sessionId,
|
|
2242
|
+
last_line_number: lastLine,
|
|
2243
|
+
lifecycle: state.lifecycle,
|
|
2244
|
+
delivery: state.delivery || "idle",
|
|
2245
|
+
});
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
try {
|
|
2249
|
+
await mcpInitialize();
|
|
2250
|
+
if (!convId) {
|
|
2251
|
+
convId = await createConversation(
|
|
2252
|
+
sessionId,
|
|
2253
|
+
effectiveAgentId,
|
|
2254
|
+
peekTranscriptMetadata(transcriptPath),
|
|
2255
|
+
datapackPin && datapackPin.datapack_id,
|
|
2256
|
+
);
|
|
2257
|
+
if (!convId) {
|
|
2258
|
+
throw new Error("conversation_create returned no ID");
|
|
2259
|
+
}
|
|
2260
|
+
state.conversation_id = convId;
|
|
2261
|
+
state.delivery = currentLines > lastLine ? "pending" : "idle";
|
|
2262
|
+
state.failure_class = null;
|
|
2263
|
+
state.last_error = null;
|
|
2264
|
+
state.next_retry_at = null;
|
|
2265
|
+
const created = captureState.writeState(sessionId, state);
|
|
2266
|
+
if (!created.ok) {
|
|
2267
|
+
throw new Error(
|
|
2268
|
+
`failed to persist created conversation: ${created.error}`,
|
|
2269
|
+
);
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
} catch (err) {
|
|
2273
|
+
const failureClass = captureState.classifyFailure(err);
|
|
2274
|
+
const pendingExchanges = extractExchanges(transcriptPath, lastLine).length;
|
|
2275
|
+
// Only persist failure onto an existing outbox — never invent a watermark
|
|
2276
|
+
// just because initialize failed (SessionStart owns intent creation).
|
|
2277
|
+
if (stateExisted) {
|
|
2278
|
+
state.delivery = failureClass === "transient" ? "retry_wait" : "blocked";
|
|
2279
|
+
state.failure_class = failureClass;
|
|
2280
|
+
state.last_error = err.message;
|
|
2281
|
+
state.attempt_count = (state.attempt_count || 0) + 1;
|
|
2282
|
+
state.next_retry_at = captureState.nextRetryAt({
|
|
2283
|
+
failure_class: failureClass,
|
|
2284
|
+
attempt_count: state.attempt_count,
|
|
2285
|
+
});
|
|
2286
|
+
state.queued_exchanges = pendingExchanges;
|
|
2287
|
+
captureState.writeState(sessionId, state);
|
|
2288
|
+
}
|
|
2289
|
+
process.stderr.write(
|
|
2290
|
+
`[meko-capture] Failed to initialize MCP session: ${err.message}\n`,
|
|
2291
|
+
);
|
|
2292
|
+
return makeDrainResult({
|
|
2293
|
+
status: failureClass === "transient" ? "retry_wait" : "blocked",
|
|
2294
|
+
session_id: sessionId,
|
|
2295
|
+
queued_remaining: pendingExchanges,
|
|
2296
|
+
failure_class: failureClass,
|
|
2297
|
+
error: err.message,
|
|
2298
|
+
last_line_number: lastLine,
|
|
2299
|
+
lifecycle: state.lifecycle,
|
|
2300
|
+
delivery: stateExisted ? state.delivery : "needs_conversation",
|
|
2301
|
+
});
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
let extractFrom = lastLine;
|
|
2305
|
+
if (!recordedOwnerOnly) {
|
|
2306
|
+
const owned = await ensureConversationOwner({
|
|
2307
|
+
convId,
|
|
2308
|
+
cachedOwner: state.agent_id,
|
|
2309
|
+
resolvedAgentId: effectiveAgentId,
|
|
2310
|
+
resolvedSource: effectiveSource,
|
|
2311
|
+
sessionId,
|
|
2312
|
+
metadata: peekTranscriptMetadata(transcriptPath),
|
|
2313
|
+
datapackId: datapackPin && datapackPin.datapack_id,
|
|
2314
|
+
mcpReady: true,
|
|
2315
|
+
});
|
|
2316
|
+
if (owned.hold) {
|
|
2317
|
+
return makeDrainResult({
|
|
2318
|
+
status: "retry_wait",
|
|
2319
|
+
session_id: sessionId,
|
|
2320
|
+
failure_class: "ownership",
|
|
2321
|
+
error: "rebucket hold",
|
|
2322
|
+
last_line_number: lastLine,
|
|
2323
|
+
lifecycle: state.lifecycle,
|
|
2324
|
+
delivery: state.delivery,
|
|
2325
|
+
});
|
|
2326
|
+
}
|
|
2327
|
+
effectiveAgentId = owned.agentId;
|
|
2328
|
+
effectiveSource = owned.source;
|
|
2329
|
+
if (owned.rebucketed) {
|
|
2330
|
+
const rb = captureState.rebucket(state, owned.convId);
|
|
2331
|
+
if (!rb.ok) {
|
|
2332
|
+
return makeDrainResult({
|
|
2333
|
+
status: "blocked",
|
|
2334
|
+
session_id: sessionId,
|
|
2335
|
+
failure_class: rb.failure_class || "persistent",
|
|
2336
|
+
error: rb.error,
|
|
2337
|
+
last_line_number: lastLine,
|
|
2338
|
+
lifecycle: state.lifecycle,
|
|
2339
|
+
delivery: "blocked",
|
|
2340
|
+
});
|
|
2341
|
+
}
|
|
2342
|
+
state = rb.state;
|
|
2343
|
+
state.agent_id = effectiveAgentId;
|
|
2344
|
+
state.agent_id_source = effectiveSource;
|
|
2345
|
+
captureState.writeState(sessionId, state);
|
|
2346
|
+
convId = owned.convId;
|
|
2347
|
+
extractFrom = 0;
|
|
2348
|
+
lastLine = 0;
|
|
2349
|
+
} else {
|
|
2350
|
+
convId = owned.convId;
|
|
2351
|
+
state.agent_id = effectiveAgentId;
|
|
2352
|
+
state.agent_id_source = effectiveSource;
|
|
2353
|
+
}
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
const entries = parseTranscriptEntries(transcriptPath, extractFrom);
|
|
2357
|
+
const built = buildExchanges(entries);
|
|
2358
|
+
let allExchanges = built.exchanges.slice();
|
|
2359
|
+
let pendingFromLine = built.pendingFromLine;
|
|
2360
|
+
const turns = built.turns || [];
|
|
2361
|
+
const turnLines = realUserTurnLines(entries);
|
|
2362
|
+
let safeLine = pendingFromLine !== null ? pendingFromLine : currentLines;
|
|
2363
|
+
|
|
2364
|
+
// Closing / dead: finalize trailing incomplete user turn exactly once.
|
|
2365
|
+
let synthesizedInterrupted = false;
|
|
2366
|
+
if (finalizeInterrupted && pendingFromLine !== null && turns.length > 0) {
|
|
2367
|
+
const trailing = turns[turns.length - 1];
|
|
2368
|
+
if (trailing && trailing.userLine === pendingFromLine) {
|
|
2369
|
+
allExchanges.push(buildInterruptedExchange(trailing, sessionId));
|
|
2370
|
+
synthesizedInterrupted = true;
|
|
2371
|
+
pendingFromLine = null;
|
|
2372
|
+
safeLine = currentLines;
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
|
|
2376
|
+
if (allExchanges.length === 0) {
|
|
2377
|
+
if (pendingFromLine !== null) {
|
|
2378
|
+
process.stderr.write(
|
|
2379
|
+
`[meko-capture] ${mode}: session ${sessionId} produced zero ` +
|
|
2380
|
+
`exchanges but a trailing turn is still awaiting completion; holding ` +
|
|
2381
|
+
`watermark at line ${safeLine} so it is captured whole on a later run.\n`,
|
|
2382
|
+
);
|
|
2383
|
+
state.delivery = "pending";
|
|
2384
|
+
state.queued_exchanges = 1;
|
|
2385
|
+
} else {
|
|
2386
|
+
state.delivery =
|
|
2387
|
+
finalizeInterrupted || state.lifecycle === "closing"
|
|
2388
|
+
? "complete"
|
|
2389
|
+
: "idle";
|
|
2390
|
+
if (finalizeInterrupted || state.lifecycle === "closing") {
|
|
2391
|
+
state.lifecycle = "closed";
|
|
2392
|
+
}
|
|
2393
|
+
state.queued_exchanges = 0;
|
|
2394
|
+
}
|
|
2395
|
+
state.agent_id = effectiveAgentId;
|
|
2396
|
+
state.agent_id_source = effectiveSource;
|
|
2397
|
+
state.conversation_id = convId;
|
|
2398
|
+
if (safeLine >= state.last_line_number) {
|
|
2399
|
+
const adv = captureState.advanceCursor(state, safeLine, { clearSeed: true });
|
|
2400
|
+
if (adv.ok) state = adv.state;
|
|
2401
|
+
}
|
|
2402
|
+
const written = captureState.writeState(sessionId, state);
|
|
2403
|
+
const s = written.ok ? written.state : state;
|
|
2404
|
+
return makeDrainResult({
|
|
2405
|
+
status: "ok",
|
|
2406
|
+
session_id: sessionId,
|
|
2407
|
+
captured: 0,
|
|
2408
|
+
queued_remaining: s.queued_exchanges || 0,
|
|
2409
|
+
cursor_advanced: (s.last_line_number || 0) > lastLine,
|
|
2410
|
+
last_line_number: s.last_line_number || 0,
|
|
2411
|
+
lifecycle: s.lifecycle,
|
|
2412
|
+
delivery: s.delivery,
|
|
2413
|
+
});
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2416
|
+
const exchanges = allExchanges.slice(0, maxBatch);
|
|
2417
|
+
let captured = 0;
|
|
2418
|
+
let failed = 0;
|
|
2419
|
+
let cursorAdvanced = false;
|
|
2420
|
+
let watermarkLine = extractFrom;
|
|
2421
|
+
state.delivery = "draining";
|
|
2422
|
+
state.queued_exchanges = allExchanges.length;
|
|
2423
|
+
captureState.writeState(sessionId, state);
|
|
2424
|
+
|
|
2425
|
+
for (const exchange of exchanges) {
|
|
2426
|
+
const seed = `${convId}:${exchange.user_uuid}`;
|
|
2427
|
+
state.in_flight = {
|
|
2428
|
+
seed,
|
|
2429
|
+
user_line:
|
|
2430
|
+
typeof exchange.user_line === "number" ? exchange.user_line : null,
|
|
2431
|
+
user_turn_id: exchange.user_uuid || null,
|
|
2432
|
+
};
|
|
2433
|
+
captureState.writeState(sessionId, state);
|
|
2434
|
+
|
|
2435
|
+
try {
|
|
2436
|
+
await addMessage(
|
|
2437
|
+
convId,
|
|
2438
|
+
effectiveAgentId,
|
|
2439
|
+
exchange,
|
|
2440
|
+
datapackPin && datapackPin.datapack_id,
|
|
2441
|
+
);
|
|
2442
|
+
const boundary = boundaryAfterExchange(turnLines, exchange.user_line);
|
|
2443
|
+
const nextLine =
|
|
2444
|
+
boundary != null
|
|
2445
|
+
? boundary
|
|
2446
|
+
: exchange.next_line_number != null
|
|
2447
|
+
? exchange.next_line_number
|
|
2448
|
+
: safeLine;
|
|
2449
|
+
const remainingAfter = allExchanges.length - captured - 1;
|
|
2450
|
+
const ck = persistExchangeCheckpoint(sessionId, state, nextLine, {
|
|
2451
|
+
delivery:
|
|
2452
|
+
remainingAfter > 0 || allExchanges.length > maxBatch
|
|
2453
|
+
? "pending"
|
|
2454
|
+
: "idle",
|
|
2455
|
+
queued_exchanges: Math.max(0, allExchanges.length - captured - 1),
|
|
2456
|
+
agent_id: effectiveAgentId,
|
|
2457
|
+
agent_id_source: effectiveSource,
|
|
2458
|
+
conversation_id: convId,
|
|
2459
|
+
datapack_id: datapackPin ? datapackPin.datapack_id : null,
|
|
2460
|
+
datapack_name: datapackPin ? datapackPin.datapack_name : null,
|
|
2461
|
+
});
|
|
2462
|
+
if (!ck.ok) {
|
|
2463
|
+
failed++;
|
|
2464
|
+
const failureClass = ck.failure_class || "transient";
|
|
2465
|
+
state.delivery = "retry_wait";
|
|
2466
|
+
state.failure_class = failureClass;
|
|
2467
|
+
state.last_error = ck.error;
|
|
2468
|
+
state.attempt_count = (state.attempt_count || 0) + 1;
|
|
2469
|
+
state.next_retry_at = captureState.nextRetryAt({
|
|
2470
|
+
failure_class: failureClass,
|
|
2471
|
+
attempt_count: state.attempt_count,
|
|
2472
|
+
});
|
|
2473
|
+
state.in_flight = {
|
|
2474
|
+
seed,
|
|
2475
|
+
user_line:
|
|
2476
|
+
typeof exchange.user_line === "number" ? exchange.user_line : null,
|
|
2477
|
+
user_turn_id: exchange.user_uuid || null,
|
|
2478
|
+
};
|
|
2479
|
+
captureState.writeState(sessionId, state);
|
|
2480
|
+
process.stderr.write(
|
|
2481
|
+
`[meko-capture] Accepted but checkpoint failed (uuid=${exchange.user_uuid}): ${ck.error}\n`,
|
|
2482
|
+
);
|
|
2483
|
+
break;
|
|
2484
|
+
}
|
|
2485
|
+
state = ck.state;
|
|
2486
|
+
watermarkLine = state.last_line_number;
|
|
2487
|
+
captured++;
|
|
2488
|
+
cursorAdvanced = true;
|
|
2489
|
+
} catch (err) {
|
|
2490
|
+
failed++;
|
|
2491
|
+
const persistent = classifyPersistentCaptureFailure(err);
|
|
2492
|
+
const failureClass = persistent
|
|
2493
|
+
? "persistent"
|
|
2494
|
+
: captureState.classifyFailure(err);
|
|
2495
|
+
state.delivery =
|
|
2496
|
+
failureClass === "transient" ? "retry_wait" : "blocked";
|
|
2497
|
+
state.failure_class = failureClass;
|
|
2498
|
+
state.last_error = err.message;
|
|
2499
|
+
state.attempt_count = (state.attempt_count || 0) + 1;
|
|
2500
|
+
state.next_retry_at = captureState.nextRetryAt({
|
|
2501
|
+
failure_class: failureClass,
|
|
2502
|
+
attempt_count: state.attempt_count,
|
|
2503
|
+
});
|
|
2504
|
+
state.queued_exchanges = allExchanges.length - captured;
|
|
2505
|
+
captureState.writeState(sessionId, state);
|
|
2506
|
+
process.stderr.write(
|
|
2507
|
+
`[meko-capture] Failed to add message (uuid=${exchange.user_uuid}): ${err.message}\n`,
|
|
2508
|
+
);
|
|
2509
|
+
break;
|
|
2510
|
+
}
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2513
|
+
const held = allExchanges.length - captured;
|
|
2514
|
+
if (failed === 0 && allExchanges.length <= maxBatch && pendingFromLine === null) {
|
|
2515
|
+
watermarkLine = safeLine;
|
|
2516
|
+
if (watermarkLine >= state.last_line_number) {
|
|
2517
|
+
const adv = captureState.advanceCursor(state, watermarkLine, {
|
|
2518
|
+
clearSeed: true,
|
|
2519
|
+
});
|
|
2520
|
+
if (adv.ok) state = adv.state;
|
|
2521
|
+
}
|
|
2522
|
+
if (finalizeInterrupted || state.lifecycle === "closing") {
|
|
2523
|
+
state.lifecycle = "closed";
|
|
2524
|
+
state.delivery = "complete";
|
|
2525
|
+
state.queued_exchanges = 0;
|
|
2526
|
+
} else {
|
|
2527
|
+
state.delivery = "idle";
|
|
2528
|
+
state.queued_exchanges = 0;
|
|
2529
|
+
}
|
|
2530
|
+
captureState.writeState(sessionId, state);
|
|
2531
|
+
cursorAdvanced = captured > 0 || cursorAdvanced || synthesizedInterrupted;
|
|
2532
|
+
} else if (failed === 0 && allExchanges.length > maxBatch) {
|
|
2533
|
+
state.delivery = "pending";
|
|
2534
|
+
state.queued_exchanges = held;
|
|
2535
|
+
captureState.writeState(sessionId, state);
|
|
2536
|
+
process.stderr.write(
|
|
2537
|
+
`[meko-capture] captured batch of ${captured}/${allExchanges.length} exchange(s); ` +
|
|
2538
|
+
`checkpointed through line ${watermarkLine}.\n`,
|
|
2539
|
+
);
|
|
2540
|
+
} else if (failed > 0) {
|
|
2541
|
+
process.stderr.write(
|
|
2542
|
+
`[meko-capture] capture stopped at first failure; ` +
|
|
2543
|
+
`holding watermark at line ${state.last_line_number} for retry.\n`,
|
|
2544
|
+
);
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
const finalState = captureState.readState(sessionId);
|
|
2548
|
+
const s = finalState.ok ? finalState.state : state;
|
|
2549
|
+
let status = "ok";
|
|
2550
|
+
if (failed > 0) {
|
|
2551
|
+
status =
|
|
2552
|
+
s.failure_class === "transient" || s.delivery === "retry_wait"
|
|
2553
|
+
? "retry_wait"
|
|
2554
|
+
: s.delivery === "blocked"
|
|
2555
|
+
? "blocked"
|
|
2556
|
+
: "partial";
|
|
2557
|
+
} else if (held > 0) {
|
|
2558
|
+
status = "partial";
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
return makeDrainResult({
|
|
2562
|
+
status,
|
|
2563
|
+
session_id: sessionId,
|
|
2564
|
+
captured,
|
|
2565
|
+
queued_remaining: held,
|
|
2566
|
+
failure_class: failed > 0 ? s.failure_class || null : null,
|
|
2567
|
+
error: failed > 0 ? s.last_error || null : null,
|
|
2568
|
+
cursor_advanced: cursorAdvanced,
|
|
2569
|
+
last_line_number: s.last_line_number || watermarkLine,
|
|
2570
|
+
lifecycle: s.lifecycle,
|
|
2571
|
+
delivery: s.delivery,
|
|
2572
|
+
});
|
|
2573
|
+
} finally {
|
|
2574
|
+
if (leaseToken) captureState.releaseLease(sessionId, leaseToken);
|
|
2575
|
+
}
|
|
2576
|
+
}
|
|
2577
|
+
|
|
1145
2578
|
// --- Main ---
|
|
1146
2579
|
|
|
1147
2580
|
async function main() {
|
|
1148
2581
|
const hookType = process.argv[2];
|
|
1149
|
-
|
|
1150
|
-
|
|
2582
|
+
const known = [
|
|
2583
|
+
"session-start",
|
|
2584
|
+
"before-submit-prompt",
|
|
2585
|
+
"pre-compact",
|
|
2586
|
+
"session-end",
|
|
2587
|
+
"checkpoint",
|
|
2588
|
+
"drain",
|
|
2589
|
+
"recover",
|
|
2590
|
+
];
|
|
2591
|
+
if (!hookType || !known.includes(hookType)) {
|
|
2592
|
+
process.stderr.write(
|
|
2593
|
+
"Usage: capture.js <session-start|before-submit-prompt|pre-compact|session-end|checkpoint|drain|recover>\n",
|
|
2594
|
+
);
|
|
1151
2595
|
process.exit(1);
|
|
1152
2596
|
}
|
|
1153
2597
|
|
|
1154
|
-
// Read hook input from stdin
|
|
1155
2598
|
let hookInput;
|
|
1156
2599
|
try {
|
|
1157
2600
|
const stdin = fs.readFileSync(0, "utf-8");
|
|
1158
|
-
hookInput = JSON.parse(stdin);
|
|
2601
|
+
hookInput = JSON.parse(stdin || "{}");
|
|
1159
2602
|
} catch (err) {
|
|
1160
|
-
process.stderr.write(
|
|
1161
|
-
|
|
2603
|
+
process.stderr.write(
|
|
2604
|
+
`[meko-capture] Failed to parse hook input from stdin: ${err.message}\n`,
|
|
2605
|
+
);
|
|
2606
|
+
if (hookType === "drain" || hookType === "recover") {
|
|
2607
|
+
process.stdout.write(
|
|
2608
|
+
JSON.stringify(
|
|
2609
|
+
makeDrainResult({
|
|
2610
|
+
status: "blocked",
|
|
2611
|
+
failure_class: "persistent",
|
|
2612
|
+
error: `invalid stdin JSON: ${err.message}`,
|
|
2613
|
+
}),
|
|
2614
|
+
) + "\n",
|
|
2615
|
+
);
|
|
2616
|
+
} else {
|
|
2617
|
+
process.stdout.write(hookOutput(""));
|
|
2618
|
+
}
|
|
1162
2619
|
return;
|
|
1163
2620
|
}
|
|
1164
2621
|
|
|
1165
|
-
// SessionStart: create conversation + watermark, output context
|
|
1166
2622
|
if (hookType === "session-start") {
|
|
1167
2623
|
await handleSessionStart(hookInput);
|
|
1168
2624
|
return;
|
|
@@ -1173,106 +2629,78 @@ async function main() {
|
|
|
1173
2629
|
return;
|
|
1174
2630
|
}
|
|
1175
2631
|
|
|
1176
|
-
// PreCompact / SessionEnd: capture transcript exchanges
|
|
1177
2632
|
const transcriptPath =
|
|
1178
2633
|
hookInput.transcript_path ||
|
|
1179
|
-
(hookInput.hookSpecificInput || {}).transcript_path
|
|
2634
|
+
(hookInput.hookSpecificInput || {}).transcript_path ||
|
|
2635
|
+
null;
|
|
2636
|
+
const sessionId =
|
|
2637
|
+
(hookInput.session_id && String(hookInput.session_id)) ||
|
|
2638
|
+
(transcriptPath ? path.basename(transcriptPath, ".jsonl") : "") ||
|
|
2639
|
+
extractSessionId(hookInput, transcriptPath);
|
|
2640
|
+
|
|
2641
|
+
const drainOpts = {
|
|
2642
|
+
session_id: sessionId,
|
|
2643
|
+
transcript_path: transcriptPath,
|
|
2644
|
+
mode: hookType === "recover" ? "drain" : hookType,
|
|
2645
|
+
lifecycle: hookInput.lifecycle,
|
|
2646
|
+
confirmed_dead: Boolean(hookInput.confirmed_dead),
|
|
2647
|
+
recorded_owner_only: Boolean(hookInput.recorded_owner_only),
|
|
2648
|
+
max_exchanges:
|
|
2649
|
+
hookInput.max_exchanges != null
|
|
2650
|
+
? Number(hookInput.max_exchanges)
|
|
2651
|
+
: undefined,
|
|
2652
|
+
force_retry: Boolean(hookInput.force_retry),
|
|
2653
|
+
};
|
|
1180
2654
|
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
2655
|
+
// recover alias: single-session drain under recorded owner + dead finalization
|
|
2656
|
+
if (hookType === "recover") {
|
|
2657
|
+
drainOpts.recorded_owner_only =
|
|
2658
|
+
hookInput.recorded_owner_only !== undefined
|
|
2659
|
+
? Boolean(hookInput.recorded_owner_only)
|
|
2660
|
+
: true;
|
|
2661
|
+
drainOpts.confirmed_dead =
|
|
2662
|
+
hookInput.confirmed_dead !== undefined
|
|
2663
|
+
? Boolean(hookInput.confirmed_dead)
|
|
2664
|
+
: true;
|
|
1184
2665
|
}
|
|
1185
2666
|
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
let convId = watermark.conversation_id || "";
|
|
1190
|
-
const lastLine = watermark.last_line_number || 0;
|
|
1191
|
-
const currentLines = countLines(transcriptPath);
|
|
1192
|
-
|
|
1193
|
-
// Resolve agent_id for this hook invocation. Prefer the value the
|
|
1194
|
-
// SessionStart hook persisted in the watermark; if it's missing or a
|
|
1195
|
-
// legacy literal ("agent"), fall back to a fresh derivation from the
|
|
1196
|
-
// transcript's cwd so writes land in the right bucket.
|
|
1197
|
-
const cachedAgentId = (watermark.agent_id || "").trim();
|
|
1198
|
-
const agentId = cachedAgentId && cachedAgentId !== "agent"
|
|
1199
|
-
? cachedAgentId
|
|
1200
|
-
: resolveSessionAgentId(transcriptPath);
|
|
1201
|
-
const datapackPin = datapackPinFromWatermark(watermark);
|
|
1202
|
-
|
|
1203
|
-
// Nothing new
|
|
1204
|
-
if (currentLines <= lastLine) {
|
|
1205
|
-
process.stdout.write(hookOutput(""));
|
|
2667
|
+
if (hookType === "drain" || hookType === "recover") {
|
|
2668
|
+
const result = await drainSession(drainOpts);
|
|
2669
|
+
process.stdout.write(JSON.stringify(result) + "\n");
|
|
1206
2670
|
return;
|
|
1207
2671
|
}
|
|
1208
2672
|
|
|
1209
|
-
|
|
1210
|
-
try {
|
|
1211
|
-
await mcpInitialize();
|
|
1212
|
-
} catch (err) {
|
|
1213
|
-
process.stderr.write(`[meko-capture] Failed to initialize MCP session: ${err.message}\n`);
|
|
2673
|
+
if (!transcriptPath || !fs.existsSync(transcriptPath)) {
|
|
1214
2674
|
process.stdout.write(hookOutput(""));
|
|
1215
2675
|
return;
|
|
1216
2676
|
}
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
// an existing watermark. Skip gracefully if missing.
|
|
1220
|
-
if (!convId) {
|
|
1221
|
-
process.stderr.write(
|
|
1222
|
-
`[meko-capture] ${hookType}: No conversation in watermark for session ${sessionId}. ` +
|
|
1223
|
-
`SessionStart hook may not have run yet.\n`
|
|
1224
|
-
);
|
|
1225
|
-
process.stdout.write(hookOutput(""));
|
|
1226
|
-
return;
|
|
2677
|
+
if (hookType === "session-end") {
|
|
2678
|
+
drainOpts.lifecycle = "closing";
|
|
1227
2679
|
}
|
|
1228
2680
|
|
|
1229
|
-
|
|
1230
|
-
const exchanges = extractExchanges(transcriptPath, lastLine);
|
|
1231
|
-
let captured = 0;
|
|
1232
|
-
let failed = 0;
|
|
1233
|
-
|
|
1234
|
-
for (const exchange of exchanges) {
|
|
1235
|
-
try {
|
|
1236
|
-
await addMessage(
|
|
1237
|
-
convId,
|
|
1238
|
-
agentId,
|
|
1239
|
-
exchange,
|
|
1240
|
-
datapackPin && datapackPin.datapack_id,
|
|
1241
|
-
);
|
|
1242
|
-
captured++;
|
|
1243
|
-
} catch (err) {
|
|
1244
|
-
failed++;
|
|
1245
|
-
process.stderr.write(`[meko-capture] Failed to add message (uuid=${exchange.user_uuid}): ${err.message}\n`);
|
|
1246
|
-
}
|
|
1247
|
-
}
|
|
1248
|
-
|
|
1249
|
-
// Advance the watermark to currentLines ONLY when every exchange was
|
|
1250
|
-
// captured. If any addMessage failed (typically a transient network error),
|
|
1251
|
-
// keep the watermark at lastLine so the next checkpoint / PreCompact /
|
|
1252
|
-
// SessionEnd hook re-extracts and retries this batch. Re-sending the
|
|
1253
|
-
// exchanges that already succeeded is safe: capture dedups by seed
|
|
1254
|
-
// (<conv_id>:<user_uuid>), so retries never create duplicates. Advancing on
|
|
1255
|
-
// partial failure is what would permanently drop the failed exchanges.
|
|
1256
|
-
// We still always persist the resolved agent_id (migrating any legacy literal
|
|
1257
|
-
// value) so later hooks see the derived bucket directly.
|
|
1258
|
-
const watermarkLine = failed === 0 ? currentLines : lastLine;
|
|
1259
|
-
if (failed > 0) {
|
|
1260
|
-
process.stderr.write(
|
|
1261
|
-
`[meko-capture] ${failed}/${exchanges.length} exchange(s) failed; ` +
|
|
1262
|
-
`holding watermark at line ${lastLine} for retry (dedup makes resends safe).\n`
|
|
1263
|
-
);
|
|
1264
|
-
}
|
|
1265
|
-
writeWatermark(wmPath, convId, watermarkLine, agentId, datapackPin);
|
|
1266
|
-
|
|
1267
|
-
// Output
|
|
2681
|
+
const result = await drainSession(drainOpts);
|
|
1268
2682
|
const context =
|
|
1269
|
-
hookType === "pre-compact"
|
|
1270
|
-
? `Pre-compact: captured ${captured} exchanges to Meko conversation
|
|
2683
|
+
hookType === "pre-compact" && result.captured > 0
|
|
2684
|
+
? `Pre-compact: captured ${result.captured} exchanges to Meko conversation`
|
|
1271
2685
|
: "";
|
|
1272
2686
|
process.stdout.write(hookOutput(context));
|
|
1273
2687
|
}
|
|
1274
2688
|
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
2689
|
+
module.exports = {
|
|
2690
|
+
deriveAgentId,
|
|
2691
|
+
drainSession,
|
|
2692
|
+
makeDrainResult,
|
|
2693
|
+
boundaryAfterExchange,
|
|
2694
|
+
realUserTurnLines,
|
|
2695
|
+
INTERRUPTED_ASSISTANT_OUTPUT,
|
|
2696
|
+
buildExchanges,
|
|
2697
|
+
extractExchanges,
|
|
2698
|
+
addMessage,
|
|
2699
|
+
};
|
|
2700
|
+
|
|
2701
|
+
if (require.main === module) {
|
|
2702
|
+
main().catch((err) => {
|
|
2703
|
+
process.stderr.write(`[meko-capture] Fatal error: ${err.message}\n`);
|
|
2704
|
+
process.stdout.write(hookOutput(""));
|
|
2705
|
+
});
|
|
2706
|
+
}
|