@yugabytedb/meko-mcp 2.1.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.
Files changed (45) hide show
  1. package/README.md +152 -27
  2. package/assets/hooks-handlers/codex-session-end.sh +4 -69
  3. package/assets/hooks-handlers/cursor-session-start.sh +19 -1
  4. package/assets/hooks-handlers/lib/capture-notices.js +321 -0
  5. package/assets/hooks-handlers/lib/capture-state.js +2079 -0
  6. package/assets/hooks-handlers/lib/capture-status-cli.js +82 -0
  7. package/assets/hooks-handlers/lib/capture.js +1578 -299
  8. package/assets/hooks-handlers/lib/checkpoint-timer.js +444 -81
  9. package/assets/hooks-handlers/lib/kiro-handler.js +738 -0
  10. package/assets/hooks-handlers/lib/terminal-handoff.js +418 -0
  11. package/assets/hooks-handlers/session-end.sh +3 -34
  12. package/assets/scripts/meko-statusline.sh +41 -1
  13. package/assets/skills/meko-mcp-tools/SKILL.md +72 -125
  14. package/assets/skills/meko-mcp-tools/references/_sections.md +2 -2
  15. package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +12 -13
  16. package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +15 -12
  17. package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +25 -1
  18. package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +19 -77
  19. package/assets/skills/meko-mcp-tools/references/tools-overview.md +8 -7
  20. package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +22 -1
  21. package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +1 -1
  22. package/assets/skills/meko-mcp-tools-desktop/SKILL.md +8 -8
  23. package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +1 -1
  24. package/assets/skills/meko-mcp-tools-desktop/references/_sections.md +1 -1
  25. package/assets/skills/meko-mcp-tools-desktop/references/tools-agent-id-conventions.md +6 -1
  26. package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +6 -5
  27. package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +4 -4
  28. package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +7 -6
  29. package/assets/skills/meko-select-datapack-desktop/SKILL.md +1 -1
  30. package/assets/skills/meko-select-datapack-desktop/references/cookbook.md +3 -3
  31. package/bin/create-meko-setup.mjs +48 -10
  32. package/lib/capture-status.mjs +67 -0
  33. package/lib/clients/aliases.mjs +5 -0
  34. package/lib/clients/claude-desktop.mjs +120 -5
  35. package/lib/clients/codex.mjs +6 -3
  36. package/lib/clients/index.mjs +2 -0
  37. package/lib/clients/kiro.mjs +705 -0
  38. package/lib/config.mjs +90 -0
  39. package/lib/installer.mjs +1 -0
  40. package/lib/migrate/adapters/codex.mjs +490 -0
  41. package/lib/migrate/id.mjs +28 -3
  42. package/lib/migrate/orchestrator.mjs +34 -7
  43. package/lib/migrate.mjs +4 -2
  44. package/lib/prompt.mjs +16 -1
  45. package/package.json +4 -4
@@ -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, watermark management, and MCP API calls.
6
- * No external dependencies — uses only Node.js built-ins (fs, http/https, crypto).
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 # creates conversation + watermark, outputs additionalContext
10
- * node capture.js pre-compact # reads hook input from stdin
11
- * node capture.js session-end # reads hook input from stdin
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 (client !== "claude_code" && client !== "cursor") return COMMON_BUCKET_AGENT_ID;
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 = process.env.MEKO_HOOK_CLIENT === "cursor" ? "cursor" : "claude_code";
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
- function extractExchanges(filePath, startLine) {
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
- const obj = JSON.parse(line);
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
- // Step 2: Group into exchanges using turn-assembly.
253
- // A real user message (no toolUseResult) starts a new exchange.
254
- // All subsequent assistant lines and tool-result user lines belong
255
- // to that exchange until the next real user message.
256
- const exchanges = [];
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 entries) {
557
+ for (const { entry, line } of items) {
260
558
  if (entry.type === "user" && !isToolResultMessage(entry)) {
261
- if (current) exchanges.push(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) exchanges.push(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
- // Step 3: Build output for each exchange
278
- const skipPattern =
279
- /^<(system-reminder|command-|local-command|available-deferred)/;
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 (const ex of exchanges) {
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 };
671
+ }
672
+
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;
322
680
  }
323
681
 
324
- // --- Watermark ---
682
+ // --- Watermark / outbox (bridged to capture-state v2) ---
325
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
- try {
328
- return JSON.parse(fs.readFileSync(wmPath, "utf-8"));
329
- } catch {
330
- return { conversation_id: "", last_line_number: 0, agent_id: "" };
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
+ };
331
707
  }
708
+ if (result.missing) {
709
+ return {
710
+ conversation_id: "",
711
+ last_line_number: 0,
712
+ agent_id: "",
713
+ agent_id_source: "",
714
+ };
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
- function writeWatermark(wmPath, convId, lineNum, agentId, datapackPin = null) {
335
- fs.mkdirSync(path.dirname(wmPath), { recursive: true });
336
- const existing = readWatermark(wmPath);
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
- existing.datapack_id && existing.datapack_name
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
- fs.writeFileSync(
346
- wmPath,
347
- JSON.stringify({
348
- conversation_id: convId,
349
- last_line_number: lineNum,
350
- agent_id: agentId != null ? agentId : existing.agent_id || "",
351
- datapack_id: pin ? pin.datapack_id : null,
352
- datapack_name: pin ? pin.datapack_name : null,
353
- updated_at: new Date().toISOString(),
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
  /**
@@ -367,45 +877,6 @@ function datapackPinSlug(agentId) {
367
877
  return trimmed.replace(/[^A-Za-z0-9._-]+/g, "_").replace(/^_+|_+$/g, "") || COMMON_BUCKET_AGENT_ID;
368
878
  }
369
879
 
370
- function captureErrorPath(agentId) {
371
- return path.join(WATERMARK_DIR, `last-capture-error-${datapackPinSlug(agentId)}.json`);
372
- }
373
-
374
- function readCaptureError(agentId) {
375
- try {
376
- const error = JSON.parse(fs.readFileSync(captureErrorPath(agentId), "utf-8"));
377
- return error && typeof error === "object" ? error : null;
378
- } catch {
379
- return null;
380
- }
381
- }
382
-
383
- function writeCaptureError(agentId, error) {
384
- try {
385
- fs.mkdirSync(WATERMARK_DIR, { recursive: true });
386
- fs.writeFileSync(
387
- captureErrorPath(agentId),
388
- JSON.stringify({
389
- ...error,
390
- agent_id: agentId,
391
- occurred_at: new Date().toISOString(),
392
- }),
393
- );
394
- } catch (err) {
395
- process.stderr.write(`[meko-capture] Could not persist capture error: ${err.message}\n`);
396
- }
397
- }
398
-
399
- function clearCaptureError(agentId) {
400
- try {
401
- fs.unlinkSync(captureErrorPath(agentId));
402
- } catch (err) {
403
- if (err && err.code !== "ENOENT") {
404
- process.stderr.write(`[meko-capture] Could not clear capture error: ${err.message}\n`);
405
- }
406
- }
407
- }
408
-
409
880
  /**
410
881
  * Read the active-datapack pin written by the meko-select-datapack skill.
411
882
  *
@@ -772,9 +1243,15 @@ async function fetchRecentMemories(agentId, datapackId = null, { limit = 10, bud
772
1243
  }
773
1244
 
774
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";
775
1252
  const payload = {
776
1253
  agent_id: agentId,
777
- title: "Claude Code session (auto-captured)",
1254
+ title: `${clientLabel} session (auto-captured)`,
778
1255
  session_id: sessionId,
779
1256
  };
780
1257
  if (metadata) {
@@ -791,6 +1268,100 @@ async function createConversation(sessionId, agentId, metadata = null, datapackI
791
1268
  return result && result.id ? result.id : null;
792
1269
  }
793
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
+
794
1365
  async function addMessage(convId, agentId, exchange, datapackId = null) {
795
1366
  const seed = `${convId}:${exchange.user_uuid}`;
796
1367
  const payload = {
@@ -802,6 +1373,12 @@ async function addMessage(convId, agentId, exchange, datapackId = null) {
802
1373
  seed: seed,
803
1374
  };
804
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
+ }
805
1382
  const response = await mcpCall("conversation_add_message", payload);
806
1383
  // FAIL CLOSED. The MCP transport resolves successfully even when the tool
807
1384
  // failed, and failures take several shapes: an `isError` result envelope, a
@@ -825,6 +1402,19 @@ async function addMessage(convId, agentId, exchange, datapackId = null) {
825
1402
  if (result.error) {
826
1403
  throw new Error(`conversation_add_message failed: ${JSON.stringify(result.error)}`);
827
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
+ }
828
1418
  return result;
829
1419
  }
830
1420
 
@@ -889,17 +1479,28 @@ function sessionCachePath(sessionId) {
889
1479
  function writeSessionCache(sessionId, payload) {
890
1480
  const filePath = sessionCachePath(sessionId);
891
1481
  if (!filePath) return;
892
- const workspaceKey = payload.workspace_key || "";
893
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
894
- const record = {
895
- session_id: sessionId,
896
- updated_at: new Date().toISOString(),
897
- ...payload,
898
- };
899
- fs.writeFileSync(filePath, JSON.stringify(record));
900
- if (workspaceKey) {
901
- const safe = workspaceKey.replace(/[^A-Za-z0-9_.-]/g, "_");
902
- fs.writeFileSync(path.join(SESSION_CACHE_DIR, `workspace-${safe}.json`), JSON.stringify(record));
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
+ );
903
1504
  }
904
1505
  }
905
1506
 
@@ -916,8 +1517,6 @@ function workspaceKeyFromHookInput(hookInput, transcriptPath) {
916
1517
  return cwd || process.cwd();
917
1518
  }
918
1519
 
919
- // --- Main ---
920
-
921
1520
  // --- SessionStart: create conversation + watermark deterministically ---
922
1521
 
923
1522
  function findMostRecentProjectWatermark(transcriptPath, currentSessionId) {
@@ -938,6 +1537,7 @@ function findMostRecentProjectWatermark(transcriptPath, currentSessionId) {
938
1537
  sessionId: sid,
939
1538
  conversation_id: wm.conversation_id,
940
1539
  agent_id: wm.agent_id || "",
1540
+ agent_id_source: wm.agent_id_source || "",
941
1541
  datapack_id: wm.datapack_id || null,
942
1542
  datapack_name: wm.datapack_name || null,
943
1543
  updated_at: ts,
@@ -955,17 +1555,9 @@ function buildSessionStartContext(convId, sessionId, agentId, memories, opts) {
955
1555
  const resolvedAgentId = agentId || COMMON_BUCKET_AGENT_ID;
956
1556
  const resumed = Boolean(opts && opts.resumed);
957
1557
  const pin = opts && opts.datapackPin ? opts.datapackPin : null;
958
- const captureError = opts && opts.captureError ? opts.captureError : null;
959
1558
  const opening = resumed
960
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.`
961
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.`;
962
- const captureWarning = captureError
963
- ? `
964
-
965
- ### Automatic capture warning — tell the user
966
-
967
- The last automatic capture attempt was persistently rejected because ${captureError.reason || "Meko rejected the write"}. **Do not claim that this session's turns are being saved.** The hook kept ${Number(captureError.held_exchanges || captureError.failed_exchanges) || 1} exchange(s) queued for retry instead of dropping them. Tell the user that automatic conversation capture is currently unhealthy and include this reason. This warning remains until a later hook write succeeds.`
968
- : "";
969
1561
  const memoryBlock =
970
1562
  Array.isArray(memories) && memories.length > 0
971
1563
  ? `
@@ -981,7 +1573,6 @@ ${memories.join("\n")}`
981
1573
  return `## Meko Session Active
982
1574
 
983
1575
  ${opening}
984
- ${captureWarning}
985
1576
 
986
1577
  ### What you MUST do with Meko tools
987
1578
 
@@ -1074,6 +1665,18 @@ function handleBeforeSubmitPrompt(hookInput) {
1074
1665
  process.stdout.write(beforeSubmitOutput("allow"));
1075
1666
  }
1076
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
+
1077
1680
  async function handleSessionStart(hookInput) {
1078
1681
  const transcriptPath =
1079
1682
  hookInput.transcript_path ||
@@ -1089,30 +1692,91 @@ async function handleSessionStart(hookInput) {
1089
1692
  if (sessionId) {
1090
1693
  const wmPath = path.join(WATERMARK_DIR, `${sessionId}.watermark.json`);
1091
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
+ }
1092
1705
  if (existing.conversation_id) {
1093
1706
  process.stderr.write(`[meko-capture] Session ${sessionId}: reusing existing conversation ${existing.conversation_id}\n`);
1094
1707
  // Prefer the watermark's cached agent_id so a user-set bucket survives
1095
- // re-fires of SessionStart (e.g. /clear, /compact). The exception is
1096
- // the legacy literal "agent" left by pre-contract installers — we
1097
- // override that with the freshly derived `claude_code:<repo>` so
1098
- // upgraded users stop writing into the ghost namespace.
1099
- const cached = (existing.agent_id || "").trim();
1100
- const resumedAgentId = cached && cached !== "agent" ? cached : derivedAgentId;
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
+ );
1101
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
+ );
1102
1767
  // Skip preload on resume (we don't know what's already in context).
1103
- const context = buildSessionStartContext(
1104
- existing.conversation_id,
1768
+ let context = withHealthNotice(buildSessionStartContext(
1769
+ resumedConvId,
1105
1770
  sessionId,
1106
1771
  resumedAgentId,
1107
1772
  null,
1108
1773
  {
1109
1774
  datapackPin: existingDatapackPin,
1110
- captureError: readCaptureError(resumedAgentId),
1111
1775
  },
1112
- );
1776
+ ));
1113
1777
  writeSessionCache(sessionId, {
1114
1778
  status: "ready",
1115
- conversation_id: existing.conversation_id,
1779
+ conversation_id: resumedConvId,
1116
1780
  agent_id: resumedAgentId,
1117
1781
  workspace_key: workspaceKey,
1118
1782
  additional_context: context,
@@ -1132,38 +1796,73 @@ async function handleSessionStart(hookInput) {
1132
1796
  if (prior && prior.conversation_id) {
1133
1797
  const currentLineCount = fs.existsSync(transcriptPath) ? countLines(transcriptPath) : 0;
1134
1798
  const wmPath = path.join(WATERMARK_DIR, `${sessionId}.watermark.json`);
1135
- // Preserve a non-legacy cached agent_id from the prior watermark so a
1136
- // user-set bucket (e.g. MEKO_AGENT_ID=foo, or a project-scoped
1137
- // claude_code:<other-repo> from the original session) survives the
1138
- // resume. Mirrors the same-session reuse branch above. The legacy
1139
- // literal "agent" is overridden with the freshly derived per-project
1140
- // bucket so upgraded users stop writing into the ghost namespace.
1141
- const priorAgentId = (prior.agent_id || "").trim();
1142
- const resumedAgentId =
1143
- priorAgentId && priorAgentId !== "agent" ? priorAgentId : agentId;
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
+ );
1144
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.
1145
1844
  writeWatermark(
1146
1845
  wmPath,
1147
- prior.conversation_id,
1148
- currentLineCount,
1846
+ resumedConvId,
1847
+ owned.rebucketed ? 0 : currentLineCount,
1149
1848
  resumedAgentId,
1150
1849
  priorDatapackPin,
1850
+ owned.source,
1151
1851
  );
1152
- process.stderr.write(`[meko-capture] Session ${sessionId}: resuming conversation ${prior.conversation_id} from ${prior.sessionId}, skipping ${currentLineCount} copied lines\n`);
1153
- const context = buildSessionStartContext(
1154
- prior.conversation_id,
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,
1155
1855
  sessionId,
1156
1856
  resumedAgentId,
1157
1857
  null,
1158
1858
  {
1159
1859
  resumed: true,
1160
1860
  datapackPin: priorDatapackPin,
1161
- captureError: readCaptureError(resumedAgentId),
1162
1861
  },
1163
- );
1862
+ ));
1164
1863
  writeSessionCache(sessionId, {
1165
1864
  status: "ready",
1166
- conversation_id: prior.conversation_id,
1865
+ conversation_id: resumedConvId,
1167
1866
  agent_id: resumedAgentId,
1168
1867
  workspace_key: workspaceKey,
1169
1868
  additional_context: context,
@@ -1176,8 +1875,29 @@ async function handleSessionStart(hookInput) {
1176
1875
 
1177
1876
  // Create conversation via MCP. Attach session metadata (cwd / gitBranch /
1178
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).
1179
1880
  const transcriptMetadata = peekTranscriptMetadata(transcriptPath);
1180
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
+ }
1181
1901
  let convId = null;
1182
1902
  try {
1183
1903
  await mcpInitialize();
@@ -1189,15 +1909,19 @@ async function handleSessionStart(hookInput) {
1189
1909
  );
1190
1910
  } catch (err) {
1191
1911
  process.stderr.write(`[meko-capture] SessionStart: MCP unavailable (${err.message}). Falling back to agent-driven setup.\n`);
1192
- const context = buildSessionStartFallbackContext(agentId, { datapackPin: readDatapackPin(agentId) });
1912
+ let context = withHealthNotice(buildSessionStartFallbackContext(agentId, { datapackPin: readDatapackPin(agentId) }));
1193
1913
  if (sessionId) {
1194
- writeSessionCache(sessionId, {
1195
- status: "error",
1196
- agent_id: agentId,
1197
- workspace_key: workspaceKey,
1198
- error: err && err.message ? String(err.message).slice(0, 240) : "MCP unavailable",
1199
- additional_context: context,
1200
- });
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
+ }
1201
1925
  }
1202
1926
  process.stdout.write(hookOutput(context));
1203
1927
  return;
@@ -1205,15 +1929,19 @@ async function handleSessionStart(hookInput) {
1205
1929
 
1206
1930
  if (!convId) {
1207
1931
  process.stderr.write("[meko-capture] SessionStart: conversation_create returned no ID.\n");
1208
- const context = buildSessionStartFallbackContext(agentId, { datapackPin: readDatapackPin(agentId) });
1932
+ let context = withHealthNotice(buildSessionStartFallbackContext(agentId, { datapackPin: readDatapackPin(agentId) }));
1209
1933
  if (sessionId) {
1210
- writeSessionCache(sessionId, {
1211
- status: "error",
1212
- agent_id: agentId,
1213
- workspace_key: workspaceKey,
1214
- error: "conversation_create returned no ID",
1215
- additional_context: context,
1216
- });
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
+ }
1217
1945
  }
1218
1946
  process.stdout.write(hookOutput(context));
1219
1947
  return;
@@ -1232,49 +1960,665 @@ async function handleSessionStart(hookInput) {
1232
1960
 
1233
1961
  if (sessionId) {
1234
1962
  const wmPath = path.join(WATERMARK_DIR, `${sessionId}.watermark.json`);
1235
- writeWatermark(wmPath, convId, 0, agentId, datapackPin);
1963
+ writeWatermark(wmPath, convId, 0, agentId, datapackPin, agentIdSource);
1236
1964
  process.stderr.write(
1237
1965
  `[meko-capture] SessionStart: created conversation ${convId}, agent_id=${agentId}, preloaded ${memories.length} memories, watermark at ${wmPath}\n`,
1238
1966
  );
1239
1967
  }
1240
1968
 
1241
- const context = buildSessionStartContext(convId, sessionId, agentId, memories, {
1969
+ let context = withHealthNotice(buildSessionStartContext(convId, sessionId, agentId, memories, {
1242
1970
  datapackPin,
1243
- captureError: readCaptureError(agentId),
1244
- });
1971
+ }));
1245
1972
  if (sessionId) {
1246
- writeSessionCache(sessionId, {
1247
- status: "ready",
1248
- conversation_id: convId,
1249
- agent_id: agentId,
1250
- workspace_key: workspaceKey,
1251
- additional_context: context,
1252
- });
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
+ }
1253
1984
  }
1254
1985
  process.stdout.write(hookOutput(context));
1255
1986
  }
1256
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
+
1257
2578
  // --- Main ---
1258
2579
 
1259
2580
  async function main() {
1260
2581
  const hookType = process.argv[2];
1261
- if (!hookType || !["session-start", "before-submit-prompt", "pre-compact", "session-end", "checkpoint"].includes(hookType)) {
1262
- process.stderr.write("Usage: capture.js <session-start|before-submit-prompt|pre-compact|session-end|checkpoint>\n");
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
+ );
1263
2595
  process.exit(1);
1264
2596
  }
1265
2597
 
1266
- // Read hook input from stdin
1267
2598
  let hookInput;
1268
2599
  try {
1269
2600
  const stdin = fs.readFileSync(0, "utf-8");
1270
- hookInput = JSON.parse(stdin);
2601
+ hookInput = JSON.parse(stdin || "{}");
1271
2602
  } catch (err) {
1272
- process.stderr.write(`[meko-capture] Failed to parse hook input from stdin: ${err.message}\n`);
1273
- process.stdout.write(hookOutput(""));
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
+ }
1274
2619
  return;
1275
2620
  }
1276
2621
 
1277
- // SessionStart: create conversation + watermark, output context
1278
2622
  if (hookType === "session-start") {
1279
2623
  await handleSessionStart(hookInput);
1280
2624
  return;
@@ -1285,143 +2629,78 @@ async function main() {
1285
2629
  return;
1286
2630
  }
1287
2631
 
1288
- // PreCompact / SessionEnd: capture transcript exchanges
1289
2632
  const transcriptPath =
1290
2633
  hookInput.transcript_path ||
1291
- (hookInput.hookSpecificInput || {}).transcript_path;
1292
-
1293
- if (!transcriptPath || !fs.existsSync(transcriptPath)) {
1294
- process.stdout.write(hookOutput(""));
1295
- return;
1296
- }
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
+ };
1297
2654
 
1298
- const sessionId = path.basename(transcriptPath, ".jsonl");
1299
- const wmPath = path.join(WATERMARK_DIR, `${sessionId}.watermark.json`);
1300
- const watermark = readWatermark(wmPath);
1301
- let convId = watermark.conversation_id || "";
1302
- const lastLine = watermark.last_line_number || 0;
1303
- const currentLines = countLines(transcriptPath);
1304
-
1305
- // Resolve agent_id for this hook invocation. Prefer the value the
1306
- // SessionStart hook persisted in the watermark; if it's missing or a
1307
- // legacy literal ("agent"), fall back to a fresh derivation from the
1308
- // transcript's cwd so writes land in the right bucket.
1309
- const cachedAgentId = (watermark.agent_id || "").trim();
1310
- const agentId = cachedAgentId && cachedAgentId !== "agent"
1311
- ? cachedAgentId
1312
- : resolveSessionAgentId(transcriptPath);
1313
- const datapackPin = datapackPinFromWatermark(watermark);
1314
-
1315
- // Nothing new
1316
- if (currentLines <= lastLine) {
1317
- process.stdout.write(hookOutput(""));
1318
- return;
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;
1319
2665
  }
1320
2666
 
1321
- // Initialize MCP session
1322
- try {
1323
- await mcpInitialize();
1324
- } catch (err) {
1325
- const persistent = classifyPersistentCaptureFailure(err);
1326
- if (persistent) {
1327
- const pendingExchanges = extractExchanges(transcriptPath, lastLine).length;
1328
- writeCaptureError(agentId, {
1329
- code: persistent.code,
1330
- reason: persistent.reason,
1331
- held_exchanges: pendingExchanges || 1,
1332
- session_id: sessionId,
1333
- conversation_id: convId || null,
1334
- });
1335
- process.stderr.write(
1336
- `[meko-capture] Persistent capture rejection recorded for the next SessionStart: ${persistent.reason}.\n`,
1337
- );
1338
- }
1339
- process.stderr.write(`[meko-capture] Failed to initialize MCP session: ${err.message}\n`);
1340
- process.stdout.write(hookOutput(""));
2667
+ if (hookType === "drain" || hookType === "recover") {
2668
+ const result = await drainSession(drainOpts);
2669
+ process.stdout.write(JSON.stringify(result) + "\n");
1341
2670
  return;
1342
2671
  }
1343
2672
 
1344
- // Only SessionStart creates conversations. All other modes require
1345
- // an existing watermark. Skip gracefully if missing.
1346
- if (!convId) {
1347
- process.stderr.write(
1348
- `[meko-capture] ${hookType}: No conversation in watermark for session ${sessionId}. ` +
1349
- `SessionStart hook may not have run yet.\n`
1350
- );
2673
+ if (!transcriptPath || !fs.existsSync(transcriptPath)) {
1351
2674
  process.stdout.write(hookOutput(""));
1352
2675
  return;
1353
2676
  }
1354
-
1355
- // Extract and send exchanges
1356
- const exchanges = extractExchanges(transcriptPath, lastLine);
1357
- let captured = 0;
1358
- let failed = 0;
1359
- const persistentFailures = [];
1360
-
1361
- for (const exchange of exchanges) {
1362
- try {
1363
- await addMessage(
1364
- convId,
1365
- agentId,
1366
- exchange,
1367
- datapackPin && datapackPin.datapack_id,
1368
- );
1369
- captured++;
1370
- } catch (err) {
1371
- failed++;
1372
- const persistent = classifyPersistentCaptureFailure(err);
1373
- if (persistent) persistentFailures.push(persistent);
1374
- process.stderr.write(`[meko-capture] Failed to add message (uuid=${exchange.user_uuid}): ${err.message}\n`);
1375
- }
1376
- }
1377
-
1378
- if (persistentFailures.length > 0) {
1379
- const persistent = persistentFailures[0];
1380
- writeCaptureError(agentId, {
1381
- code: persistent.code,
1382
- reason: persistent.reason,
1383
- // Every failed exchange remains held by the watermark, including any
1384
- // transient failures that happened in the same persistently rejected batch.
1385
- held_exchanges: failed,
1386
- session_id: sessionId,
1387
- conversation_id: convId,
1388
- });
1389
- process.stderr.write(
1390
- `[meko-capture] Persistent capture rejection recorded for the next SessionStart: ${persistent.reason}.\n`,
1391
- );
1392
- } else if (captured > 0) {
1393
- // A successful write proves the prior persistent rejection has cleared.
1394
- // Transient failures may still hold this batch's watermark independently.
1395
- clearCaptureError(agentId);
1396
- }
1397
-
1398
- // Advance the watermark to currentLines ONLY when every exchange was
1399
- // captured. If any addMessage failed (typically a transient network error),
1400
- // keep the watermark at lastLine so the next checkpoint / PreCompact /
1401
- // SessionEnd hook re-extracts and retries this batch. Re-sending the
1402
- // exchanges that already succeeded is safe: capture dedups by seed
1403
- // (<conv_id>:<user_uuid>), so retries never create duplicates. Advancing on
1404
- // partial failure is what would permanently drop the failed exchanges.
1405
- // We still always persist the resolved agent_id (migrating any legacy literal
1406
- // value) so later hooks see the derived bucket directly.
1407
- const watermarkLine = failed === 0 ? currentLines : lastLine;
1408
- if (failed > 0) {
1409
- process.stderr.write(
1410
- `[meko-capture] ${failed}/${exchanges.length} exchange(s) failed; ` +
1411
- `holding watermark at line ${lastLine} for retry (dedup makes resends safe).\n`
1412
- );
2677
+ if (hookType === "session-end") {
2678
+ drainOpts.lifecycle = "closing";
1413
2679
  }
1414
- writeWatermark(wmPath, convId, watermarkLine, agentId, datapackPin);
1415
2680
 
1416
- // Output
2681
+ const result = await drainSession(drainOpts);
1417
2682
  const context =
1418
- hookType === "pre-compact"
1419
- ? `Pre-compact: captured ${captured} exchanges to Meko conversation ${convId}`
2683
+ hookType === "pre-compact" && result.captured > 0
2684
+ ? `Pre-compact: captured ${result.captured} exchanges to Meko conversation`
1420
2685
  : "";
1421
2686
  process.stdout.write(hookOutput(context));
1422
2687
  }
1423
2688
 
1424
- main().catch((err) => {
1425
- process.stderr.write(`[meko-capture] Fatal error: ${err.message}\n`);
1426
- process.stdout.write(hookOutput(""));
1427
- });
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
+ }