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