@yugabytedb/meko-mcp 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +183 -33
  2. package/assets/hooks-handlers/codex-session-end.sh +13 -0
  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 +1653 -225
  8. package/assets/hooks-handlers/lib/checkpoint-timer.js +455 -50
  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 -24
  12. package/assets/hooks-handlers/session-start.sh +8 -1
  13. package/assets/hooks-handlers/test_checkpoint_timer.sh +162 -0
  14. package/assets/scripts/meko-statusline.sh +41 -1
  15. package/assets/scripts/package-desktop-skill.sh +32 -1
  16. package/assets/skills/meko-mcp-tools/SKILL.md +90 -137
  17. package/assets/skills/meko-mcp-tools/references/_sections.md +3 -5
  18. package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +31 -28
  19. package/assets/skills/meko-mcp-tools/references/tools-conversation-dedup.md +5 -7
  20. package/assets/skills/meko-mcp-tools/references/tools-conversation-reasoning.md +50 -0
  21. package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +69 -49
  22. package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +6 -6
  23. package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +29 -5
  24. package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +29 -92
  25. package/assets/skills/meko-mcp-tools/references/tools-overview.md +42 -40
  26. package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +1 -2
  27. package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +18 -11
  28. package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +10 -9
  29. package/assets/skills/meko-mcp-tools-desktop/SKILL.md +51 -44
  30. package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +6 -6
  31. package/assets/skills/meko-mcp-tools-desktop/references/_sections.md +2 -2
  32. package/assets/skills/meko-mcp-tools-desktop/references/tools-agent-id-conventions.md +34 -25
  33. package/assets/skills/meko-mcp-tools-desktop/references/tools-conversation-reasoning.md +50 -0
  34. package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +63 -37
  35. package/assets/skills/meko-mcp-tools-desktop/references/tools-known-limitations.md +4 -4
  36. package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +19 -22
  37. package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +42 -40
  38. package/assets/skills/meko-mcp-tools-desktop/references/tools-rag-workflow.md +1 -2
  39. package/assets/skills/meko-mcp-tools-desktop/references/tools-troubleshooting.md +3 -17
  40. package/assets/skills/meko-select-datapack-desktop/SKILL.md +31 -21
  41. package/assets/skills/meko-select-datapack-desktop/references/cookbook.md +8 -8
  42. package/assets/skills/meko-select-datapack-desktop/references/role-display-future.md +41 -0
  43. package/assets/skills/meko-select-datapack-desktop/references/troubleshooting.md +1 -1
  44. package/bin/create-meko-setup.mjs +93 -16
  45. package/lib/capture-status.mjs +67 -0
  46. package/lib/clients/aliases.mjs +5 -0
  47. package/lib/clients/claude-code.mjs +8 -4
  48. package/lib/clients/claude-desktop.mjs +133 -4
  49. package/lib/clients/codex.mjs +29 -11
  50. package/lib/clients/common.mjs +2 -0
  51. package/lib/clients/cursor.mjs +7 -2
  52. package/lib/clients/index.mjs +2 -0
  53. package/lib/clients/kiro.mjs +705 -0
  54. package/lib/config.mjs +90 -0
  55. package/lib/detect.mjs +7 -2
  56. package/lib/installer.mjs +1 -0
  57. package/lib/migrate/adapters/codex.mjs +490 -0
  58. package/lib/migrate/id.mjs +28 -3
  59. package/lib/migrate/orchestrator.mjs +34 -7
  60. package/lib/migrate.mjs +4 -2
  61. package/lib/prompt.mjs +17 -2
  62. package/lib/url-selection.mjs +55 -0
  63. package/package.json +4 -4
  64. package/assets/skills/meko-mcp-tools/references/tools-scope-permissions.md +0 -71
  65. package/assets/skills/meko-mcp-tools-desktop/references/tools-scope-permissions.md +0 -71
@@ -1,14 +1,29 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * capture.js — Unified conversation capture for Meko MCP plugin.
3
+ * capture.js — Unified conversation capture for Meko MCP plugin (Capture V2).
4
4
  *
5
- * Handles transcript parsing, 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 };
322
671
  }
323
672
 
324
- // --- Watermark ---
673
+ /**
674
+ * Extract renderable exchanges from a transcript starting at `startLine`.
675
+ * Thin wrapper over parseTranscriptEntries + buildExchanges kept for the
676
+ * callers that only need the exchange list.
677
+ */
678
+ function extractExchanges(filePath, startLine) {
679
+ return buildExchanges(parseTranscriptEntries(filePath, startLine)).exchanges;
680
+ }
325
681
 
682
+ // --- Watermark / outbox (bridged to capture-state v2) ---
683
+
684
+ /**
685
+ * Compatibility reader used by SessionStart resume/rebucket paths.
686
+ * Returns a flat object with the classic watermark fields. Corrupt files are
687
+ * surfaced via `__corrupt` so callers never invent a zero cursor.
688
+ */
326
689
  function readWatermark(wmPath) {
327
- 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
+ };
707
+ }
708
+ if (result.missing) {
709
+ return {
710
+ conversation_id: "",
711
+ last_line_number: 0,
712
+ agent_id: "",
713
+ agent_id_source: "",
714
+ };
331
715
  }
716
+ return {
717
+ __corrupt: true,
718
+ failure_class: result.failure_class || "state_corrupt",
719
+ error: result.error || "corrupt watermark",
720
+ conversation_id: "",
721
+ last_line_number: 0,
722
+ agent_id: "",
723
+ agent_id_source: "",
724
+ };
332
725
  }
333
726
 
334
- 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
  /**
@@ -409,7 +919,7 @@ function buildActiveDatapackBlock(pin) {
409
919
 
410
920
  ### Active datapack
411
921
 
412
- The user pinned datapack **${pin.datapack_name}** (\`${pin.datapack_id}\`) for this project via the \`meko-select-datapack\` skill. Pass \`datapack_id="${pin.datapack_id}"\` to every Meko MCP tool call (memory_*, db_*, knowledgebase_*, conversation_*) unless the user explicitly overrides for a single call. The pin is project-scoped (keyed by \`agent_id\`) and survives \`/clear\`, \`/compact\`, and Claude Code restart. Automatic capture keeps the datapack selected when this conversation was created; switching or clearing takes effect for automatic capture on the next new session.`;
922
+ The user pinned datapack **${pin.datapack_name}** (\`${pin.datapack_id}\`) for this project via the \`meko-select-datapack\` skill. Pass \`datapack_id="${pin.datapack_id}"\` to every Meko MCP tool call that accepts it (memory_*, knowledgebase_search, conversation_*, artifact_*) unless the user explicitly overrides for a single call. The pin is project-scoped (keyed by \`agent_id\`) and survives \`/clear\`, \`/compact\`, and Claude Code restart. Automatic capture keeps the datapack selected when this conversation was created; switching or clearing takes effect for automatic capture on the next new session.`;
413
923
  }
414
924
 
415
925
  // note: Subagents cannot auto-discover the parent's watermark today.
@@ -470,6 +980,16 @@ function mcpPost(jsonBody) {
470
980
  res.on("data", (chunk) => (data += chunk));
471
981
  res.on("end", () => {
472
982
  inflightRequests.delete(req);
983
+ if (res.statusCode >= 400) {
984
+ const snippet = data.trim().replace(/\s+/g, " ").slice(0, 200);
985
+ const err = new Error(
986
+ `MCP HTTP ${res.statusCode}${snippet ? `: ${snippet}` : ""}`,
987
+ );
988
+ err.statusCode = res.statusCode;
989
+ err.responseBody = data.slice(0, 1000);
990
+ reject(err);
991
+ return;
992
+ }
473
993
  // Notifications return 202/204 with no body — that's OK
474
994
  if (!data.trim()) {
475
995
  resolve({ body: null, headers: res.headers });
@@ -535,6 +1055,51 @@ function parseMcpResponseBody(data) {
535
1055
  return JSON.parse(payload);
536
1056
  }
537
1057
 
1058
+ function classifyPersistentCaptureFailure(err) {
1059
+ const message = err && err.message ? String(err.message) : String(err || "");
1060
+ const normalized = message.toLowerCase();
1061
+ const statusCode = Number(err && err.statusCode);
1062
+ const statusMatch = normalized.match(/\bmcp http (402|403)\b/);
1063
+ let persistentStatus = null;
1064
+ if (statusCode === 402 || statusCode === 403) {
1065
+ persistentStatus = statusCode;
1066
+ } else if (statusMatch) {
1067
+ persistentStatus = Number(statusMatch[1]);
1068
+ }
1069
+
1070
+ if (persistentStatus) {
1071
+ return {
1072
+ code: `http_${persistentStatus}`,
1073
+ reason: `Meko rejected capture with HTTP ${persistentStatus}`,
1074
+ };
1075
+ }
1076
+
1077
+ const persistentPatterns = [
1078
+ ["free_tier_limit_reached", /free[_\s-]?tier.*limit|free_tier_limit_reached/],
1079
+ ["quota_exceeded", /quota[_\s-]?(reached|exceeded)|(?:reached|exceeded).*quota/],
1080
+ ["usage_limit_reached", /usage[_\s-]?limit[_\s-]?(reached|exceeded)/],
1081
+ ["plan_limit_reached", /plan[_\s-]?limit[_\s-]?(reached|exceeded)/],
1082
+ ["payment_required", /payment[_\s-]?required/],
1083
+ ["subscription_required", /subscription[_\s-]?(required|inactive|expired)/],
1084
+ ["entitlement_denied", /entitlement.*(denied|required|missing)|(?:denied|required|missing).*entitlement/],
1085
+ ];
1086
+ for (const [code, pattern] of persistentPatterns) {
1087
+ if (pattern.test(normalized)) {
1088
+ const reasons = {
1089
+ free_tier_limit_reached: "the free-tier capture limit was reached",
1090
+ quota_exceeded: "the capture quota was exceeded",
1091
+ usage_limit_reached: "the capture usage limit was reached",
1092
+ plan_limit_reached: "the current plan's capture limit was reached",
1093
+ payment_required: "capture requires payment",
1094
+ subscription_required: "capture requires an active subscription",
1095
+ entitlement_denied: "the account is not entitled to capture conversations",
1096
+ };
1097
+ return { code, reason: reasons[code] };
1098
+ }
1099
+ }
1100
+ return null;
1101
+ }
1102
+
538
1103
  /**
539
1104
  * Abort all pending MCP requests. Used by the preload timeout path so the
540
1105
  * SessionStart hook can return promptly instead of waiting for a slow server
@@ -654,7 +1219,6 @@ function peekTranscriptMetadata(transcriptPath) {
654
1219
  */
655
1220
  async function fetchRecentMemories(agentId, datapackId = null, { limit = 10, budget = 2000 } = {}) {
656
1221
  const payload = {
657
- scope: "read",
658
1222
  agent_id: agentId,
659
1223
  conversation_id: "00000000-0000-0000-0000-000000000000",
660
1224
  limit,
@@ -679,10 +1243,15 @@ async function fetchRecentMemories(agentId, datapackId = null, { limit = 10, bud
679
1243
  }
680
1244
 
681
1245
  async function createConversation(sessionId, agentId, metadata = null, datapackId = null) {
1246
+ const clientLabel = {
1247
+ claude_code: "Claude Code",
1248
+ cursor: "Cursor",
1249
+ codex: "Codex",
1250
+ kiro: "Kiro",
1251
+ }[resolveHookClient()] || "Coding agent";
682
1252
  const payload = {
683
- scope: "write",
684
1253
  agent_id: agentId,
685
- title: "Claude Code session (auto-captured)",
1254
+ title: `${clientLabel} session (auto-captured)`,
686
1255
  session_id: sessionId,
687
1256
  };
688
1257
  if (metadata) {
@@ -699,10 +1268,103 @@ async function createConversation(sessionId, agentId, metadata = null, datapackI
699
1268
  return result && result.id ? result.id : null;
700
1269
  }
701
1270
 
1271
+ /**
1272
+ * Ensure the conversation we are about to write to is owned by `resolvedAgentId`.
1273
+ *
1274
+ * The server pins a conversation's owner at `conversation_create` and rejects
1275
+ * `conversation_add_message` / `conversation_update` whose agent_id differs
1276
+ * (src/tools.py → `agent_id_mismatch`). There is no owner-reassign tool. So
1277
+ * when MEKO-385 reconciliation changes the identity (e.g. a conversation the
1278
+ * OLD Codex hook created under `claude_code:<repo>` must now be `codex:<repo>`),
1279
+ * we CANNOT keep writing to the old conversation — every future write would
1280
+ * fail closed and wedge the watermark. Instead we open a NEW conversation
1281
+ * under the corrected agent_id and repoint the watermark at it (approach (a);
1282
+ * no server reassign exists).
1283
+ *
1284
+ * The invariant this preserves: a watermark's `agent_id` always equals the
1285
+ * owner of its `conversation_id`. So callers can detect a stale owner simply
1286
+ * by comparing the cached agent_id against the freshly resolved one.
1287
+ *
1288
+ * On rebucket-create FAILURE the fallback depends on WHY the rebucket was
1289
+ * demanded:
1290
+ * - EXPLICIT override (resolvedSource === "explicit", i.e. the user set
1291
+ * MEKO_AGENT_ID): do NOT fall back to capturing under the stale owner —
1292
+ * that would silently attribute turns to the wrong namespace in direct
1293
+ * violation of the explicit override. Instead HOLD: return `{ hold: true }`
1294
+ * so the caller sends no turns and does not advance the watermark, and a
1295
+ * later hook retries the rebucket. A visible diagnostic is emitted.
1296
+ * - DERIVED (auto-derived id): fall back to the ORIGINAL owner (old
1297
+ * conversation + old agent_id) so writes still succeed and the watermark
1298
+ * advances — strictly better than failing closed; a later hook retries.
1299
+ *
1300
+ * The invariant this preserves: a watermark's `agent_id` always equals the
1301
+ * owner of its `conversation_id`.
1302
+ *
1303
+ * @returns {Promise<{convId: string, agentId: string, source: string,
1304
+ * rebucketed: boolean, hold?: boolean}>}
1305
+ */
1306
+ async function ensureConversationOwner({
1307
+ convId,
1308
+ cachedOwner,
1309
+ resolvedAgentId,
1310
+ resolvedSource,
1311
+ sessionId,
1312
+ metadata = null,
1313
+ datapackId = null,
1314
+ mcpReady = false,
1315
+ }) {
1316
+ if (!ownerChanged(cachedOwner, resolvedAgentId, convId)) {
1317
+ return { convId, agentId: resolvedAgentId, source: resolvedSource, rebucketed: false };
1318
+ }
1319
+ try {
1320
+ if (!mcpReady) await mcpInitialize();
1321
+ const newId = await createConversation(
1322
+ sessionId || "unknown",
1323
+ resolvedAgentId,
1324
+ metadata,
1325
+ datapackId,
1326
+ );
1327
+ if (newId) {
1328
+ process.stderr.write(
1329
+ `[meko-capture] Rebucketed conversation ${convId} (owner ${cachedOwner || "unknown"}) -> ` +
1330
+ `new ${newId} owned by ${resolvedAgentId} (server pins owner at create).\n`,
1331
+ );
1332
+ return { convId: newId, agentId: resolvedAgentId, source: resolvedSource, rebucketed: true };
1333
+ }
1334
+ process.stderr.write("[meko-capture] Rebucket conversation_create returned no ID.\n");
1335
+ } catch (err) {
1336
+ process.stderr.write(`[meko-capture] Rebucket conversation_create failed (${err.message}).\n`);
1337
+ }
1338
+ // Create failed. An EXPLICIT override must never be captured under the stale
1339
+ // owner — hold and retry rather than write to the wrong namespace.
1340
+ if (resolvedSource === "explicit") {
1341
+ process.stderr.write(
1342
+ `[meko-capture] Explicit MEKO_AGENT_ID override requires rebucketing to ` +
1343
+ `${resolvedAgentId}, but conversation_create failed; HOLDING (no turns sent, ` +
1344
+ `watermark not advanced) so a later hook retries — will not capture under the ` +
1345
+ `stale owner ${cachedOwner || "unknown"}.\n`,
1346
+ );
1347
+ return { convId, agentId: resolvedAgentId, source: resolvedSource, rebucketed: false, hold: true };
1348
+ }
1349
+ // Derived case: fall back to the original owner so writes still succeed.
1350
+ // (When the owner is unknown/empty there is nothing safe to fall back to —
1351
+ // hold instead, since writing under an unknown owner risks agent_id_mismatch.)
1352
+ if (!(cachedOwner || "").trim()) {
1353
+ process.stderr.write(
1354
+ "[meko-capture] Unknown-owner conversation and rebucket create failed; " +
1355
+ "HOLDING (no turns sent) so a later hook retries.\n",
1356
+ );
1357
+ return { convId, agentId: resolvedAgentId, source: resolvedSource, rebucketed: false, hold: true };
1358
+ }
1359
+ process.stderr.write(
1360
+ `[meko-capture] Keeping original owner ${cachedOwner} so writes still succeed.\n`,
1361
+ );
1362
+ return { convId, agentId: cachedOwner, source: "derived", rebucketed: false };
1363
+ }
1364
+
702
1365
  async function addMessage(convId, agentId, exchange, datapackId = null) {
703
1366
  const seed = `${convId}:${exchange.user_uuid}`;
704
1367
  const payload = {
705
- scope: "write",
706
1368
  conversation_id: convId,
707
1369
  agent_id: agentId,
708
1370
  input: exchange.input,
@@ -711,6 +1373,12 @@ async function addMessage(convId, agentId, exchange, datapackId = null) {
711
1373
  seed: seed,
712
1374
  };
713
1375
  if (datapackId) payload.datapack_id = datapackId;
1376
+ if (exchange.metadata != null) {
1377
+ payload.metadata =
1378
+ typeof exchange.metadata === "string"
1379
+ ? exchange.metadata
1380
+ : JSON.stringify(exchange.metadata);
1381
+ }
714
1382
  const response = await mcpCall("conversation_add_message", payload);
715
1383
  // FAIL CLOSED. The MCP transport resolves successfully even when the tool
716
1384
  // failed, and failures take several shapes: an `isError` result envelope, a
@@ -734,6 +1402,19 @@ async function addMessage(convId, agentId, exchange, datapackId = null) {
734
1402
  if (result.error) {
735
1403
  throw new Error(`conversation_add_message failed: ${JSON.stringify(result.error)}`);
736
1404
  }
1405
+ // Contracts: advance only after a structurally valid accepted response that
1406
+ // contains a message ID (server returns message_id; tolerate `id` aliases).
1407
+ const messageId = result.message_id || result.id || null;
1408
+ if (!messageId) {
1409
+ throw new Error(
1410
+ "conversation_add_message: accepted response missing message_id (fail-closed)",
1411
+ );
1412
+ }
1413
+ if (result.status !== "accepted") {
1414
+ throw new Error(
1415
+ `conversation_add_message: unexpected status ${JSON.stringify(result.status)}`,
1416
+ );
1417
+ }
737
1418
  return result;
738
1419
  }
739
1420
 
@@ -745,14 +1426,20 @@ function hookOutput(additionalContext) {
745
1426
  // emitting it for lifecycle capture hooks makes Codex reject the JSON.
746
1427
  if (process.argv[2] !== "session-start") return "{}";
747
1428
 
748
- // Emit the native coding-agent harness field while preserving the nested
749
- // Claude-compatible shape for clients that still consume it.
1429
+ // Codex rejects unknown top-level fields, so its SessionStart response must
1430
+ // contain only the hook-specific output defined by the Codex wire schema.
750
1431
  const output = {
751
1432
  hookSpecificOutput: {
752
1433
  hookEventName: "SessionStart",
753
1434
  additionalContext: additionalContext || "",
754
1435
  },
755
1436
  };
1437
+ if (process.env.MEKO_HOOK_CLIENT === "codex") {
1438
+ return JSON.stringify(output);
1439
+ }
1440
+
1441
+ // Preserve the native coding-agent harness field for clients that consume
1442
+ // it alongside the nested Claude-compatible shape.
756
1443
  output.additional_context = additionalContext || "";
757
1444
  return JSON.stringify(output);
758
1445
  }
@@ -792,17 +1479,28 @@ function sessionCachePath(sessionId) {
792
1479
  function writeSessionCache(sessionId, payload) {
793
1480
  const filePath = sessionCachePath(sessionId);
794
1481
  if (!filePath) return;
795
- const workspaceKey = payload.workspace_key || "";
796
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
797
- const record = {
798
- session_id: sessionId,
799
- updated_at: new Date().toISOString(),
800
- ...payload,
801
- };
802
- fs.writeFileSync(filePath, JSON.stringify(record));
803
- if (workspaceKey) {
804
- const safe = workspaceKey.replace(/[^A-Za-z0-9_.-]/g, "_");
805
- 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
+ );
806
1504
  }
807
1505
  }
808
1506
 
@@ -819,8 +1517,6 @@ function workspaceKeyFromHookInput(hookInput, transcriptPath) {
819
1517
  return cwd || process.cwd();
820
1518
  }
821
1519
 
822
- // --- Main ---
823
-
824
1520
  // --- SessionStart: create conversation + watermark deterministically ---
825
1521
 
826
1522
  function findMostRecentProjectWatermark(transcriptPath, currentSessionId) {
@@ -841,6 +1537,7 @@ function findMostRecentProjectWatermark(transcriptPath, currentSessionId) {
841
1537
  sessionId: sid,
842
1538
  conversation_id: wm.conversation_id,
843
1539
  agent_id: wm.agent_id || "",
1540
+ agent_id_source: wm.agent_id_source || "",
844
1541
  datapack_id: wm.datapack_id || null,
845
1542
  datapack_name: wm.datapack_name || null,
846
1543
  updated_at: ts,
@@ -859,8 +1556,8 @@ function buildSessionStartContext(convId, sessionId, agentId, memories, opts) {
859
1556
  const resumed = Boolean(opts && opts.resumed);
860
1557
  const pin = opts && opts.datapackPin ? opts.datapackPin : null;
861
1558
  const opening = resumed
862
- ? `Resuming Meko conversation **${convId}** from a prior Claude Code session. The watermark file is written — PreCompact and SessionEnd hooks will append this session's new exchanges to the same conversation.`
863
- : `Meko conversation **${convId}** was created automatically by the SessionStart hook. The watermark file is written — PreCompact and SessionEnd hooks will capture this session's transcript verbatim.`;
1559
+ ? `Resuming Meko conversation **${convId}** from a prior Claude Code session. The watermark file is written — PreCompact and SessionEnd hooks will attempt to append this session's new exchanges to the same conversation.`
1560
+ : `Meko conversation **${convId}** was created automatically by the SessionStart hook. The watermark file is written — PreCompact and SessionEnd hooks are configured to capture this session's transcript.`;
864
1561
  const memoryBlock =
865
1562
  Array.isArray(memories) && memories.length > 0
866
1563
  ? `
@@ -881,11 +1578,10 @@ ${opening}
881
1578
 
882
1579
  - **agent_id**: use "${resolvedAgentId}" verbatim for every Meko MCP tool call in this session. This was derived from the cwd as \`<client>:<repo-basename>\` so memories stay scoped to this project. For genuinely cross-project facts (user identity, global preferences) pass agent_id="${COMMON_BUCKET_AGENT_ID}" — that's the common bucket any agent can read regardless of project.
883
1580
  - **conversation_id**: Use "${convId}" for all MCP tool calls that accept it
884
- - **scope**: Only "read", "write", or "admin"
885
1581
 
886
- ### Memory capture is automatic — do NOT proactively call memory_add
1582
+ ### Memory capture is automatic only when hook delivery succeeds — do NOT proactively call memory_add
887
1583
 
888
- This session's turns are captured to Meko by the hooks above, and the server extracts durable memories from them on its own. **Any fact the user states in conversation — their name, role, preferences, org conventions, corrections — is captured without you doing anything.** Do not call memory_add for facts the user says out loud; it duplicates what extraction already stores.
1584
+ The hooks above attempt to capture this session's turns, and the server extracts durable memories from successfully delivered turns on its own. Do not call memory_add for facts the user says out loud; when hook delivery succeeds, that would duplicate what extraction already stores. If the warning above is present, tell the user instead of pretending an explicit memory write will bypass the same server rejection.
889
1585
 
890
1586
  Call memory_add (with the session's agent_id + conversation_id) ONLY for the narrow cases extraction cannot reach:
891
1587
  - The user explicitly says "remember this" / "save this to memory".
@@ -913,7 +1609,6 @@ The SessionStart hook could not reach the Meko MCP server to create a conversati
913
1609
 
914
1610
  - **agent_id**: use "${resolvedAgentId}" for project-scoped writes; use "${COMMON_BUCKET_AGENT_ID}" for cross-project common facts
915
1611
  - **conversation_id**: Use nil UUID "00000000-0000-0000-0000-000000000000" as placeholder
916
- - **scope**: Only "read", "write", or "admin"
917
1612
 
918
1613
  ### Proactive storage (capture is down — do NOT wait to be asked)
919
1614
 
@@ -970,6 +1665,18 @@ function handleBeforeSubmitPrompt(hookInput) {
970
1665
  process.stdout.write(beforeSubmitOutput("allow"));
971
1666
  }
972
1667
 
1668
+ function withHealthNotice(context) {
1669
+ try {
1670
+ const { injectHealthNotice } = require("./capture-notices");
1671
+ return injectHealthNotice(context);
1672
+ } catch (err) {
1673
+ process.stderr.write(
1674
+ `[meko-capture] SessionStart health notice skipped: ${err && err.message ? err.message : err}\n`,
1675
+ );
1676
+ return context;
1677
+ }
1678
+ }
1679
+
973
1680
  async function handleSessionStart(hookInput) {
974
1681
  const transcriptPath =
975
1682
  hookInput.transcript_path ||
@@ -985,27 +1692,91 @@ async function handleSessionStart(hookInput) {
985
1692
  if (sessionId) {
986
1693
  const wmPath = path.join(WATERMARK_DIR, `${sessionId}.watermark.json`);
987
1694
  const existing = readWatermark(wmPath);
1695
+ if (existing.__corrupt) {
1696
+ process.stderr.write(
1697
+ `[meko-capture] SessionStart: corrupt watermark for ${sessionId} (${existing.error}); blocking.\n`,
1698
+ );
1699
+ const context = withHealthNotice(buildSessionStartFallbackContext(derivedAgentId, {
1700
+ datapackPin: readDatapackPin(derivedAgentId),
1701
+ }));
1702
+ process.stdout.write(hookOutput(context));
1703
+ return;
1704
+ }
988
1705
  if (existing.conversation_id) {
989
1706
  process.stderr.write(`[meko-capture] Session ${sessionId}: reusing existing conversation ${existing.conversation_id}\n`);
990
1707
  // Prefer the watermark's cached agent_id so a user-set bucket survives
991
- // re-fires of SessionStart (e.g. /clear, /compact). The exception is
992
- // the legacy literal "agent" left by pre-contract installers — we
993
- // override that with the freshly derived `claude_code:<repo>` so
994
- // upgraded users stop writing into the ghost namespace.
995
- const cached = (existing.agent_id || "").trim();
996
- 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
+ );
997
1720
  const existingDatapackPin = datapackPinFromWatermark(existing);
1721
+ // If the reconciled identity differs from the conversation's owner (the
1722
+ // cached agent_id), open a NEW conversation under it — the server pins
1723
+ // the owner at create and rejects mismatched writes, so keeping the old
1724
+ // conversation_id would wedge every future capture. ensureConversationOwner
1725
+ // is a no-op (no MCP) when the owner is unchanged.
1726
+ const owned = await ensureConversationOwner({
1727
+ convId: existing.conversation_id,
1728
+ cachedOwner: existing.agent_id,
1729
+ resolvedAgentId: reconciled.agentId,
1730
+ resolvedSource: reconciled.source,
1731
+ sessionId,
1732
+ metadata: peekTranscriptMetadata(transcriptPath),
1733
+ datapackId: existingDatapackPin && existingDatapackPin.datapack_id,
1734
+ });
1735
+ // HOLD: rebucket required but conversation_create failed. Leave the
1736
+ // existing watermark untouched (agent_id still equals the owner of its
1737
+ // conversation_id) and inject context under the actual owner so nothing
1738
+ // is written to the wrong namespace; a later hook retries the rebucket.
1739
+ if (owned.hold) {
1740
+ const holdContext = buildSessionStartContext(
1741
+ existing.conversation_id,
1742
+ sessionId,
1743
+ existing.agent_id,
1744
+ null,
1745
+ {
1746
+ datapackPin: existingDatapackPin,
1747
+ },
1748
+ );
1749
+ process.stdout.write(hookOutput(withHealthNotice(holdContext)));
1750
+ return;
1751
+ }
1752
+ const resumedAgentId = owned.agentId;
1753
+ const resumedConvId = owned.convId;
1754
+ // Persist the corrected identity + conversation (+ provenance) so the
1755
+ // checkpoint / PreCompact / SessionEnd hooks read a consistent watermark
1756
+ // (agent_id always equals the owner of conversation_id). On a rebucket
1757
+ // reset last_line_number to 0 so this session's turns replay into the
1758
+ // new conversation; otherwise preserve the prior line count.
1759
+ writeWatermark(
1760
+ wmPath,
1761
+ resumedConvId,
1762
+ owned.rebucketed ? 0 : (existing.last_line_number || 0),
1763
+ resumedAgentId,
1764
+ existingDatapackPin,
1765
+ owned.source,
1766
+ );
998
1767
  // Skip preload on resume (we don't know what's already in context).
999
- const context = buildSessionStartContext(
1000
- existing.conversation_id,
1768
+ let context = withHealthNotice(buildSessionStartContext(
1769
+ resumedConvId,
1001
1770
  sessionId,
1002
1771
  resumedAgentId,
1003
1772
  null,
1004
- { datapackPin: existingDatapackPin },
1005
- );
1773
+ {
1774
+ datapackPin: existingDatapackPin,
1775
+ },
1776
+ ));
1006
1777
  writeSessionCache(sessionId, {
1007
1778
  status: "ready",
1008
- conversation_id: existing.conversation_id,
1779
+ conversation_id: resumedConvId,
1009
1780
  agent_id: resumedAgentId,
1010
1781
  workspace_key: workspaceKey,
1011
1782
  additional_context: context,
@@ -1025,34 +1796,73 @@ async function handleSessionStart(hookInput) {
1025
1796
  if (prior && prior.conversation_id) {
1026
1797
  const currentLineCount = fs.existsSync(transcriptPath) ? countLines(transcriptPath) : 0;
1027
1798
  const wmPath = path.join(WATERMARK_DIR, `${sessionId}.watermark.json`);
1028
- // Preserve a non-legacy cached agent_id from the prior watermark so a
1029
- // user-set bucket (e.g. MEKO_AGENT_ID=foo, or a project-scoped
1030
- // claude_code:<other-repo> from the original session) survives the
1031
- // resume. Mirrors the same-session reuse branch above. The legacy
1032
- // literal "agent" is overridden with the freshly derived per-project
1033
- // bucket so upgraded users stop writing into the ghost namespace.
1034
- const priorAgentId = (prior.agent_id || "").trim();
1035
- const resumedAgentId =
1036
- 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
+ );
1037
1810
  const priorDatapackPin = datapackPinFromWatermark(prior);
1811
+ // Rebucket to a new conversation when the reconciled identity differs
1812
+ // from the prior conversation's owner (server pins owner at create).
1813
+ const owned = await ensureConversationOwner({
1814
+ convId: prior.conversation_id,
1815
+ cachedOwner: prior.agent_id,
1816
+ resolvedAgentId: reconciled.agentId,
1817
+ resolvedSource: reconciled.source,
1818
+ sessionId,
1819
+ metadata: peekTranscriptMetadata(transcriptPath),
1820
+ datapackId: priorDatapackPin && priorDatapackPin.datapack_id,
1821
+ });
1822
+ // HOLD: rebucket required but conversation_create failed. Do not write a
1823
+ // watermark for this resumed session (no owner we can safely write under)
1824
+ // — inject context under the prior owner and let a later hook retry.
1825
+ if (owned.hold) {
1826
+ const holdContext = buildSessionStartContext(
1827
+ prior.conversation_id,
1828
+ sessionId,
1829
+ prior.agent_id,
1830
+ null,
1831
+ {
1832
+ resumed: true,
1833
+ datapackPin: priorDatapackPin,
1834
+ },
1835
+ );
1836
+ process.stdout.write(hookOutput(withHealthNotice(holdContext)));
1837
+ return;
1838
+ }
1839
+ const resumedAgentId = owned.agentId;
1840
+ const resumedConvId = owned.convId;
1841
+ // On a fresh rebucketed conversation, start the watermark at 0 so this
1842
+ // session's turns replay into it. Otherwise keep currentLineCount so the
1843
+ // replayed copied history isn't re-ingested.
1038
1844
  writeWatermark(
1039
1845
  wmPath,
1040
- prior.conversation_id,
1041
- currentLineCount,
1846
+ resumedConvId,
1847
+ owned.rebucketed ? 0 : currentLineCount,
1042
1848
  resumedAgentId,
1043
1849
  priorDatapackPin,
1850
+ owned.source,
1044
1851
  );
1045
- process.stderr.write(`[meko-capture] Session ${sessionId}: resuming conversation ${prior.conversation_id} from ${prior.sessionId}, skipping ${currentLineCount} copied lines\n`);
1046
- const context = buildSessionStartContext(
1047
- 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,
1048
1855
  sessionId,
1049
1856
  resumedAgentId,
1050
1857
  null,
1051
- { resumed: true, datapackPin: priorDatapackPin },
1052
- );
1858
+ {
1859
+ resumed: true,
1860
+ datapackPin: priorDatapackPin,
1861
+ },
1862
+ ));
1053
1863
  writeSessionCache(sessionId, {
1054
1864
  status: "ready",
1055
- conversation_id: prior.conversation_id,
1865
+ conversation_id: resumedConvId,
1056
1866
  agent_id: resumedAgentId,
1057
1867
  workspace_key: workspaceKey,
1058
1868
  additional_context: context,
@@ -1065,8 +1875,29 @@ async function handleSessionStart(hookInput) {
1065
1875
 
1066
1876
  // Create conversation via MCP. Attach session metadata (cwd / gitBranch /
1067
1877
  // startedAt) from the transcript header so hook traces match the migrator.
1878
+ // Intent-first: durable outbox BEFORE any network call so create failures
1879
+ // leave a recoverable needs_conversation state (Capture V2).
1068
1880
  const transcriptMetadata = peekTranscriptMetadata(transcriptPath);
1069
1881
  const datapackPin = readDatapackPin(agentId);
1882
+ const agentIdSource = (process.env.MEKO_AGENT_ID || "").trim() ? "explicit" : "derived";
1883
+ if (sessionId) {
1884
+ const intent = captureState.writeSessionIntent(sessionId, {
1885
+ client: resolveHookClient() || "",
1886
+ agent_id: agentId,
1887
+ agent_id_source: agentIdSource,
1888
+ transcript_path: transcriptPath || null,
1889
+ datapack_id: datapackPin ? datapackPin.datapack_id : null,
1890
+ datapack_name: datapackPin ? datapackPin.datapack_name : null,
1891
+ });
1892
+ if (!intent.ok) {
1893
+ process.stderr.write(
1894
+ `[meko-capture] SessionStart: writeSessionIntent failed (${intent.failure_class}: ${intent.error}).\n`,
1895
+ );
1896
+ const context = withHealthNotice(buildSessionStartFallbackContext(agentId, { datapackPin }));
1897
+ process.stdout.write(hookOutput(context));
1898
+ return;
1899
+ }
1900
+ }
1070
1901
  let convId = null;
1071
1902
  try {
1072
1903
  await mcpInitialize();
@@ -1078,15 +1909,19 @@ async function handleSessionStart(hookInput) {
1078
1909
  );
1079
1910
  } catch (err) {
1080
1911
  process.stderr.write(`[meko-capture] SessionStart: MCP unavailable (${err.message}). Falling back to agent-driven setup.\n`);
1081
- const context = buildSessionStartFallbackContext(agentId, { datapackPin: readDatapackPin(agentId) });
1912
+ let context = withHealthNotice(buildSessionStartFallbackContext(agentId, { datapackPin: readDatapackPin(agentId) }));
1082
1913
  if (sessionId) {
1083
- writeSessionCache(sessionId, {
1084
- status: "error",
1085
- agent_id: agentId,
1086
- workspace_key: workspaceKey,
1087
- error: err && err.message ? String(err.message).slice(0, 240) : "MCP unavailable",
1088
- additional_context: context,
1089
- });
1914
+ try {
1915
+ writeSessionCache(sessionId, {
1916
+ status: "error",
1917
+ agent_id: agentId,
1918
+ workspace_key: workspaceKey,
1919
+ error: err && err.message ? String(err.message).slice(0, 240) : "MCP unavailable",
1920
+ additional_context: context,
1921
+ });
1922
+ } catch (cacheErr) {
1923
+ process.stderr.write(`[meko-capture] SessionStart cache write skipped: ${cacheErr.message}\n`);
1924
+ }
1090
1925
  }
1091
1926
  process.stdout.write(hookOutput(context));
1092
1927
  return;
@@ -1094,15 +1929,19 @@ async function handleSessionStart(hookInput) {
1094
1929
 
1095
1930
  if (!convId) {
1096
1931
  process.stderr.write("[meko-capture] SessionStart: conversation_create returned no ID.\n");
1097
- const context = buildSessionStartFallbackContext(agentId, { datapackPin: readDatapackPin(agentId) });
1932
+ let context = withHealthNotice(buildSessionStartFallbackContext(agentId, { datapackPin: readDatapackPin(agentId) }));
1098
1933
  if (sessionId) {
1099
- writeSessionCache(sessionId, {
1100
- status: "error",
1101
- agent_id: agentId,
1102
- workspace_key: workspaceKey,
1103
- error: "conversation_create returned no ID",
1104
- additional_context: context,
1105
- });
1934
+ try {
1935
+ writeSessionCache(sessionId, {
1936
+ status: "error",
1937
+ agent_id: agentId,
1938
+ workspace_key: workspaceKey,
1939
+ error: "conversation_create returned no ID",
1940
+ additional_context: context,
1941
+ });
1942
+ } catch (cacheErr) {
1943
+ process.stderr.write(`[meko-capture] SessionStart cache write skipped: ${cacheErr.message}\n`);
1944
+ }
1106
1945
  }
1107
1946
  process.stdout.write(hookOutput(context));
1108
1947
  return;
@@ -1121,48 +1960,665 @@ async function handleSessionStart(hookInput) {
1121
1960
 
1122
1961
  if (sessionId) {
1123
1962
  const wmPath = path.join(WATERMARK_DIR, `${sessionId}.watermark.json`);
1124
- writeWatermark(wmPath, convId, 0, agentId, datapackPin);
1963
+ writeWatermark(wmPath, convId, 0, agentId, datapackPin, agentIdSource);
1125
1964
  process.stderr.write(
1126
1965
  `[meko-capture] SessionStart: created conversation ${convId}, agent_id=${agentId}, preloaded ${memories.length} memories, watermark at ${wmPath}\n`,
1127
1966
  );
1128
1967
  }
1129
1968
 
1130
- const context = buildSessionStartContext(convId, sessionId, agentId, memories, {
1969
+ let context = withHealthNotice(buildSessionStartContext(convId, sessionId, agentId, memories, {
1131
1970
  datapackPin,
1132
- });
1971
+ }));
1133
1972
  if (sessionId) {
1134
- writeSessionCache(sessionId, {
1135
- status: "ready",
1136
- conversation_id: convId,
1137
- agent_id: agentId,
1138
- workspace_key: workspaceKey,
1139
- additional_context: context,
1140
- });
1973
+ try {
1974
+ writeSessionCache(sessionId, {
1975
+ status: "ready",
1976
+ conversation_id: convId,
1977
+ agent_id: agentId,
1978
+ workspace_key: workspaceKey,
1979
+ additional_context: context,
1980
+ });
1981
+ } catch (cacheErr) {
1982
+ process.stderr.write(`[meko-capture] SessionStart cache write skipped: ${cacheErr.message}\n`);
1983
+ }
1141
1984
  }
1142
1985
  process.stdout.write(hookOutput(context));
1143
1986
  }
1144
1987
 
1988
+ // --- Drain engine (Capture V2) ---
1989
+
1990
+ /**
1991
+ * Persist a post-accept cursor advance. Returns writeState result.
1992
+ * Used after structurally valid addMessage acceptance only.
1993
+ */
1994
+ function persistExchangeCheckpoint(sessionId, state, nextLine, extras) {
1995
+ const adv = captureState.advanceCursor(state, nextLine, { clearSeed: true });
1996
+ if (!adv.ok) return adv;
1997
+ const next = {
1998
+ ...adv.state,
1999
+ delivery: extras.delivery != null ? extras.delivery : "pending",
2000
+ queued_exchanges:
2001
+ extras.queued_exchanges != null
2002
+ ? extras.queued_exchanges
2003
+ : Math.max(0, (state.queued_exchanges || 0) - 1),
2004
+ attempt_count: 0,
2005
+ next_retry_at: null,
2006
+ failure_class: null,
2007
+ last_error: null,
2008
+ blocked_reason: null,
2009
+ last_success_at: new Date().toISOString(),
2010
+ in_flight: { seed: null, user_line: null, user_turn_id: null },
2011
+ };
2012
+ if (extras.lifecycle) next.lifecycle = extras.lifecycle;
2013
+ if (extras.agent_id != null) next.agent_id = extras.agent_id;
2014
+ if (extras.agent_id_source != null) next.agent_id_source = extras.agent_id_source;
2015
+ if (extras.conversation_id != null) next.conversation_id = extras.conversation_id;
2016
+ if (extras.datapack_id !== undefined) next.datapack_id = extras.datapack_id;
2017
+ if (extras.datapack_name !== undefined) next.datapack_name = extras.datapack_name;
2018
+ // Crash-injection hook for tests: server accepted, local checkpoint skipped.
2019
+ if (process.env.MEKO_CAPTURE_TEST_CRASH_AFTER_ACCEPT === "1") {
2020
+ return {
2021
+ ok: false,
2022
+ failure_class: "transient",
2023
+ error: "MEKO_CAPTURE_TEST_CRASH_AFTER_ACCEPT",
2024
+ state: next,
2025
+ crashed_after_accept: true,
2026
+ };
2027
+ }
2028
+ return captureState.writeState(sessionId, next);
2029
+ }
2030
+
2031
+ /**
2032
+ * Build a synthetic interrupted exchange for a trailing incomplete user turn.
2033
+ */
2034
+ function buildInterruptedExchange(turn, sessionId) {
2035
+ const input = extractText((turn.userMsg.message || {}).content) || "";
2036
+ return {
2037
+ user_uuid: turn.userMsg.uuid || "unknown",
2038
+ user_line: turn.userLine,
2039
+ next_line_number: null,
2040
+ input,
2041
+ output: INTERRUPTED_ASSISTANT_OUTPUT,
2042
+ reasoning: "",
2043
+ metadata: {
2044
+ capture_status: "interrupted",
2045
+ session_id: sessionId,
2046
+ user_line: turn.userLine,
2047
+ },
2048
+ timestamp: turn.userMsg.timestamp || "",
2049
+ };
2050
+ }
2051
+
2052
+ /**
2053
+ * Unified drain entry used by checkpoint, pre-compact, session-end, and CLI
2054
+ * recover/drain. Acquires a drain lease, delivers sequentially with batch
2055
+ * limits, and advances the cursor only after valid accepted message IDs.
2056
+ *
2057
+ * @param {object} options
2058
+ * @param {string} options.session_id
2059
+ * @param {string} [options.transcript_path]
2060
+ * @param {"checkpoint"|"pre-compact"|"session-end"|"drain"|"recover"} [options.mode]
2061
+ * @param {"active"|"closing"|"closed"} [options.lifecycle]
2062
+ * @param {boolean} [options.confirmed_dead]
2063
+ * @param {boolean} [options.recorded_owner_only]
2064
+ * @param {number} [options.max_exchanges]
2065
+ * @param {boolean} [options.force_retry] One-shot daemon-start override for
2066
+ * persistent failures only.
2067
+ * @returns {Promise<object>} DrainResult
2068
+ */
2069
+ async function drainSession(options) {
2070
+ const opts = options && typeof options === "object" ? options : {};
2071
+ const sessionId = String(opts.session_id || "").trim();
2072
+ if (!sessionId) {
2073
+ return makeDrainResult({
2074
+ status: "blocked",
2075
+ failure_class: "persistent",
2076
+ error: "session_id required",
2077
+ });
2078
+ }
2079
+
2080
+ const mode = opts.mode || "drain";
2081
+ const configuredBatch = captureState.maxCaptureBatchSize();
2082
+ const maxBatch =
2083
+ opts.max_exchanges != null
2084
+ ? Math.min(
2085
+ configuredBatch,
2086
+ Math.max(1, Number(opts.max_exchanges) || 1),
2087
+ )
2088
+ : configuredBatch;
2089
+ const recordedOwnerOnly = Boolean(opts.recorded_owner_only);
2090
+ const finalizeInterrupted =
2091
+ Boolean(opts.confirmed_dead) ||
2092
+ opts.lifecycle === "closing" ||
2093
+ opts.lifecycle === "closed" ||
2094
+ mode === "session-end";
2095
+
2096
+ const leaseAcq = captureState.acquireLease(sessionId);
2097
+ if (!leaseAcq.ok) {
2098
+ return makeDrainResult({
2099
+ status: "retry_wait",
2100
+ session_id: sessionId,
2101
+ failure_class: leaseAcq.failure_class || "ownership",
2102
+ error: leaseAcq.error || "lease held",
2103
+ delivery: "retry_wait",
2104
+ });
2105
+ }
2106
+ const leaseToken = leaseAcq.lease && leaseAcq.lease.token;
2107
+
2108
+ try {
2109
+ const loaded = captureState.readState(sessionId);
2110
+ if (!loaded.ok && !loaded.missing) {
2111
+ return makeDrainResult({
2112
+ status: "blocked",
2113
+ session_id: sessionId,
2114
+ failure_class: loaded.failure_class || "state_corrupt",
2115
+ error: loaded.error || "corrupt state",
2116
+ delivery: "blocked",
2117
+ });
2118
+ }
2119
+
2120
+ const stateExisted = Boolean(loaded.ok);
2121
+ const nowIso = new Date().toISOString();
2122
+ let state = loaded.ok
2123
+ ? { ...loaded.state }
2124
+ : {
2125
+ schema_version: 2,
2126
+ session_id: sessionId,
2127
+ client: "",
2128
+ agent_id: "",
2129
+ agent_id_source: "",
2130
+ transcript_path: null,
2131
+ datapack_id: null,
2132
+ datapack_name: null,
2133
+ created_at: nowIso,
2134
+ updated_at: nowIso,
2135
+ last_activity_at: nowIso,
2136
+ conversation_id: null,
2137
+ conversation_epoch: 0,
2138
+ last_line_number: 0,
2139
+ in_flight: { seed: null, user_line: null, user_turn_id: null },
2140
+ lifecycle: "active",
2141
+ delivery: "needs_conversation",
2142
+ queued_exchanges: 0,
2143
+ attempt_count: 0,
2144
+ next_retry_at: null,
2145
+ failure_class: null,
2146
+ last_error: null,
2147
+ last_success_at: null,
2148
+ blocked_reason: null,
2149
+ };
2150
+ const transcriptPath =
2151
+ opts.transcript_path ||
2152
+ state.transcript_path ||
2153
+ null;
2154
+
2155
+ if (opts.lifecycle && opts.lifecycle !== state.lifecycle) {
2156
+ state.lifecycle = opts.lifecycle;
2157
+ } else if (mode === "session-end" && state.lifecycle === "active") {
2158
+ state.lifecycle = "closing";
2159
+ }
2160
+ if (transcriptPath && !state.transcript_path) {
2161
+ state.transcript_path = transcriptPath;
2162
+ }
2163
+
2164
+ const retryAtMs = state.next_retry_at
2165
+ ? Date.parse(state.next_retry_at)
2166
+ : NaN;
2167
+ if (
2168
+ state.failure_class &&
2169
+ Number.isFinite(retryAtMs) &&
2170
+ retryAtMs > Date.now() &&
2171
+ !(Boolean(opts.force_retry) && state.failure_class === "persistent")
2172
+ ) {
2173
+ return makeDrainResult({
2174
+ status: "retry_wait",
2175
+ session_id: sessionId,
2176
+ queued_remaining: state.queued_exchanges || 0,
2177
+ failure_class: state.failure_class,
2178
+ error: state.last_error || "retry not due",
2179
+ last_line_number: state.last_line_number || 0,
2180
+ lifecycle: state.lifecycle,
2181
+ delivery: state.delivery,
2182
+ next_retry_at: state.next_retry_at,
2183
+ });
2184
+ }
2185
+
2186
+ if (!transcriptPath || !fs.existsSync(transcriptPath)) {
2187
+ if (stateExisted) captureState.writeState(sessionId, state);
2188
+ return makeDrainResult({
2189
+ status: "noop",
2190
+ session_id: sessionId,
2191
+ last_line_number: state.last_line_number || 0,
2192
+ lifecycle: state.lifecycle,
2193
+ delivery: state.delivery,
2194
+ error: "transcript missing",
2195
+ });
2196
+ }
2197
+
2198
+ let convId = state.conversation_id || "";
2199
+ let lastLine = state.last_line_number || 0;
2200
+ const currentLines = countLines(transcriptPath);
2201
+ const datapackPin = datapackPinFromWatermark({
2202
+ datapack_id: state.datapack_id,
2203
+ datapack_name: state.datapack_name,
2204
+ });
2205
+
2206
+ let effectiveAgentId = state.agent_id || "";
2207
+ let effectiveSource = state.agent_id_source || "";
2208
+
2209
+ if (!recordedOwnerOnly) {
2210
+ const reconciled = reconcileCachedAgentId(
2211
+ state.agent_id,
2212
+ state.agent_id_source || "",
2213
+ resolveSessionAgentId(transcriptPath),
2214
+ );
2215
+ effectiveAgentId = reconciled.agentId;
2216
+ effectiveSource = reconciled.source;
2217
+ } else if (!(state.agent_id || "").trim()) {
2218
+ return makeDrainResult({
2219
+ status: "noop",
2220
+ session_id: sessionId,
2221
+ last_line_number: lastLine,
2222
+ lifecycle: state.lifecycle,
2223
+ delivery: state.delivery,
2224
+ error: "recorded owner missing",
2225
+ });
2226
+ }
2227
+
2228
+ // Nothing-new early exit BEFORE network (preserve quiet skip for EOF hooks).
2229
+ if (convId && currentLines <= lastLine && !finalizeInterrupted) {
2230
+ if (
2231
+ stateExisted &&
2232
+ !recordedOwnerOnly &&
2233
+ effectiveAgentId !== state.agent_id
2234
+ ) {
2235
+ state.agent_id = effectiveAgentId;
2236
+ state.agent_id_source = effectiveSource;
2237
+ captureState.writeState(sessionId, state);
2238
+ }
2239
+ return makeDrainResult({
2240
+ status: "noop",
2241
+ session_id: sessionId,
2242
+ last_line_number: lastLine,
2243
+ lifecycle: state.lifecycle,
2244
+ delivery: state.delivery || "idle",
2245
+ });
2246
+ }
2247
+
2248
+ try {
2249
+ await mcpInitialize();
2250
+ if (!convId) {
2251
+ convId = await createConversation(
2252
+ sessionId,
2253
+ effectiveAgentId,
2254
+ peekTranscriptMetadata(transcriptPath),
2255
+ datapackPin && datapackPin.datapack_id,
2256
+ );
2257
+ if (!convId) {
2258
+ throw new Error("conversation_create returned no ID");
2259
+ }
2260
+ state.conversation_id = convId;
2261
+ state.delivery = currentLines > lastLine ? "pending" : "idle";
2262
+ state.failure_class = null;
2263
+ state.last_error = null;
2264
+ state.next_retry_at = null;
2265
+ const created = captureState.writeState(sessionId, state);
2266
+ if (!created.ok) {
2267
+ throw new Error(
2268
+ `failed to persist created conversation: ${created.error}`,
2269
+ );
2270
+ }
2271
+ }
2272
+ } catch (err) {
2273
+ const failureClass = captureState.classifyFailure(err);
2274
+ const pendingExchanges = extractExchanges(transcriptPath, lastLine).length;
2275
+ // Only persist failure onto an existing outbox — never invent a watermark
2276
+ // just because initialize failed (SessionStart owns intent creation).
2277
+ if (stateExisted) {
2278
+ state.delivery = failureClass === "transient" ? "retry_wait" : "blocked";
2279
+ state.failure_class = failureClass;
2280
+ state.last_error = err.message;
2281
+ state.attempt_count = (state.attempt_count || 0) + 1;
2282
+ state.next_retry_at = captureState.nextRetryAt({
2283
+ failure_class: failureClass,
2284
+ attempt_count: state.attempt_count,
2285
+ });
2286
+ state.queued_exchanges = pendingExchanges;
2287
+ captureState.writeState(sessionId, state);
2288
+ }
2289
+ process.stderr.write(
2290
+ `[meko-capture] Failed to initialize MCP session: ${err.message}\n`,
2291
+ );
2292
+ return makeDrainResult({
2293
+ status: failureClass === "transient" ? "retry_wait" : "blocked",
2294
+ session_id: sessionId,
2295
+ queued_remaining: pendingExchanges,
2296
+ failure_class: failureClass,
2297
+ error: err.message,
2298
+ last_line_number: lastLine,
2299
+ lifecycle: state.lifecycle,
2300
+ delivery: stateExisted ? state.delivery : "needs_conversation",
2301
+ });
2302
+ }
2303
+
2304
+ let extractFrom = lastLine;
2305
+ if (!recordedOwnerOnly) {
2306
+ const owned = await ensureConversationOwner({
2307
+ convId,
2308
+ cachedOwner: state.agent_id,
2309
+ resolvedAgentId: effectiveAgentId,
2310
+ resolvedSource: effectiveSource,
2311
+ sessionId,
2312
+ metadata: peekTranscriptMetadata(transcriptPath),
2313
+ datapackId: datapackPin && datapackPin.datapack_id,
2314
+ mcpReady: true,
2315
+ });
2316
+ if (owned.hold) {
2317
+ return makeDrainResult({
2318
+ status: "retry_wait",
2319
+ session_id: sessionId,
2320
+ failure_class: "ownership",
2321
+ error: "rebucket hold",
2322
+ last_line_number: lastLine,
2323
+ lifecycle: state.lifecycle,
2324
+ delivery: state.delivery,
2325
+ });
2326
+ }
2327
+ effectiveAgentId = owned.agentId;
2328
+ effectiveSource = owned.source;
2329
+ if (owned.rebucketed) {
2330
+ const rb = captureState.rebucket(state, owned.convId);
2331
+ if (!rb.ok) {
2332
+ return makeDrainResult({
2333
+ status: "blocked",
2334
+ session_id: sessionId,
2335
+ failure_class: rb.failure_class || "persistent",
2336
+ error: rb.error,
2337
+ last_line_number: lastLine,
2338
+ lifecycle: state.lifecycle,
2339
+ delivery: "blocked",
2340
+ });
2341
+ }
2342
+ state = rb.state;
2343
+ state.agent_id = effectiveAgentId;
2344
+ state.agent_id_source = effectiveSource;
2345
+ captureState.writeState(sessionId, state);
2346
+ convId = owned.convId;
2347
+ extractFrom = 0;
2348
+ lastLine = 0;
2349
+ } else {
2350
+ convId = owned.convId;
2351
+ state.agent_id = effectiveAgentId;
2352
+ state.agent_id_source = effectiveSource;
2353
+ }
2354
+ }
2355
+
2356
+ const entries = parseTranscriptEntries(transcriptPath, extractFrom);
2357
+ const built = buildExchanges(entries);
2358
+ let allExchanges = built.exchanges.slice();
2359
+ let pendingFromLine = built.pendingFromLine;
2360
+ const turns = built.turns || [];
2361
+ const turnLines = realUserTurnLines(entries);
2362
+ let safeLine = pendingFromLine !== null ? pendingFromLine : currentLines;
2363
+
2364
+ // Closing / dead: finalize trailing incomplete user turn exactly once.
2365
+ let synthesizedInterrupted = false;
2366
+ if (finalizeInterrupted && pendingFromLine !== null && turns.length > 0) {
2367
+ const trailing = turns[turns.length - 1];
2368
+ if (trailing && trailing.userLine === pendingFromLine) {
2369
+ allExchanges.push(buildInterruptedExchange(trailing, sessionId));
2370
+ synthesizedInterrupted = true;
2371
+ pendingFromLine = null;
2372
+ safeLine = currentLines;
2373
+ }
2374
+ }
2375
+
2376
+ if (allExchanges.length === 0) {
2377
+ if (pendingFromLine !== null) {
2378
+ process.stderr.write(
2379
+ `[meko-capture] ${mode}: session ${sessionId} produced zero ` +
2380
+ `exchanges but a trailing turn is still awaiting completion; holding ` +
2381
+ `watermark at line ${safeLine} so it is captured whole on a later run.\n`,
2382
+ );
2383
+ state.delivery = "pending";
2384
+ state.queued_exchanges = 1;
2385
+ } else {
2386
+ state.delivery =
2387
+ finalizeInterrupted || state.lifecycle === "closing"
2388
+ ? "complete"
2389
+ : "idle";
2390
+ if (finalizeInterrupted || state.lifecycle === "closing") {
2391
+ state.lifecycle = "closed";
2392
+ }
2393
+ state.queued_exchanges = 0;
2394
+ }
2395
+ state.agent_id = effectiveAgentId;
2396
+ state.agent_id_source = effectiveSource;
2397
+ state.conversation_id = convId;
2398
+ if (safeLine >= state.last_line_number) {
2399
+ const adv = captureState.advanceCursor(state, safeLine, { clearSeed: true });
2400
+ if (adv.ok) state = adv.state;
2401
+ }
2402
+ const written = captureState.writeState(sessionId, state);
2403
+ const s = written.ok ? written.state : state;
2404
+ return makeDrainResult({
2405
+ status: "ok",
2406
+ session_id: sessionId,
2407
+ captured: 0,
2408
+ queued_remaining: s.queued_exchanges || 0,
2409
+ cursor_advanced: (s.last_line_number || 0) > lastLine,
2410
+ last_line_number: s.last_line_number || 0,
2411
+ lifecycle: s.lifecycle,
2412
+ delivery: s.delivery,
2413
+ });
2414
+ }
2415
+
2416
+ const exchanges = allExchanges.slice(0, maxBatch);
2417
+ let captured = 0;
2418
+ let failed = 0;
2419
+ let cursorAdvanced = false;
2420
+ let watermarkLine = extractFrom;
2421
+ state.delivery = "draining";
2422
+ state.queued_exchanges = allExchanges.length;
2423
+ captureState.writeState(sessionId, state);
2424
+
2425
+ for (const exchange of exchanges) {
2426
+ const seed = `${convId}:${exchange.user_uuid}`;
2427
+ state.in_flight = {
2428
+ seed,
2429
+ user_line:
2430
+ typeof exchange.user_line === "number" ? exchange.user_line : null,
2431
+ user_turn_id: exchange.user_uuid || null,
2432
+ };
2433
+ captureState.writeState(sessionId, state);
2434
+
2435
+ try {
2436
+ await addMessage(
2437
+ convId,
2438
+ effectiveAgentId,
2439
+ exchange,
2440
+ datapackPin && datapackPin.datapack_id,
2441
+ );
2442
+ const boundary = boundaryAfterExchange(turnLines, exchange.user_line);
2443
+ const nextLine =
2444
+ boundary != null
2445
+ ? boundary
2446
+ : exchange.next_line_number != null
2447
+ ? exchange.next_line_number
2448
+ : safeLine;
2449
+ const remainingAfter = allExchanges.length - captured - 1;
2450
+ const ck = persistExchangeCheckpoint(sessionId, state, nextLine, {
2451
+ delivery:
2452
+ remainingAfter > 0 || allExchanges.length > maxBatch
2453
+ ? "pending"
2454
+ : "idle",
2455
+ queued_exchanges: Math.max(0, allExchanges.length - captured - 1),
2456
+ agent_id: effectiveAgentId,
2457
+ agent_id_source: effectiveSource,
2458
+ conversation_id: convId,
2459
+ datapack_id: datapackPin ? datapackPin.datapack_id : null,
2460
+ datapack_name: datapackPin ? datapackPin.datapack_name : null,
2461
+ });
2462
+ if (!ck.ok) {
2463
+ failed++;
2464
+ const failureClass = ck.failure_class || "transient";
2465
+ state.delivery = "retry_wait";
2466
+ state.failure_class = failureClass;
2467
+ state.last_error = ck.error;
2468
+ state.attempt_count = (state.attempt_count || 0) + 1;
2469
+ state.next_retry_at = captureState.nextRetryAt({
2470
+ failure_class: failureClass,
2471
+ attempt_count: state.attempt_count,
2472
+ });
2473
+ state.in_flight = {
2474
+ seed,
2475
+ user_line:
2476
+ typeof exchange.user_line === "number" ? exchange.user_line : null,
2477
+ user_turn_id: exchange.user_uuid || null,
2478
+ };
2479
+ captureState.writeState(sessionId, state);
2480
+ process.stderr.write(
2481
+ `[meko-capture] Accepted but checkpoint failed (uuid=${exchange.user_uuid}): ${ck.error}\n`,
2482
+ );
2483
+ break;
2484
+ }
2485
+ state = ck.state;
2486
+ watermarkLine = state.last_line_number;
2487
+ captured++;
2488
+ cursorAdvanced = true;
2489
+ } catch (err) {
2490
+ failed++;
2491
+ const persistent = classifyPersistentCaptureFailure(err);
2492
+ const failureClass = persistent
2493
+ ? "persistent"
2494
+ : captureState.classifyFailure(err);
2495
+ state.delivery =
2496
+ failureClass === "transient" ? "retry_wait" : "blocked";
2497
+ state.failure_class = failureClass;
2498
+ state.last_error = err.message;
2499
+ state.attempt_count = (state.attempt_count || 0) + 1;
2500
+ state.next_retry_at = captureState.nextRetryAt({
2501
+ failure_class: failureClass,
2502
+ attempt_count: state.attempt_count,
2503
+ });
2504
+ state.queued_exchanges = allExchanges.length - captured;
2505
+ captureState.writeState(sessionId, state);
2506
+ process.stderr.write(
2507
+ `[meko-capture] Failed to add message (uuid=${exchange.user_uuid}): ${err.message}\n`,
2508
+ );
2509
+ break;
2510
+ }
2511
+ }
2512
+
2513
+ const held = allExchanges.length - captured;
2514
+ if (failed === 0 && allExchanges.length <= maxBatch && pendingFromLine === null) {
2515
+ watermarkLine = safeLine;
2516
+ if (watermarkLine >= state.last_line_number) {
2517
+ const adv = captureState.advanceCursor(state, watermarkLine, {
2518
+ clearSeed: true,
2519
+ });
2520
+ if (adv.ok) state = adv.state;
2521
+ }
2522
+ if (finalizeInterrupted || state.lifecycle === "closing") {
2523
+ state.lifecycle = "closed";
2524
+ state.delivery = "complete";
2525
+ state.queued_exchanges = 0;
2526
+ } else {
2527
+ state.delivery = "idle";
2528
+ state.queued_exchanges = 0;
2529
+ }
2530
+ captureState.writeState(sessionId, state);
2531
+ cursorAdvanced = captured > 0 || cursorAdvanced || synthesizedInterrupted;
2532
+ } else if (failed === 0 && allExchanges.length > maxBatch) {
2533
+ state.delivery = "pending";
2534
+ state.queued_exchanges = held;
2535
+ captureState.writeState(sessionId, state);
2536
+ process.stderr.write(
2537
+ `[meko-capture] captured batch of ${captured}/${allExchanges.length} exchange(s); ` +
2538
+ `checkpointed through line ${watermarkLine}.\n`,
2539
+ );
2540
+ } else if (failed > 0) {
2541
+ process.stderr.write(
2542
+ `[meko-capture] capture stopped at first failure; ` +
2543
+ `holding watermark at line ${state.last_line_number} for retry.\n`,
2544
+ );
2545
+ }
2546
+
2547
+ const finalState = captureState.readState(sessionId);
2548
+ const s = finalState.ok ? finalState.state : state;
2549
+ let status = "ok";
2550
+ if (failed > 0) {
2551
+ status =
2552
+ s.failure_class === "transient" || s.delivery === "retry_wait"
2553
+ ? "retry_wait"
2554
+ : s.delivery === "blocked"
2555
+ ? "blocked"
2556
+ : "partial";
2557
+ } else if (held > 0) {
2558
+ status = "partial";
2559
+ }
2560
+
2561
+ return makeDrainResult({
2562
+ status,
2563
+ session_id: sessionId,
2564
+ captured,
2565
+ queued_remaining: held,
2566
+ failure_class: failed > 0 ? s.failure_class || null : null,
2567
+ error: failed > 0 ? s.last_error || null : null,
2568
+ cursor_advanced: cursorAdvanced,
2569
+ last_line_number: s.last_line_number || watermarkLine,
2570
+ lifecycle: s.lifecycle,
2571
+ delivery: s.delivery,
2572
+ });
2573
+ } finally {
2574
+ if (leaseToken) captureState.releaseLease(sessionId, leaseToken);
2575
+ }
2576
+ }
2577
+
1145
2578
  // --- Main ---
1146
2579
 
1147
2580
  async function main() {
1148
2581
  const hookType = process.argv[2];
1149
- if (!hookType || !["session-start", "before-submit-prompt", "pre-compact", "session-end", "checkpoint"].includes(hookType)) {
1150
- 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
+ );
1151
2595
  process.exit(1);
1152
2596
  }
1153
2597
 
1154
- // Read hook input from stdin
1155
2598
  let hookInput;
1156
2599
  try {
1157
2600
  const stdin = fs.readFileSync(0, "utf-8");
1158
- hookInput = JSON.parse(stdin);
2601
+ hookInput = JSON.parse(stdin || "{}");
1159
2602
  } catch (err) {
1160
- process.stderr.write(`[meko-capture] Failed to parse hook input from stdin: ${err.message}\n`);
1161
- 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
+ }
1162
2619
  return;
1163
2620
  }
1164
2621
 
1165
- // SessionStart: create conversation + watermark, output context
1166
2622
  if (hookType === "session-start") {
1167
2623
  await handleSessionStart(hookInput);
1168
2624
  return;
@@ -1173,106 +2629,78 @@ async function main() {
1173
2629
  return;
1174
2630
  }
1175
2631
 
1176
- // PreCompact / SessionEnd: capture transcript exchanges
1177
2632
  const transcriptPath =
1178
2633
  hookInput.transcript_path ||
1179
- (hookInput.hookSpecificInput || {}).transcript_path;
2634
+ (hookInput.hookSpecificInput || {}).transcript_path ||
2635
+ null;
2636
+ const sessionId =
2637
+ (hookInput.session_id && String(hookInput.session_id)) ||
2638
+ (transcriptPath ? path.basename(transcriptPath, ".jsonl") : "") ||
2639
+ extractSessionId(hookInput, transcriptPath);
2640
+
2641
+ const drainOpts = {
2642
+ session_id: sessionId,
2643
+ transcript_path: transcriptPath,
2644
+ mode: hookType === "recover" ? "drain" : hookType,
2645
+ lifecycle: hookInput.lifecycle,
2646
+ confirmed_dead: Boolean(hookInput.confirmed_dead),
2647
+ recorded_owner_only: Boolean(hookInput.recorded_owner_only),
2648
+ max_exchanges:
2649
+ hookInput.max_exchanges != null
2650
+ ? Number(hookInput.max_exchanges)
2651
+ : undefined,
2652
+ force_retry: Boolean(hookInput.force_retry),
2653
+ };
1180
2654
 
1181
- if (!transcriptPath || !fs.existsSync(transcriptPath)) {
1182
- process.stdout.write(hookOutput(""));
1183
- 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;
1184
2665
  }
1185
2666
 
1186
- const sessionId = path.basename(transcriptPath, ".jsonl");
1187
- const wmPath = path.join(WATERMARK_DIR, `${sessionId}.watermark.json`);
1188
- const watermark = readWatermark(wmPath);
1189
- let convId = watermark.conversation_id || "";
1190
- const lastLine = watermark.last_line_number || 0;
1191
- const currentLines = countLines(transcriptPath);
1192
-
1193
- // Resolve agent_id for this hook invocation. Prefer the value the
1194
- // SessionStart hook persisted in the watermark; if it's missing or a
1195
- // legacy literal ("agent"), fall back to a fresh derivation from the
1196
- // transcript's cwd so writes land in the right bucket.
1197
- const cachedAgentId = (watermark.agent_id || "").trim();
1198
- const agentId = cachedAgentId && cachedAgentId !== "agent"
1199
- ? cachedAgentId
1200
- : resolveSessionAgentId(transcriptPath);
1201
- const datapackPin = datapackPinFromWatermark(watermark);
1202
-
1203
- // Nothing new
1204
- if (currentLines <= lastLine) {
1205
- process.stdout.write(hookOutput(""));
2667
+ if (hookType === "drain" || hookType === "recover") {
2668
+ const result = await drainSession(drainOpts);
2669
+ process.stdout.write(JSON.stringify(result) + "\n");
1206
2670
  return;
1207
2671
  }
1208
2672
 
1209
- // Initialize MCP session
1210
- try {
1211
- await mcpInitialize();
1212
- } catch (err) {
1213
- process.stderr.write(`[meko-capture] Failed to initialize MCP session: ${err.message}\n`);
2673
+ if (!transcriptPath || !fs.existsSync(transcriptPath)) {
1214
2674
  process.stdout.write(hookOutput(""));
1215
2675
  return;
1216
2676
  }
1217
-
1218
- // Only SessionStart creates conversations. All other modes require
1219
- // an existing watermark. Skip gracefully if missing.
1220
- if (!convId) {
1221
- process.stderr.write(
1222
- `[meko-capture] ${hookType}: No conversation in watermark for session ${sessionId}. ` +
1223
- `SessionStart hook may not have run yet.\n`
1224
- );
1225
- process.stdout.write(hookOutput(""));
1226
- return;
2677
+ if (hookType === "session-end") {
2678
+ drainOpts.lifecycle = "closing";
1227
2679
  }
1228
2680
 
1229
- // Extract and send exchanges
1230
- const exchanges = extractExchanges(transcriptPath, lastLine);
1231
- let captured = 0;
1232
- let failed = 0;
1233
-
1234
- for (const exchange of exchanges) {
1235
- try {
1236
- await addMessage(
1237
- convId,
1238
- agentId,
1239
- exchange,
1240
- datapackPin && datapackPin.datapack_id,
1241
- );
1242
- captured++;
1243
- } catch (err) {
1244
- failed++;
1245
- process.stderr.write(`[meko-capture] Failed to add message (uuid=${exchange.user_uuid}): ${err.message}\n`);
1246
- }
1247
- }
1248
-
1249
- // Advance the watermark to currentLines ONLY when every exchange was
1250
- // captured. If any addMessage failed (typically a transient network error),
1251
- // keep the watermark at lastLine so the next checkpoint / PreCompact /
1252
- // SessionEnd hook re-extracts and retries this batch. Re-sending the
1253
- // exchanges that already succeeded is safe: capture dedups by seed
1254
- // (<conv_id>:<user_uuid>), so retries never create duplicates. Advancing on
1255
- // partial failure is what would permanently drop the failed exchanges.
1256
- // We still always persist the resolved agent_id (migrating any legacy literal
1257
- // value) so later hooks see the derived bucket directly.
1258
- const watermarkLine = failed === 0 ? currentLines : lastLine;
1259
- if (failed > 0) {
1260
- process.stderr.write(
1261
- `[meko-capture] ${failed}/${exchanges.length} exchange(s) failed; ` +
1262
- `holding watermark at line ${lastLine} for retry (dedup makes resends safe).\n`
1263
- );
1264
- }
1265
- writeWatermark(wmPath, convId, watermarkLine, agentId, datapackPin);
1266
-
1267
- // Output
2681
+ const result = await drainSession(drainOpts);
1268
2682
  const context =
1269
- hookType === "pre-compact"
1270
- ? `Pre-compact: captured ${captured} exchanges to Meko conversation ${convId}`
2683
+ hookType === "pre-compact" && result.captured > 0
2684
+ ? `Pre-compact: captured ${result.captured} exchanges to Meko conversation`
1271
2685
  : "";
1272
2686
  process.stdout.write(hookOutput(context));
1273
2687
  }
1274
2688
 
1275
- main().catch((err) => {
1276
- process.stderr.write(`[meko-capture] Fatal error: ${err.message}\n`);
1277
- process.stdout.write(hookOutput(""));
1278
- });
2689
+ module.exports = {
2690
+ deriveAgentId,
2691
+ drainSession,
2692
+ makeDrainResult,
2693
+ boundaryAfterExchange,
2694
+ realUserTurnLines,
2695
+ INTERRUPTED_ASSISTANT_OUTPUT,
2696
+ buildExchanges,
2697
+ extractExchanges,
2698
+ addMessage,
2699
+ };
2700
+
2701
+ if (require.main === module) {
2702
+ main().catch((err) => {
2703
+ process.stderr.write(`[meko-capture] Fatal error: ${err.message}\n`);
2704
+ process.stdout.write(hookOutput(""));
2705
+ });
2706
+ }