@yugabytedb/meko-mcp 2.1.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +233 -40
- 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-cleanup-cli.js +1473 -0
- package/assets/hooks-handlers/lib/capture-notices.js +438 -0
- package/assets/hooks-handlers/lib/capture-state.js +2173 -0
- package/assets/hooks-handlers/lib/capture-status-cli.js +86 -0
- package/assets/hooks-handlers/lib/capture.js +2782 -403
- package/assets/hooks-handlers/lib/checkpoint-timer.js +485 -81
- package/assets/hooks-handlers/lib/journal-bridge.js +218 -0
- package/assets/hooks-handlers/lib/kiro-handler.js +760 -0
- package/assets/hooks-handlers/lib/opencode-handler.js +620 -0
- package/assets/hooks-handlers/lib/opencode-normalizer.js +145 -0
- package/assets/hooks-handlers/lib/terminal-handoff.js +427 -0
- package/assets/hooks-handlers/session-end.sh +3 -34
- package/assets/opencode-plugin/meko.js +213 -0
- package/assets/scripts/meko-statusline.sh +123 -16
- package/assets/skills/meko-mcp-tools/SKILL.md +116 -126
- package/assets/skills/meko-mcp-tools/references/_sections.md +3 -3
- package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +25 -18
- package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +99 -24
- package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +1 -1
- package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +53 -6
- package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +25 -79
- package/assets/skills/meko-mcp-tools/references/tools-overview.md +37 -26
- package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +14 -8
- package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +66 -1
- package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +7 -40
- package/assets/skills/meko-mcp-tools-desktop/SKILL.md +56 -20
- 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 +15 -8
- package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +99 -36
- package/assets/skills/meko-mcp-tools-desktop/references/tools-known-limitations.md +22 -5
- package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +18 -14
- package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +39 -28
- package/assets/skills/meko-mcp-tools-desktop/references/tools-rag-workflow.md +14 -8
- package/assets/skills/meko-mcp-tools-desktop/references/tools-troubleshooting.md +67 -1
- package/assets/skills/meko-select-datapack-desktop/SKILL.md +4 -4
- package/assets/skills/meko-select-datapack-desktop/references/cookbook.md +8 -8
- package/assets/skills/meko-select-datapack-desktop/references/role-display-future.md +7 -5
- package/assets/skills/meko-select-datapack-desktop/references/troubleshooting.md +4 -4
- package/bin/create-meko-setup.mjs +113 -12
- package/lib/capture-cleanup.mjs +63 -0
- package/lib/capture-status.mjs +67 -0
- package/lib/clients/aliases.mjs +5 -0
- package/lib/clients/claude-code.mjs +1 -0
- package/lib/clients/claude-desktop.mjs +120 -5
- package/lib/clients/codex.mjs +85 -37
- package/lib/clients/common.mjs +7 -0
- package/lib/clients/index.mjs +4 -0
- package/lib/clients/kiro.mjs +705 -0
- package/lib/clients/opencode.mjs +532 -0
- package/lib/config.mjs +158 -1
- package/lib/installer.mjs +18 -1
- package/lib/migrate/adapters/claude-code.mjs +11 -2
- package/lib/migrate/adapters/codex.mjs +497 -0
- package/lib/migrate/adapters/memory-md.mjs +27 -8
- package/lib/migrate/adapters/opencode.mjs +148 -0
- package/lib/migrate/claude-parser.mjs +153 -9
- package/lib/migrate/id.mjs +217 -10
- package/lib/migrate/mcp-client.mjs +25 -1
- package/lib/migrate/opencode-normalizer.mjs +132 -0
- package/lib/migrate/orchestrator.mjs +299 -56
- package/lib/migrate/progress.mjs +239 -5
- package/lib/migrate/registry.mjs +72 -0
- package/lib/migrate.mjs +100 -10
- package/lib/prompt.mjs +16 -1
- package/package.json +4 -4
- package/scripts/bundle-assets.mjs +4 -0
|
@@ -1,23 +1,46 @@
|
|
|
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)
|
|
15
30
|
* MEKO_AGENT_ID Agent identifier override; if unset, derived from the
|
|
16
|
-
* session's cwd
|
|
17
|
-
*
|
|
31
|
+
* session's cwd by walking parents for `.git` and taking
|
|
32
|
+
* the repo root's basename, as `claude_code:<repo>` (or
|
|
33
|
+
* the bare client name if the session is outside a repo,
|
|
34
|
+
* so sibling subdirectories in unrelated repos can't
|
|
35
|
+
* silently collide). See deriveAgentId().
|
|
18
36
|
* MEKO_API_KEY API key for Cloud Meko auth (optional, omit for local)
|
|
19
37
|
* MEKO_API_TIMEOUT Request timeout in seconds (default: 10)
|
|
38
|
+
* MEKO_ADD_MESSAGE_TIMEOUT Timeout for conversation_add_message in
|
|
39
|
+
* seconds (default: 60). Storing a large turn can take
|
|
40
|
+
* the server more than 15s. PreCompact runs in the
|
|
41
|
+
* foreground and keeps MEKO_API_TIMEOUT instead.
|
|
20
42
|
* MEKO_WATERMARK_DIR Watermark directory (default: ~/.claude/meko-capture)
|
|
43
|
+
* MEKO_MAX_CAPTURE_BATCH_SIZE Max exchanges per drain (default 5, min 1)
|
|
21
44
|
*/
|
|
22
45
|
|
|
23
46
|
const fs = require("fs");
|
|
@@ -25,6 +48,7 @@ const os = require("os");
|
|
|
25
48
|
const path = require("path");
|
|
26
49
|
const http = require("http");
|
|
27
50
|
const https = require("https");
|
|
51
|
+
const captureState = require("./capture-state");
|
|
28
52
|
|
|
29
53
|
// --- Config ---
|
|
30
54
|
|
|
@@ -35,16 +59,73 @@ const MEKO_MCP_URL = process.env.MEKO_MCP_URL || "http://localhost:8000/mcp";
|
|
|
35
59
|
const COMMON_BUCKET_AGENT_ID = "meko_agent";
|
|
36
60
|
const AGENT_ID_MAX_LEN = 64;
|
|
37
61
|
|
|
62
|
+
/**
|
|
63
|
+
* The user's home directory, read fresh so a re-pointed `$HOME` (tests,
|
|
64
|
+
* sandboxes) is honored. Mirror of `homeBoundary` in
|
|
65
|
+
* installer/lib/migrate/id.mjs.
|
|
66
|
+
*/
|
|
67
|
+
function homeBoundary() {
|
|
68
|
+
const fromEnv = (process.env.HOME || "").trim();
|
|
69
|
+
if (fromEnv) return path.resolve(fromEnv);
|
|
70
|
+
try {
|
|
71
|
+
const h = os.homedir();
|
|
72
|
+
return h ? path.resolve(h) : null;
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Walk parents of `cwd` looking for a `.git` entry (directory for a normal
|
|
80
|
+
* checkout, file for a submodule / linked worktree). Returns the absolute
|
|
81
|
+
* repo-root path, or `null` if none is found. Mirror of `resolveRepoRoot`
|
|
82
|
+
* in installer/lib/migrate/id.mjs.
|
|
83
|
+
*
|
|
84
|
+
* The walk stops at `$HOME` (from below only, so a git-tracked home is still
|
|
85
|
+
* its own repo root): without that bound, dotfiles-under-git would make every
|
|
86
|
+
* non-repo directory under home derive `claude_code:<username>`.
|
|
87
|
+
*/
|
|
88
|
+
function resolveRepoRoot(cwd) {
|
|
89
|
+
if (!cwd || typeof cwd !== "string") return null;
|
|
90
|
+
let dir;
|
|
91
|
+
try {
|
|
92
|
+
dir = path.resolve(cwd);
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
const start = dir;
|
|
97
|
+
const home = homeBoundary();
|
|
98
|
+
let prev = null;
|
|
99
|
+
while (dir && dir !== prev) {
|
|
100
|
+
// A directory below a git-tracked home must not inherit home's `.git`.
|
|
101
|
+
if (home && dir === home && dir !== start) return null;
|
|
102
|
+
try {
|
|
103
|
+
fs.statSync(path.join(dir, ".git"));
|
|
104
|
+
return dir;
|
|
105
|
+
} catch {
|
|
106
|
+
// keep walking
|
|
107
|
+
}
|
|
108
|
+
// Check home itself for `.git`, then stop. A non-git home must not
|
|
109
|
+
// inherit repository state from /Users, /home, or /.
|
|
110
|
+
if (home && dir === home) return null;
|
|
111
|
+
prev = dir;
|
|
112
|
+
dir = path.dirname(dir);
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
38
117
|
/**
|
|
39
118
|
* Derive an `agent_id` for a Meko write. Mirror of `deriveAgentId` in
|
|
40
119
|
* installer/lib/migrate/id.mjs — kept inline because capture.js is published
|
|
41
120
|
* in a separate package with no module dependencies on the installer.
|
|
42
121
|
*
|
|
43
122
|
* - `envOverride` (typically MEKO_AGENT_ID) wins if non-empty.
|
|
44
|
-
* - Coding clients (claude_code, cursor) →
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
123
|
+
* - Coding clients (claude_code, cursor, codex, kiro, opencode) →
|
|
124
|
+
* `<client>:<repo-basename>`, where the basename comes from the
|
|
125
|
+
* nearest ancestor of `cwd` that contains `.git`. Lowercased,
|
|
126
|
+
* non-`[a-z0-9-]` runs collapse to `-`. Outside a repo → bare
|
|
127
|
+
* client name (keeps sibling `docs`/`src`/`webapp` directories in
|
|
128
|
+
* unrelated repos from collapsing into one agent_id — MEKO-590).
|
|
48
129
|
* - Loose clients (claude-desktop) → `claude_desktop`.
|
|
49
130
|
* - Anything else → `meko_agent` (common cross-project bucket).
|
|
50
131
|
*/
|
|
@@ -53,10 +134,20 @@ function deriveAgentId(opts) {
|
|
|
53
134
|
if (override) return override;
|
|
54
135
|
const client = (opts && opts.client ? String(opts.client) : "").trim();
|
|
55
136
|
if (client === "claude-desktop" || client === "claude_desktop") return "claude_desktop";
|
|
56
|
-
if (
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
137
|
+
if (
|
|
138
|
+
client !== "claude_code" &&
|
|
139
|
+
client !== "cursor" &&
|
|
140
|
+
client !== "codex" &&
|
|
141
|
+
client !== "kiro" &&
|
|
142
|
+
client !== "opencode"
|
|
143
|
+
) {
|
|
144
|
+
return COMMON_BUCKET_AGENT_ID;
|
|
145
|
+
}
|
|
146
|
+
const repoRoot = opts && Object.prototype.hasOwnProperty.call(opts, "repoRoot")
|
|
147
|
+
? opts.repoRoot
|
|
148
|
+
: resolveRepoRoot(opts && opts.cwd);
|
|
149
|
+
if (!repoRoot) return client;
|
|
150
|
+
const project = path.basename(repoRoot).toLowerCase()
|
|
60
151
|
.replace(/[^a-z0-9-]+/g, "-")
|
|
61
152
|
.replace(/^-+|-+$/g, "")
|
|
62
153
|
.slice(0, AGENT_ID_MAX_LEN);
|
|
@@ -64,6 +155,45 @@ function deriveAgentId(opts) {
|
|
|
64
155
|
return `${client}:${project}`;
|
|
65
156
|
}
|
|
66
157
|
|
|
158
|
+
/**
|
|
159
|
+
* Pre-MEKO-590 coding-agent derivation. Existing pin filenames and session
|
|
160
|
+
* state may still use this cwd-leaf id, so it remains a read-only alias while
|
|
161
|
+
* new sessions use the repo-root id from `deriveAgentId`.
|
|
162
|
+
*/
|
|
163
|
+
function deriveLegacyAgentId(opts) {
|
|
164
|
+
const envOverride = opts && typeof opts.envOverride === "string"
|
|
165
|
+
? opts.envOverride.trim()
|
|
166
|
+
: "";
|
|
167
|
+
if (envOverride) return envOverride;
|
|
168
|
+
const client = opts && opts.client ? String(opts.client).trim() : "";
|
|
169
|
+
if (client === "claude-desktop" || client === "claude_desktop") {
|
|
170
|
+
return "claude_desktop";
|
|
171
|
+
}
|
|
172
|
+
if (!["claude_code", "cursor", "codex", "kiro"].includes(client)) {
|
|
173
|
+
return COMMON_BUCKET_AGENT_ID;
|
|
174
|
+
}
|
|
175
|
+
const rawBase = opts && opts.cwd ? path.basename(opts.cwd) : "";
|
|
176
|
+
const project = rawBase.toLowerCase()
|
|
177
|
+
.replace(/[^a-z0-9-]+/g, "-")
|
|
178
|
+
.replace(/^-+|-+$/g, "")
|
|
179
|
+
.slice(0, AGENT_ID_MAX_LEN);
|
|
180
|
+
return project ? `${client}:${project}` : client;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Map MEKO_HOOK_CLIENT to a known coding-client identity. The installer
|
|
185
|
+
* prefixes each client's hook commands with `MEKO_HOOK_CLIENT=<client>`;
|
|
186
|
+
* cursor and codex are first-class coding clients, everything else (including
|
|
187
|
+
* an unset value) defaults to claude_code. Keeping this in one place means
|
|
188
|
+
* every call path — SessionStart, watermark write, PreCompact/SessionEnd
|
|
189
|
+
* re-derivation — resolves the same identity for a given client.
|
|
190
|
+
*/
|
|
191
|
+
function resolveHookClient() {
|
|
192
|
+
const client = (process.env.MEKO_HOOK_CLIENT || "").trim();
|
|
193
|
+
if (client === "cursor" || client === "codex" || client === "kiro" || client === "opencode") return client;
|
|
194
|
+
return "claude_code";
|
|
195
|
+
}
|
|
196
|
+
|
|
67
197
|
/**
|
|
68
198
|
* Resolve the agent_id for the current session. Frozen at SessionStart;
|
|
69
199
|
* subsequent hook invocations re-derive from the watermark + env, not
|
|
@@ -76,9 +206,7 @@ function deriveAgentId(opts) {
|
|
|
76
206
|
* 4. cwd from the hook input.
|
|
77
207
|
* 5. process.cwd() — last resort if no transcript metadata yet.
|
|
78
208
|
*/
|
|
79
|
-
function
|
|
80
|
-
const envOverride = process.env.MEKO_AGENT_ID;
|
|
81
|
-
if (envOverride && envOverride.trim()) return envOverride.trim();
|
|
209
|
+
function pickSessionCwd(transcriptPath, hookInput) {
|
|
82
210
|
const meta = transcriptPath ? peekTranscriptMetadata(transcriptPath) : null;
|
|
83
211
|
const nested = hookInput && hookInput.hookSpecificInput && typeof hookInput.hookSpecificInput === "object"
|
|
84
212
|
? hookInput.hookSpecificInput
|
|
@@ -89,7 +217,7 @@ function resolveSessionAgentId(transcriptPath, hookInput = null) {
|
|
|
89
217
|
const nestedRoots = Array.isArray(nested.workspace_roots) ? nested.workspace_roots : [];
|
|
90
218
|
const workspaceRoot = [...roots, ...nestedRoots]
|
|
91
219
|
.find((item) => typeof item === "string" && item.trim());
|
|
92
|
-
|
|
220
|
+
return (
|
|
93
221
|
(meta && meta.cwd) ||
|
|
94
222
|
hookInput?.workspace_path ||
|
|
95
223
|
hookInput?.workspacePath ||
|
|
@@ -100,12 +228,200 @@ function resolveSessionAgentId(transcriptPath, hookInput = null) {
|
|
|
100
228
|
workspaceRoot ||
|
|
101
229
|
hookInput?.cwd ||
|
|
102
230
|
nested.cwd ||
|
|
103
|
-
process.cwd()
|
|
104
|
-
|
|
231
|
+
process.cwd()
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function resolveSessionAgentId(transcriptPath, hookInput = null, precomputedCwd = null) {
|
|
236
|
+
const envOverride = process.env.MEKO_AGENT_ID;
|
|
237
|
+
if (envOverride && envOverride.trim()) return envOverride.trim();
|
|
238
|
+
// SessionStart picks the cwd once and passes it in, so the transcript is
|
|
239
|
+
// peeked once per hook invocation rather than once per consumer.
|
|
240
|
+
const cwd = precomputedCwd || pickSessionCwd(transcriptPath, hookInput);
|
|
241
|
+
const client = resolveHookClient();
|
|
105
242
|
return deriveAgentId({ client, cwd, envOverride });
|
|
106
243
|
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Emit a one-line derivation log to stderr so future MEKO-590–class
|
|
247
|
+
* collisions ("why did session X land under agent_id Y?") are diagnosable
|
|
248
|
+
* from capture logs. Called once at SessionStart, not on every drain, so
|
|
249
|
+
* test assertions on downstream hooks stay stable.
|
|
250
|
+
*
|
|
251
|
+
* Takes the already-picked `cwd` so the log reports the SAME cwd the
|
|
252
|
+
* derivation actually used (Cursor's `workspace_roots` included — the
|
|
253
|
+
* hook's raw cwd is `~/.cursor`, which would print misleadingly) without
|
|
254
|
+
* a second transcript peek.
|
|
255
|
+
*
|
|
256
|
+
* Reports `derivedAgentId`, NOT necessarily the id this session writes
|
|
257
|
+
* under: the watermark-reuse and resume branches run
|
|
258
|
+
* `reconcileCachedAgentId` afterwards and may preserve a cached bucket.
|
|
259
|
+
* `logEffectiveAgentId` closes that gap on those paths.
|
|
260
|
+
*/
|
|
261
|
+
function logAgentIdDerivation(cwd, derivedAgentId) {
|
|
262
|
+
const envOverride = (process.env.MEKO_AGENT_ID || "").trim();
|
|
263
|
+
if (envOverride) {
|
|
264
|
+
process.stderr.write(
|
|
265
|
+
`[meko-capture] deriveAgentId: source=env-override derivedAgentId=${derivedAgentId}\n`,
|
|
266
|
+
);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
const client = resolveHookClient();
|
|
270
|
+
const repoRoot = resolveRepoRoot(cwd);
|
|
271
|
+
process.stderr.write(
|
|
272
|
+
`[meko-capture] deriveAgentId: client=${client} cwd=${cwd || ""} ` +
|
|
273
|
+
`resolvedRepoRoot=${repoRoot || "<none>"} derivedAgentId=${derivedAgentId}\n`,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Log the agent_id this session actually writes under, when a cached bucket
|
|
279
|
+
* won over the fresh derivation (`/clear`, `/compact`, resume, or a HOLD).
|
|
280
|
+
* Silent when it matches, so the common path stays one line.
|
|
281
|
+
*
|
|
282
|
+
* Without this, "why did session X land under agent_id Y?" is unanswerable
|
|
283
|
+
* from the logs in exactly the cases where the answer is not the derivation.
|
|
284
|
+
*/
|
|
285
|
+
function logEffectiveAgentId(derivedAgentId, effectiveAgentId, reason) {
|
|
286
|
+
if (!effectiveAgentId || effectiveAgentId === derivedAgentId) return;
|
|
287
|
+
process.stderr.write(
|
|
288
|
+
`[meko-capture] deriveAgentId: effectiveAgentId=${effectiveAgentId} ` +
|
|
289
|
+
`(overrides derivedAgentId=${derivedAgentId}; reason=${reason})\n`,
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Coding clients that derive a `<client>:<repo>` agent_id. Kept in
|
|
294
|
+
// sync with deriveAgentId's coding-client branch.
|
|
295
|
+
const CODING_CLIENTS = ["claude_code", "cursor", "codex", "kiro", "opencode"];
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Extract the client segment of an agent_id: the part before the first `:`,
|
|
299
|
+
* or the whole string for a bare client name. `codex:my-repo` → `codex`,
|
|
300
|
+
* `claude_code` → `claude_code`, `custom_bucket` → `custom_bucket`.
|
|
301
|
+
*/
|
|
302
|
+
function clientOfAgentId(agentId) {
|
|
303
|
+
const id = (typeof agentId === "string" ? agentId : "").trim();
|
|
304
|
+
return id.includes(":") ? id.slice(0, id.indexOf(":")) : id;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* True if `agentId` looks like an auto-derived coding-client identity, i.e.
|
|
309
|
+
* `<coding-client>:<something>` or the bare client name. Used to tell a
|
|
310
|
+
* stale auto-derived value (safe to re-derive) apart from a user-chosen
|
|
311
|
+
* bucket (must be preserved).
|
|
312
|
+
*/
|
|
313
|
+
function isDerivedCodingAgentId(agentId) {
|
|
314
|
+
return CODING_CLIENTS.includes(clientOfAgentId(agentId));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Reconcile a cached watermark agent_id with the current hook invocation.
|
|
319
|
+
*
|
|
320
|
+
* Upgrade path (MEKO-385): a watermark written by the OLD Codex hook holds
|
|
321
|
+
* `claude_code:<repo>` because the pre-fix hook collapsed every non-cursor
|
|
322
|
+
* client to claude_code. After upgrade, a SessionStart re-fire (/clear,
|
|
323
|
+
* /compact) or a checkpoint/PreCompact/SessionEnd hook must NOT keep writing
|
|
324
|
+
* into that wrong namespace. So when the current client derives a different
|
|
325
|
+
* coding-client identity than the cached one, and the cached value is a
|
|
326
|
+
* plain auto-derived coding id (not a user bucket), we re-derive.
|
|
327
|
+
*
|
|
328
|
+
* Resolution order:
|
|
329
|
+
* 1. Current MEKO_AGENT_ID override wins verbatim — it is the live source of
|
|
330
|
+
* truth and equals `derivedAgentId` (resolveSessionAgentId returns the
|
|
331
|
+
* override first). This applies regardless of what the watermark cached,
|
|
332
|
+
* including a source-less legacy watermark whose stale value must NOT be
|
|
333
|
+
* preserved when a differing override is set now.
|
|
334
|
+
* 2. No usable cached value, or the legacy ghost literal "agent" → fresh
|
|
335
|
+
* derivation.
|
|
336
|
+
* 3. A previously-recorded explicit override (agent_id_source==="explicit",
|
|
337
|
+
* with no current env override to supersede it) → preserved verbatim.
|
|
338
|
+
* 4. Auto-derived cached value → re-derive ONLY when the CLIENT segment is
|
|
339
|
+
* stale for the current client (claude_code:<repo> under a now-codex
|
|
340
|
+
* session). We compare the client, not the whole id: a differing repo
|
|
341
|
+
* basename alone (same client) is NOT a reason to re-derive — that would
|
|
342
|
+
* clobber a legitimately preserved project bucket on resume.
|
|
343
|
+
*
|
|
344
|
+
* The returned agent_id may differ from the conversation's current owner; when
|
|
345
|
+
* it does the caller must open a NEW conversation under it (see
|
|
346
|
+
* needsRebucket / rebucketConversation) because the server pins the owner at
|
|
347
|
+
* creation and rejects mismatched agent_id on an existing conversation.
|
|
348
|
+
*
|
|
349
|
+
* @param {string} cachedAgentId agent_id read from the watermark.
|
|
350
|
+
* @param {string} cachedSource watermark.agent_id_source ("" if legacy).
|
|
351
|
+
* @param {string} derivedAgentId freshly derived identity for this session.
|
|
352
|
+
* @returns {{agentId: string, source: "explicit"|"derived"}}
|
|
353
|
+
*/
|
|
354
|
+
function reconcileCachedAgentId(cachedAgentId, cachedSource, derivedAgentId) {
|
|
355
|
+
const cached = (typeof cachedAgentId === "string" ? cachedAgentId : "").trim();
|
|
356
|
+
const envOverride = (process.env.MEKO_AGENT_ID || "").trim();
|
|
357
|
+
|
|
358
|
+
// (1) A current env override always wins. derivedAgentId already equals the
|
|
359
|
+
// override (resolveSessionAgentId honors MEKO_AGENT_ID first), so applying it
|
|
360
|
+
// here never preserves a stale cached value against the live override.
|
|
361
|
+
if (envOverride) {
|
|
362
|
+
return { agentId: derivedAgentId, source: "explicit" };
|
|
363
|
+
}
|
|
364
|
+
// (2) No usable cached value, or the legacy ghost literal → fresh derivation.
|
|
365
|
+
if (!cached || cached === "agent") {
|
|
366
|
+
return { agentId: derivedAgentId, source: "derived" };
|
|
367
|
+
}
|
|
368
|
+
// (3) A previously-recorded explicit override (no current env override to
|
|
369
|
+
// supersede it) is preserved verbatim.
|
|
370
|
+
if (cachedSource === "explicit") {
|
|
371
|
+
return { agentId: cached, source: "explicit" };
|
|
372
|
+
}
|
|
373
|
+
// (4) Auto-derived cached value: re-derive only on a stale CLIENT segment.
|
|
374
|
+
if (
|
|
375
|
+
isDerivedCodingAgentId(cached)
|
|
376
|
+
&& isDerivedCodingAgentId(derivedAgentId)
|
|
377
|
+
&& clientOfAgentId(cached) !== clientOfAgentId(derivedAgentId)
|
|
378
|
+
) {
|
|
379
|
+
return { agentId: derivedAgentId, source: "derived" };
|
|
380
|
+
}
|
|
381
|
+
// Otherwise keep the cached derived value (non-coding custom bucket, or
|
|
382
|
+
// already the right client).
|
|
383
|
+
return { agentId: cached, source: "derived" };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* True when the existing conversation may NOT be owned by the agent_id we
|
|
388
|
+
* resolved for this invocation, so the caller must open a fresh conversation
|
|
389
|
+
* under the resolved id before writing. The server pins the owner at creation
|
|
390
|
+
* and rejects a mismatched agent_id on an existing conversation (src/tools.py:
|
|
391
|
+
* conversation_add_message returns `agent_id_mismatch`), so reusing a
|
|
392
|
+
* wrong-owner conversation would make every future write fail closed and wedge
|
|
393
|
+
* the watermark forever.
|
|
394
|
+
*
|
|
395
|
+
* Two triggering cases, given a non-empty conversation_id:
|
|
396
|
+
* - the cached owner differs from the resolved id (a real client-mismatch
|
|
397
|
+
* upgrade, e.g. claude_code:<repo> → codex:<repo>); or
|
|
398
|
+
* - the cached owner is EMPTY/unknown. A legacy or partially-written
|
|
399
|
+
* watermark can carry a conversation_id with no agent_id; we don't know
|
|
400
|
+
* who owns that conversation, and silently reusing it under the resolved
|
|
401
|
+
* id risks agent_id_mismatch on every write with no way to self-heal. So
|
|
402
|
+
* unknown-owner is treated as "needs a conversation we know we own".
|
|
403
|
+
*
|
|
404
|
+
* @param {string} cachedAgentId watermark.agent_id (the conversation owner).
|
|
405
|
+
* @param {string} resolvedAgentId agent_id resolved for this invocation.
|
|
406
|
+
* @param {string} convId watermark.conversation_id (may be empty).
|
|
407
|
+
* @returns {boolean}
|
|
408
|
+
*/
|
|
409
|
+
function ownerChanged(cachedAgentId, resolvedAgentId, convId) {
|
|
410
|
+
const conv = (typeof convId === "string" ? convId : "").trim();
|
|
411
|
+
if (!conv) return false; // no existing conversation → nothing to reconcile
|
|
412
|
+
const cached = (typeof cachedAgentId === "string" ? cachedAgentId : "").trim();
|
|
413
|
+
if (!cached) return true; // conversation exists but owner unknown → reconcile
|
|
414
|
+
return cached !== resolvedAgentId;
|
|
415
|
+
}
|
|
416
|
+
|
|
107
417
|
const MEKO_API_KEY = process.env.MEKO_API_KEY || "";
|
|
108
418
|
const MEKO_API_TIMEOUT = parseInt(process.env.MEKO_API_TIMEOUT || "10", 10) * 1000;
|
|
419
|
+
const MEKO_ADD_MESSAGE_TIMEOUT = (() => {
|
|
420
|
+
// A value that doesn't parse would reach the lease TTL as NaN and throw
|
|
421
|
+
// inside renewLease, so fall back to the default instead.
|
|
422
|
+
const n = Number.parseInt(process.env.MEKO_ADD_MESSAGE_TIMEOUT || "", 10);
|
|
423
|
+
return (Number.isFinite(n) && n > 0 ? n : 60) * 1000;
|
|
424
|
+
})();
|
|
109
425
|
const WATERMARK_DIR =
|
|
110
426
|
process.env.MEKO_WATERMARK_DIR ||
|
|
111
427
|
path.join(os.homedir(), ".claude", "meko-capture");
|
|
@@ -113,6 +429,10 @@ const SESSION_CACHE_DIR =
|
|
|
113
429
|
process.env.MEKO_SESSION_CACHE_DIR ||
|
|
114
430
|
path.join(os.homedir(), ".cursor", "meko-session-cache");
|
|
115
431
|
|
|
432
|
+
/** Exact interrupted-turn assistant marker (contracts). */
|
|
433
|
+
const INTERRUPTED_ASSISTANT_OUTPUT =
|
|
434
|
+
"[Meko capture: assistant response was interrupted before completion.]";
|
|
435
|
+
|
|
116
436
|
let mcpRequestId = 0;
|
|
117
437
|
let mcpSessionId = null; // MCP Streamable HTTP session ID
|
|
118
438
|
const inflightRequests = new Set(); // tracked so preload timeouts can abort
|
|
@@ -228,89 +548,507 @@ function extractToolResultContent(msg) {
|
|
|
228
548
|
return parts.length > 0 ? "TOOL RESULT:\n" + parts.join("\n") : "";
|
|
229
549
|
}
|
|
230
550
|
|
|
551
|
+
// Turn inputs that are Claude Code bookkeeping, not something the user typed.
|
|
552
|
+
// A turn whose rendered input matches is never captured.
|
|
553
|
+
const SKIP_INPUT_PATTERN =
|
|
554
|
+
/^<(system-reminder|command-|local-command|available-deferred)/;
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Render a Claude Code skill or prompt command entry as the command the user
|
|
558
|
+
* typed. Claude Code writes `/commit fix typo` as
|
|
559
|
+
* `<command-message>commit</command-message><command-name>/commit</command-name>`
|
|
560
|
+
* plus an optional `<command-args>fix typo</command-args>`, and writes the
|
|
561
|
+
* skill or command body as the `isMeta` entry that follows. Built-in commands
|
|
562
|
+
* (`/clear`, `/model`, `/compact`, …) lead with `<command-name>` instead and
|
|
563
|
+
* have no body; they return "" here, so they never pull a following `isMeta`
|
|
564
|
+
* entry into their turn and always keep their raw, filtered input.
|
|
565
|
+
*/
|
|
566
|
+
function skillCommandInput(text) {
|
|
567
|
+
if (typeof text !== "string" || !/^<command-message>/.test(text)) {
|
|
568
|
+
return "";
|
|
569
|
+
}
|
|
570
|
+
const name = /<command-name>([\s\S]*?)<\/command-name>/.exec(text);
|
|
571
|
+
if (!name) return "";
|
|
572
|
+
const args = /<command-args>([\s\S]*?)<\/command-args>/.exec(text);
|
|
573
|
+
return [name[1].trim(), args ? args[1].trim() : ""].filter(Boolean).join(" ");
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Is this a Claude Code background-task or subagent notification? Claude Code
|
|
578
|
+
* writes these as plain (non-isMeta) `type: "user"` entries whose content is a
|
|
579
|
+
* `<task-notification>…<summary>…</summary>…</task-notification>` block, and
|
|
580
|
+
* newer versions also tag them `origin: { kind: "task-notification" }`.
|
|
581
|
+
*/
|
|
582
|
+
function isTaskNotification(entry) {
|
|
583
|
+
if (entry.type !== "user" || isToolResultMessage(entry)) return false;
|
|
584
|
+
if (entry.origin && entry.origin.kind === "task-notification") return true;
|
|
585
|
+
return /^\s*<task-notification>/.test(
|
|
586
|
+
extractText((entry.message || {}).content),
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Render a task notification that has to open its own turn as a one-line
|
|
592
|
+
* input. The full block (which can carry a subagent's whole report in
|
|
593
|
+
* `<result>`) goes to reasoning instead, so it is never read as user input.
|
|
594
|
+
*/
|
|
595
|
+
function taskNotificationInput(text) {
|
|
596
|
+
const summary = /<summary>([\s\S]*?)<\/summary>/.exec(text || "");
|
|
597
|
+
return `Task notification: ${summary ? summary[1].trim() : "background task update"}`;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Is this a subagent's hand-back report? Claude Code delivers it as an
|
|
602
|
+
* `isMeta` user entry tagged `origin: { kind: "peer", handback: true }`, with
|
|
603
|
+
* text starting "Another Claude session sent a message: <agent-message …>".
|
|
604
|
+
* Like a task notification, it is not user input and belongs to the open
|
|
605
|
+
* turn. Other peer messages (a request from an independent session, with no
|
|
606
|
+
* `handback`) are real input and still open their own turn.
|
|
607
|
+
*/
|
|
608
|
+
function isSubagentHandback(entry) {
|
|
609
|
+
if (entry.type !== "user" || isToolResultMessage(entry)) return false;
|
|
610
|
+
const origin = entry.origin;
|
|
611
|
+
return !!origin && origin.kind === "peer" && origin.handback === true;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** Render a hand-back that has to open its own turn as a one-line input. */
|
|
615
|
+
function handbackInput(entry) {
|
|
616
|
+
const origin = entry.origin || {};
|
|
617
|
+
const name = origin.name || origin.from || "";
|
|
618
|
+
return name ? `Agent message: ${name}` : "Agent message";
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const SKILL_BODY_PATTERN = /^Base directory for this skill: *(\S+)/;
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Is this an `isMeta` entry Claude Code injected while loading a skill (the
|
|
625
|
+
* skill body, or a notice such as "Skill /commit was loaded earlier")? The
|
|
626
|
+
* model-invoked shape carries `sourceToolUseID`; a body from a typed `/skill`
|
|
627
|
+
* is recognized by its "Base directory for this skill:" first line.
|
|
628
|
+
*/
|
|
629
|
+
function isSkillContext(entry) {
|
|
630
|
+
if (entry.type !== "user" || entry.isMeta !== true) return false;
|
|
631
|
+
if (isToolResultMessage(entry)) return false;
|
|
632
|
+
if (entry.sourceToolUseID) return true;
|
|
633
|
+
return SKILL_BODY_PATTERN.test(extractText((entry.message || {}).content));
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** Context injected into a turn rather than typed by the user. */
|
|
637
|
+
function isInjectedContext(entry) {
|
|
638
|
+
return isTaskNotification(entry) || isSubagentHandback(entry) || isSkillContext(entry);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Render skill context that has to open its own turn (see isTurnStart) as a
|
|
643
|
+
* one-line input naming the skill. The body goes to reasoning instead.
|
|
644
|
+
*/
|
|
645
|
+
function skillContextInput(text) {
|
|
646
|
+
const dir = SKILL_BODY_PATTERN.exec(text || "");
|
|
647
|
+
const name = dir ? path.basename(dir[1].replace(/\/+$/, "")) : "";
|
|
648
|
+
return name ? `Skill loaded: ${name}` : "Skill context loaded";
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* The rendered input for a turn. Skill-loading commands are unwrapped, and
|
|
653
|
+
* a turn opened by injected context gets a one-line summary so that context
|
|
654
|
+
* is never sent as user input.
|
|
655
|
+
*/
|
|
656
|
+
function turnInput(turn) {
|
|
657
|
+
const text = extractText((turn.userMsg.message || {}).content);
|
|
658
|
+
if (isTaskNotification(turn.userMsg)) return taskNotificationInput(text);
|
|
659
|
+
if (isSubagentHandback(turn.userMsg)) return handbackInput(turn.userMsg);
|
|
660
|
+
if (isSkillContext(turn.userMsg)) return skillContextInput(text);
|
|
661
|
+
// Only a skill command that actually got its body is shown as `/name args`.
|
|
662
|
+
return turn.injectedEntries.some((e) => e.isMeta === true && !isSubagentHandback(e))
|
|
663
|
+
? skillCommandInput(text) || text
|
|
664
|
+
: text;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/** Will a turn with this rendered input be captured? */
|
|
668
|
+
function isCapturableInput(input) {
|
|
669
|
+
return !!input && !SKIP_INPUT_PATTERN.test(input);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Does this user entry open a new turn? `prev` is the parsed entry just
|
|
674
|
+
* before it, or null. `insideInjectedRun` is true while consecutive injected
|
|
675
|
+
* `isMeta` entries continue after a skill-loading command or tool call.
|
|
676
|
+
* `openTurn` is the `{ userMsg, injectedEntries }` of the turn an earlier entry
|
|
677
|
+
* in this parse window opened, or null.
|
|
678
|
+
*
|
|
679
|
+
* Claude Code injects skill bodies ("Base directory for this skill: …") as
|
|
680
|
+
* `type: "user"` entries with `isMeta: true`. They belong to the turn that
|
|
681
|
+
* loaded the skill, not to a new one:
|
|
682
|
+
* - the model called the Skill tool → the entry carries `sourceToolUseID`;
|
|
683
|
+
* - the user typed `/skill` → the entry follows the `<command-message>`
|
|
684
|
+
* entry (see skillCommandInput).
|
|
685
|
+
* Other `isMeta` user entries (scheduled and /loop prompts, image captions,
|
|
686
|
+
* requests from other sessions) still open a turn.
|
|
687
|
+
*
|
|
688
|
+
* Task notifications (see isTaskNotification) and subagent hand-backs (see
|
|
689
|
+
* isSubagentHandback) are not user input either, so they also stay in the
|
|
690
|
+
* open turn and the assistant's reaction joins it — unless that turn is never
|
|
691
|
+
* captured (a `/compact` or `<local-command-…>` turn), which would drop the
|
|
692
|
+
* reaction with it.
|
|
693
|
+
*/
|
|
694
|
+
function isTurnStart(entry, { prev = null, insideInjectedRun = false, openTurn = null } = {}) {
|
|
695
|
+
if (entry.type !== "user" || isToolResultMessage(entry)) return false;
|
|
696
|
+
// Nothing to fold into: the turn this entry belongs to was captured by an
|
|
697
|
+
// earlier drain (a notification arriving after the checkpoint), or a cursor
|
|
698
|
+
// persisted by an older capture version points straight at an injected
|
|
699
|
+
// entry. Anchor a turn here so the assistant reply that follows is kept.
|
|
700
|
+
if (!openTurn) return true;
|
|
701
|
+
if (isTaskNotification(entry) || isSubagentHandback(entry)) {
|
|
702
|
+
return !isCapturableInput(turnInput(openTurn));
|
|
703
|
+
}
|
|
704
|
+
if (entry.isMeta !== true) return true;
|
|
705
|
+
if (entry.sourceToolUseID) return false;
|
|
706
|
+
if (insideInjectedRun) return false;
|
|
707
|
+
const prevIsSkillCommand =
|
|
708
|
+
prev != null &&
|
|
709
|
+
prev.type === "user" &&
|
|
710
|
+
skillCommandInput(extractText((prev.message || {}).content)) !== "";
|
|
711
|
+
return !prevIsSkillCommand;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Stateful wrapper over isTurnStart for a pass over parsed entries, so
|
|
716
|
+
* buildExchanges and realUserTurnLines apply the same turn-start rule.
|
|
717
|
+
* Returns `startsTurn(entry) → boolean`; call it once per entry, in order.
|
|
718
|
+
*/
|
|
719
|
+
function turnStartTracker() {
|
|
720
|
+
let prev = null;
|
|
721
|
+
let insideInjectedRun = false;
|
|
722
|
+
// Mirrors the turn buildExchanges is assembling, so turnInput can render it.
|
|
723
|
+
let openTurn = null;
|
|
724
|
+
return (entry) => {
|
|
725
|
+
const startsTurn = isTurnStart(entry, { prev, insideInjectedRun, openTurn });
|
|
726
|
+
const isUserText = entry.type === "user" && !isToolResultMessage(entry);
|
|
727
|
+
// A hand-back is isMeta too, but it never continues a skill's run.
|
|
728
|
+
const isMetaUser =
|
|
729
|
+
isUserText && entry.isMeta === true && !isSubagentHandback(entry);
|
|
730
|
+
insideInjectedRun =
|
|
731
|
+
isMetaUser && (entry.sourceToolUseID != null || !startsTurn);
|
|
732
|
+
if (startsTurn) {
|
|
733
|
+
openTurn = { userMsg: entry, injectedEntries: [] };
|
|
734
|
+
} else if (openTurn && isUserText) {
|
|
735
|
+
openTurn.injectedEntries.push(entry);
|
|
736
|
+
}
|
|
737
|
+
prev = entry;
|
|
738
|
+
return startsTurn;
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
|
|
231
742
|
// --- Turn-assembly exchange extraction ---
|
|
232
743
|
|
|
233
|
-
|
|
744
|
+
// Codex turn-completion boundary discriminator.
|
|
745
|
+
//
|
|
746
|
+
// A single Codex task emits MULTIPLE assistant `response_item` messages within
|
|
747
|
+
// one turn: `phase: "commentary"` progress updates stream first, and the turn
|
|
748
|
+
// ends with the `phase: "final_answer"` reply, after which Codex writes an
|
|
749
|
+
// `event_msg` whose `payload.type` is `"task_complete"`. A checkpoint can fire
|
|
750
|
+
// after commentary but before the final answer, so a mid-stream turn is only
|
|
751
|
+
// safe to consume once one of these boundaries has arrived — otherwise the
|
|
752
|
+
// trailing (still-streaming) assistant messages would be read on a later run
|
|
753
|
+
// without their user and dropped.
|
|
754
|
+
//
|
|
755
|
+
// The in-progress SIGNAL is a `commentary`-phase assistant message with no
|
|
756
|
+
// following completion boundary. A turn whose assistant message carries no
|
|
757
|
+
// phase at all is the legacy/simple shape (no streaming): it's complete as
|
|
758
|
+
// soon as any assistant message exists, exactly as before — so we never hold
|
|
759
|
+
// such a turn forever waiting for a boundary it will never emit.
|
|
760
|
+
//
|
|
761
|
+
// These are named constants so the Codex migrator adapter (PR #232,
|
|
762
|
+
// installer/lib/migrate/adapters/codex.mjs) can mirror the SAME definition:
|
|
763
|
+
// in-progress = a CODEX_COMMENTARY_PHASE assistant message with no boundary;
|
|
764
|
+
// boundary = an assistant message with phase === CODEX_FINAL_ANSWER_PHASE, or
|
|
765
|
+
// an event_msg with payload.type === CODEX_TASK_COMPLETE_TYPE.
|
|
766
|
+
const CODEX_COMMENTARY_PHASE = "commentary";
|
|
767
|
+
const CODEX_FINAL_ANSWER_PHASE = "final_answer";
|
|
768
|
+
const CODEX_TASK_COMPLETE_TYPE = "task_complete";
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Normalize a Codex rollout record into the Claude-shaped entry the
|
|
772
|
+
* turn-assembly below expects. Codex wraps each turn in a `response_item`
|
|
773
|
+
* envelope with `payload.type === "message"` and role-specific content
|
|
774
|
+
* blocks (`input_text` for the user, `output_text` for the assistant).
|
|
775
|
+
*
|
|
776
|
+
* Two record kinds are surfaced:
|
|
777
|
+
* - `response_item` messages → user/assistant entries (assistant entries
|
|
778
|
+
* carry `codexPhase` so turn-assembly can tell an in-progress `commentary`
|
|
779
|
+
* message from the `final_answer` that ends the turn).
|
|
780
|
+
* - the `event_msg` whose `payload.type` is `task_complete` → a lightweight
|
|
781
|
+
* `{ type: "codex_task_complete" }` boundary marker, so turn-assembly
|
|
782
|
+
* knows the trailing assistant sequence is finished.
|
|
783
|
+
*
|
|
784
|
+
* Every other `event_msg` mirrors `response_item` text as a UI event and is
|
|
785
|
+
* dropped (returns null), so each turn is captured exactly once. Returns null
|
|
786
|
+
* for non-Codex input too, so Claude Code / Cursor records fall through to
|
|
787
|
+
* their own branch in parseTranscriptEntries untouched.
|
|
788
|
+
*/
|
|
789
|
+
function normalizeCodexEntry(obj, lineIndex) {
|
|
790
|
+
if (!obj || typeof obj !== "object") return null;
|
|
791
|
+
if (obj.type === "event_msg") {
|
|
792
|
+
const p = obj.payload;
|
|
793
|
+
if (p && p.type === CODEX_TASK_COMPLETE_TYPE) {
|
|
794
|
+
return { type: "codex_task_complete" };
|
|
795
|
+
}
|
|
796
|
+
return null;
|
|
797
|
+
}
|
|
798
|
+
if (obj.type !== "response_item") return null;
|
|
799
|
+
const payload = obj.payload;
|
|
800
|
+
if (!payload || payload.type !== "message") return null;
|
|
801
|
+
const role = payload.role;
|
|
802
|
+
if (role !== "user" && role !== "assistant") return null;
|
|
803
|
+
const wantType = role === "user" ? "input_text" : "output_text";
|
|
804
|
+
const blocks = Array.isArray(payload.content) ? payload.content : [];
|
|
805
|
+
const text = blocks
|
|
806
|
+
.filter((b) => b && (b.type === wantType || b.type === "text"))
|
|
807
|
+
.map((b) => (typeof b.text === "string" ? b.text : ""))
|
|
808
|
+
.filter(Boolean)
|
|
809
|
+
.join("\n");
|
|
810
|
+
const explicitId = obj.id || payload.id || obj.uuid;
|
|
811
|
+
const timestamp = obj.timestamp || payload.timestamp || "";
|
|
812
|
+
// Real Codex rollout records frequently omit id/payload.id/uuid on
|
|
813
|
+
// response_item messages. Falling back to a constant ("unknown") gave every
|
|
814
|
+
// user turn the same addMessage dedup seed (`${convId}:unknown`), so all but
|
|
815
|
+
// the first were collapsed as duplicates. When no explicit id exists, derive
|
|
816
|
+
// a stable, unique-per-turn id from fields real records DO carry: the
|
|
817
|
+
// record's timestamp plus its line position in the transcript. Position is
|
|
818
|
+
// deterministic across re-parses of the same file (so PreCompact/SessionEnd
|
|
819
|
+
// retries stay idempotent) yet distinct per turn (so turns aren't collapsed).
|
|
820
|
+
const uuid = explicitId || `codex-${timestamp || "no-ts"}-line${lineIndex}`;
|
|
821
|
+
const entry = {
|
|
822
|
+
type: role,
|
|
823
|
+
uuid,
|
|
824
|
+
timestamp,
|
|
825
|
+
codex: true,
|
|
826
|
+
message: { role, content: [{ type: "text", text }] },
|
|
827
|
+
};
|
|
828
|
+
if (role === "assistant") {
|
|
829
|
+
// Codex may put the phase on the payload or the envelope; check both.
|
|
830
|
+
const phase = payload.phase || obj.phase;
|
|
831
|
+
entry.codexPhase = typeof phase === "string" ? phase : "";
|
|
832
|
+
}
|
|
833
|
+
return entry;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* Parse the transcript lines after `startLine` into the user/assistant
|
|
838
|
+
* entries turn-assembly consumes. Recognizes both Claude Code / Cursor
|
|
839
|
+
* records (top-level `type` of `user`/`assistant`) and Codex `response_item`
|
|
840
|
+
* message records (via normalizeCodexEntry). Each element is
|
|
841
|
+
* `{ entry, line }`, pairing the normalized entry with its 0-based line index
|
|
842
|
+
* in the transcript so buildExchanges can report the last safely-consumed
|
|
843
|
+
* line. Returns the raw entry list so callers can distinguish "no eligible
|
|
844
|
+
* records" from "records present but zero exchanges" when deciding whether to
|
|
845
|
+
* advance the watermark.
|
|
846
|
+
*/
|
|
847
|
+
function parseTranscriptEntries(filePath, startLine) {
|
|
234
848
|
const data = fs.readFileSync(filePath, "utf-8");
|
|
235
849
|
const lines = data.split("\n");
|
|
236
850
|
|
|
237
|
-
// Step 1: Parse relevant lines
|
|
238
851
|
const entries = [];
|
|
239
852
|
for (let i = startLine; i < lines.length; i++) {
|
|
240
853
|
const line = lines[i].trim();
|
|
241
854
|
if (!line) continue;
|
|
855
|
+
let obj;
|
|
242
856
|
try {
|
|
243
|
-
|
|
244
|
-
if (obj.type === "user" || obj.type === "assistant") {
|
|
245
|
-
entries.push(obj);
|
|
246
|
-
}
|
|
857
|
+
obj = JSON.parse(line);
|
|
247
858
|
} catch {
|
|
248
859
|
continue;
|
|
249
860
|
}
|
|
861
|
+
if (obj.type === "user" || obj.type === "assistant") {
|
|
862
|
+
entries.push({ entry: obj, line: i });
|
|
863
|
+
continue;
|
|
864
|
+
}
|
|
865
|
+
const codexEntry = normalizeCodexEntry(obj, i);
|
|
866
|
+
if (codexEntry) entries.push({ entry: codexEntry, line: i });
|
|
867
|
+
}
|
|
868
|
+
return entries;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* Render a turn's assistant side: assistant text becomes `output`; thinking,
|
|
873
|
+
* tool calls, tool results and injected entries become `reasoning`.
|
|
874
|
+
*/
|
|
875
|
+
function renderTurnBody(ex) {
|
|
876
|
+
const outputParts = [];
|
|
877
|
+
const reasoningParts = [];
|
|
878
|
+
|
|
879
|
+
for (const aEntry of ex.assistantEntries) {
|
|
880
|
+
const content = (aEntry.message || {}).content;
|
|
881
|
+
if (!Array.isArray(content)) continue;
|
|
882
|
+
|
|
883
|
+
const text = extractText(content);
|
|
884
|
+
if (text) outputParts.push(text);
|
|
885
|
+
|
|
886
|
+
const thinking = extractThinking(content);
|
|
887
|
+
if (thinking) reasoningParts.push("THINKING:\n" + thinking);
|
|
888
|
+
|
|
889
|
+
const toolCalls = extractToolCalls(content);
|
|
890
|
+
if (toolCalls) reasoningParts.push(toolCalls);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
for (const trEntry of ex.toolResultEntries) {
|
|
894
|
+
const resultText = extractToolResultContent(trEntry);
|
|
895
|
+
if (resultText) reasoningParts.push(resultText);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
const injected = isInjectedContext(ex.userMsg)
|
|
899
|
+
? [ex.userMsg, ...ex.injectedEntries]
|
|
900
|
+
: ex.injectedEntries;
|
|
901
|
+
for (const injEntry of injected) {
|
|
902
|
+
const text = extractText((injEntry.message || {}).content);
|
|
903
|
+
if (text) {
|
|
904
|
+
const label = isTaskNotification(injEntry)
|
|
905
|
+
? "TASK NOTIFICATION:\n"
|
|
906
|
+
: isSubagentHandback(injEntry)
|
|
907
|
+
? "AGENT MESSAGE:\n"
|
|
908
|
+
: "INJECTED CONTEXT:\n";
|
|
909
|
+
reasoningParts.push(label + truncate(text, MAX_TOOL_RESULT_LEN));
|
|
910
|
+
}
|
|
250
911
|
}
|
|
251
912
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
913
|
+
return {
|
|
914
|
+
output: outputParts.join("\n\n"),
|
|
915
|
+
reasoning: reasoningParts.join("\n---\n"),
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* Group parsed entries into exchanges using turn-assembly, then render each
|
|
921
|
+
* exchange to the { input, output, reasoning } shape addMessage sends.
|
|
922
|
+
*
|
|
923
|
+
* A real user message (no toolUseResult) starts a new exchange. Skill bodies
|
|
924
|
+
* and related injected `isMeta` entries stay in the turn that loaded them, and
|
|
925
|
+
* task notifications stay in the open turn (see isTurnStart). Other user
|
|
926
|
+
* messages start the next exchange. Assistant lines and tool-result user lines
|
|
927
|
+
* belong to the current exchange.
|
|
928
|
+
*
|
|
929
|
+
* `items` is the `{ entry, line }` list from parseTranscriptEntries. Returns
|
|
930
|
+
* `{ exchanges, pendingFromLine }`:
|
|
931
|
+
* - `exchanges` — the renderable exchange list (unchanged contract). All
|
|
932
|
+
* assistant messages of a turn (Codex `commentary` … `final_answer`) are
|
|
933
|
+
* folded into the single assistant side, not just the first.
|
|
934
|
+
* - `pendingFromLine` — the 0-based transcript line index of a trailing
|
|
935
|
+
* user turn that is renderable but NOT yet safe to consume, or `null`
|
|
936
|
+
* otherwise. A turn is not safe to consume when it is still awaiting its
|
|
937
|
+
* assistant reply, or — for Codex — when its assistant sequence has not
|
|
938
|
+
* reached the completion boundary yet (no `phase: final_answer` message
|
|
939
|
+
* and no `task_complete` marker). Callers advance the watermark only up to
|
|
940
|
+
* this line so the in-progress turn is re-read and captured whole once it
|
|
941
|
+
* finishes. `null` for a filtered/never-renderable trailing turn (e.g.
|
|
942
|
+
* `<system-reminder>` only) so the watermark can advance past it, and for a
|
|
943
|
+
* complete trailing turn, which advances fully.
|
|
944
|
+
*/
|
|
945
|
+
function buildExchanges(items) {
|
|
946
|
+
const turns = [];
|
|
257
947
|
let current = null;
|
|
258
948
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
949
|
+
const startsTurn = turnStartTracker();
|
|
950
|
+
for (const { entry, line } of items) {
|
|
951
|
+
if (startsTurn(entry)) {
|
|
952
|
+
if (current) turns.push(current);
|
|
262
953
|
current = {
|
|
263
954
|
userMsg: entry,
|
|
955
|
+
userLine: line,
|
|
264
956
|
assistantEntries: [],
|
|
265
957
|
toolResultEntries: [],
|
|
958
|
+
// Skill bodies, other isMeta text and task notifications Claude Code
|
|
959
|
+
// writes as user entries inside this turn (see isTurnStart).
|
|
960
|
+
injectedEntries: [],
|
|
961
|
+
// A turn with injected context is incomplete until an assistant entry
|
|
962
|
+
// arrives after its latest injection. Assistant entries before that
|
|
963
|
+
// injection do not make the turn safe for a checkpoint to consume.
|
|
964
|
+
assistantAfterInjection: false,
|
|
965
|
+
// Codex streaming turns end at a completion boundary (phase:
|
|
966
|
+
// final_answer, or a task_complete event_msg). `codexStreaming` marks a
|
|
967
|
+
// turn that emitted commentary and is therefore mid-stream until a
|
|
968
|
+
// boundary arrives. A Codex turn with no phase info at all is the
|
|
969
|
+
// legacy/simple shape — complete as soon as any assistant message
|
|
970
|
+
// exists, like non-Codex turns.
|
|
971
|
+
codexStreaming: false,
|
|
972
|
+
codexComplete: false,
|
|
266
973
|
};
|
|
267
974
|
} else if (current) {
|
|
268
975
|
if (entry.type === "assistant") {
|
|
269
976
|
current.assistantEntries.push(entry);
|
|
977
|
+
if (current.injectedEntries.length > 0) {
|
|
978
|
+
current.assistantAfterInjection = true;
|
|
979
|
+
}
|
|
980
|
+
if (entry.codexPhase === CODEX_FINAL_ANSWER_PHASE) {
|
|
981
|
+
current.codexComplete = true;
|
|
982
|
+
} else if (entry.codexPhase === CODEX_COMMENTARY_PHASE) {
|
|
983
|
+
current.codexStreaming = true;
|
|
984
|
+
}
|
|
985
|
+
} else if (entry.type === "codex_task_complete") {
|
|
986
|
+
current.codexComplete = true;
|
|
270
987
|
} else if (entry.type === "user" && isToolResultMessage(entry)) {
|
|
271
988
|
current.toolResultEntries.push(entry);
|
|
989
|
+
} else if (entry.type === "user") {
|
|
990
|
+
current.injectedEntries.push(entry);
|
|
991
|
+
// Each injection needs its own reply: a second skill body or
|
|
992
|
+
// notification re-opens the hold until an assistant entry follows it.
|
|
993
|
+
current.assistantAfterInjection = false;
|
|
272
994
|
}
|
|
273
995
|
}
|
|
274
996
|
}
|
|
275
|
-
if (current)
|
|
997
|
+
if (current) turns.push(current);
|
|
998
|
+
|
|
999
|
+
// Decide whether the TRAILING turn is safe to consume. A checkpoint can fire
|
|
1000
|
+
// mid-turn (after the user, after some assistant commentary, but before the
|
|
1001
|
+
// final answer / task_complete). Consuming it then would advance the
|
|
1002
|
+
// watermark past the in-progress turn, so the later assistant messages get
|
|
1003
|
+
// read without their user and dropped. A trailing turn is NOT safe to consume
|
|
1004
|
+
// when it has no assistant reply yet, OR when it is a Codex turn still
|
|
1005
|
+
// streaming commentary with no completion boundary. Hold only when that turn
|
|
1006
|
+
// is actually renderable (real input, not a filtered marker); a filtered or
|
|
1007
|
+
// never-renderable trailing turn produces no exchange regardless, so we let
|
|
1008
|
+
// the watermark advance past it rather than reparse it forever.
|
|
1009
|
+
let pendingFromLine = null;
|
|
1010
|
+
const last = turns.length > 0 ? turns[turns.length - 1] : null;
|
|
1011
|
+
const lastIncomplete = last
|
|
1012
|
+
? last.assistantEntries.length === 0 ||
|
|
1013
|
+
(last.injectedEntries.length > 0 && !last.assistantAfterInjection) ||
|
|
1014
|
+
(last.codexStreaming && !last.codexComplete)
|
|
1015
|
+
: false;
|
|
1016
|
+
if (last && lastIncomplete) {
|
|
1017
|
+
const lastInput = turnInput(last);
|
|
1018
|
+
if (isCapturableInput(lastInput)) {
|
|
1019
|
+
pendingFromLine = last.userLine;
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
276
1022
|
|
|
277
|
-
//
|
|
278
|
-
|
|
279
|
-
|
|
1023
|
+
// Build output for each COMPLETE turn. The trailing turn is skipped while it
|
|
1024
|
+
// is still pending (pendingFromLine set) so its partial assistant content is
|
|
1025
|
+
// not captured early — it is captured whole on a later run.
|
|
280
1026
|
const results = [];
|
|
281
1027
|
|
|
282
|
-
for (
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
const outputParts = [];
|
|
288
|
-
const reasoningParts = [];
|
|
1028
|
+
for (let t = 0; t < turns.length; t++) {
|
|
1029
|
+
const ex = turns[t];
|
|
1030
|
+
const isPendingTrailing =
|
|
1031
|
+
pendingFromLine !== null && t === turns.length - 1;
|
|
1032
|
+
if (isPendingTrailing) continue;
|
|
289
1033
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
const text = extractText(content);
|
|
295
|
-
if (text) outputParts.push(text);
|
|
296
|
-
|
|
297
|
-
const thinking = extractThinking(content);
|
|
298
|
-
if (thinking) reasoningParts.push("THINKING:\n" + thinking);
|
|
299
|
-
|
|
300
|
-
const toolCalls = extractToolCalls(content);
|
|
301
|
-
if (toolCalls) reasoningParts.push(toolCalls);
|
|
302
|
-
}
|
|
1034
|
+
const input = turnInput(ex);
|
|
1035
|
+
if (!isCapturableInput(input)) continue;
|
|
1036
|
+
if (ex.assistantEntries.length === 0) continue;
|
|
303
1037
|
|
|
304
|
-
|
|
305
|
-
const resultText = extractToolResultContent(trEntry);
|
|
306
|
-
if (resultText) reasoningParts.push(resultText);
|
|
307
|
-
}
|
|
1038
|
+
const { output, reasoning } = renderTurnBody(ex);
|
|
308
1039
|
|
|
309
1040
|
results.push({
|
|
310
1041
|
user_uuid: ex.userMsg.uuid || "unknown",
|
|
1042
|
+
// PR #262: line-keyed boundary so missing/duplicate uuids cannot skip
|
|
1043
|
+
// remaining exchanges. Prefer this over uuid/index for cursor advances.
|
|
1044
|
+
user_line: ex.userLine,
|
|
1045
|
+
// PR #254: next user turn line, or null for the final complete turn
|
|
1046
|
+
// (caller substitutes safeLine when draining the full suffix).
|
|
1047
|
+
next_line_number:
|
|
1048
|
+
t + 1 < turns.length ? turns[t + 1].userLine : null,
|
|
311
1049
|
input: input,
|
|
312
|
-
output
|
|
313
|
-
reasoning
|
|
1050
|
+
output,
|
|
1051
|
+
reasoning,
|
|
314
1052
|
timestamp:
|
|
315
1053
|
ex.assistantEntries[ex.assistantEntries.length - 1].timestamp ||
|
|
316
1054
|
ex.userMsg.timestamp ||
|
|
@@ -318,41 +1056,203 @@ function extractExchanges(filePath, startLine) {
|
|
|
318
1056
|
});
|
|
319
1057
|
}
|
|
320
1058
|
|
|
321
|
-
return results;
|
|
1059
|
+
return { exchanges: results, pendingFromLine, turns };
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/**
|
|
1063
|
+
* Extract renderable exchanges from a transcript starting at `startLine`.
|
|
1064
|
+
* Thin wrapper over parseTranscriptEntries + buildExchanges kept for the
|
|
1065
|
+
* callers that only need the exchange list.
|
|
1066
|
+
*/
|
|
1067
|
+
function extractExchanges(filePath, startLine) {
|
|
1068
|
+
return buildExchanges(parseTranscriptEntries(filePath, startLine)).exchanges;
|
|
322
1069
|
}
|
|
323
1070
|
|
|
324
|
-
// --- Watermark ---
|
|
1071
|
+
// --- Watermark / outbox (bridged to capture-state v2) ---
|
|
325
1072
|
|
|
1073
|
+
/**
|
|
1074
|
+
* Compatibility reader used by SessionStart resume/rebucket paths.
|
|
1075
|
+
* Returns a flat object with the classic watermark fields. Corrupt files are
|
|
1076
|
+
* surfaced via `__corrupt` so callers never invent a zero cursor.
|
|
1077
|
+
*/
|
|
326
1078
|
function readWatermark(wmPath) {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
1079
|
+
const sessionId = path.basename(wmPath, ".watermark.json");
|
|
1080
|
+
const result = captureState.readState(sessionId);
|
|
1081
|
+
if (result.ok && result.state) {
|
|
1082
|
+
const s = result.state;
|
|
1083
|
+
return {
|
|
1084
|
+
conversation_id: s.conversation_id || "",
|
|
1085
|
+
last_line_number: s.last_line_number || 0,
|
|
1086
|
+
agent_id: s.agent_id || "",
|
|
1087
|
+
agent_id_source: s.agent_id_source || "",
|
|
1088
|
+
datapack_id: s.datapack_id || null,
|
|
1089
|
+
datapack_name: s.datapack_name || null,
|
|
1090
|
+
updated_at: s.updated_at || "",
|
|
1091
|
+
lifecycle: s.lifecycle,
|
|
1092
|
+
delivery: s.delivery,
|
|
1093
|
+
schema_version: s.schema_version,
|
|
1094
|
+
session_id: s.session_id,
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
if (result.missing) {
|
|
1098
|
+
return {
|
|
1099
|
+
conversation_id: "",
|
|
1100
|
+
last_line_number: 0,
|
|
1101
|
+
agent_id: "",
|
|
1102
|
+
agent_id_source: "",
|
|
1103
|
+
};
|
|
331
1104
|
}
|
|
1105
|
+
return {
|
|
1106
|
+
__corrupt: true,
|
|
1107
|
+
failure_class: result.failure_class || "state_corrupt",
|
|
1108
|
+
error: result.error || "corrupt watermark",
|
|
1109
|
+
conversation_id: "",
|
|
1110
|
+
last_line_number: 0,
|
|
1111
|
+
agent_id: "",
|
|
1112
|
+
agent_id_source: "",
|
|
1113
|
+
};
|
|
332
1114
|
}
|
|
333
1115
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
1116
|
+
/**
|
|
1117
|
+
* Persist outbox fields via capture-state. Never silently replaces a corrupt
|
|
1118
|
+
* file. Conversation-id changes go through rebucket() so the epoch resets
|
|
1119
|
+
* correctly instead of a non-monotonic cursor write.
|
|
1120
|
+
*/
|
|
1121
|
+
function writeWatermark(wmPath, convId, lineNum, agentId, datapackPin = null, agentIdSource = null) {
|
|
1122
|
+
const sessionId = path.basename(wmPath, ".watermark.json");
|
|
1123
|
+
const existing = captureState.readState(sessionId);
|
|
1124
|
+
if (!existing.ok && !existing.missing && existing.failure_class === "state_corrupt") {
|
|
1125
|
+
process.stderr.write(
|
|
1126
|
+
`[meko-capture] refusing to overwrite corrupt watermark for ${sessionId}: ${existing.error}\n`,
|
|
1127
|
+
);
|
|
1128
|
+
return existing;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
const now = new Date().toISOString();
|
|
1132
|
+
let state = existing.ok
|
|
1133
|
+
? { ...existing.state }
|
|
1134
|
+
: {
|
|
1135
|
+
schema_version: 2,
|
|
1136
|
+
session_id: sessionId,
|
|
1137
|
+
client: resolveHookClient() || "",
|
|
1138
|
+
agent_id: "",
|
|
1139
|
+
agent_id_source: "",
|
|
1140
|
+
transcript_path: null,
|
|
1141
|
+
datapack_id: null,
|
|
1142
|
+
datapack_name: null,
|
|
1143
|
+
created_at: now,
|
|
1144
|
+
updated_at: now,
|
|
1145
|
+
last_activity_at: now,
|
|
1146
|
+
conversation_id: null,
|
|
1147
|
+
conversation_epoch: 0,
|
|
1148
|
+
last_line_number: 0,
|
|
1149
|
+
in_flight: { seed: null, user_line: null, user_turn_id: null },
|
|
1150
|
+
lifecycle: "active",
|
|
1151
|
+
delivery: "needs_conversation",
|
|
1152
|
+
queued_exchanges: 0,
|
|
1153
|
+
attempt_count: 0,
|
|
1154
|
+
next_retry_at: null,
|
|
1155
|
+
failure_class: null,
|
|
1156
|
+
last_error: null,
|
|
1157
|
+
last_success_at: null,
|
|
1158
|
+
blocked_reason: null,
|
|
1159
|
+
consecutive_exchange_drops: 0,
|
|
1160
|
+
};
|
|
1161
|
+
|
|
337
1162
|
const existingPin =
|
|
338
|
-
|
|
339
|
-
? {
|
|
340
|
-
datapack_id: existing.datapack_id,
|
|
341
|
-
datapack_name: existing.datapack_name,
|
|
342
|
-
}
|
|
1163
|
+
state.datapack_id && state.datapack_name
|
|
1164
|
+
? { datapack_id: state.datapack_id, datapack_name: state.datapack_name }
|
|
343
1165
|
: null;
|
|
344
1166
|
const pin = datapackPin || existingPin;
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
1167
|
+
const source =
|
|
1168
|
+
agentIdSource != null ? agentIdSource : state.agent_id_source || "";
|
|
1169
|
+
|
|
1170
|
+
const prevConv = state.conversation_id || "";
|
|
1171
|
+
const nextConv = convId || "";
|
|
1172
|
+
const conversationChanged = Boolean(nextConv) && nextConv !== prevConv;
|
|
1173
|
+
|
|
1174
|
+
if (conversationChanged && prevConv) {
|
|
1175
|
+
const rb = captureState.rebucket(state, nextConv);
|
|
1176
|
+
if (!rb.ok) {
|
|
1177
|
+
process.stderr.write(
|
|
1178
|
+
`[meko-capture] rebucket failed for ${sessionId}: ${rb.error}\n`,
|
|
1179
|
+
);
|
|
1180
|
+
return rb;
|
|
1181
|
+
}
|
|
1182
|
+
state = rb.state;
|
|
1183
|
+
if (lineNum > 0) {
|
|
1184
|
+
const adv = captureState.advanceCursor(state, lineNum, { clearSeed: true });
|
|
1185
|
+
if (adv.ok) state = adv.state;
|
|
1186
|
+
}
|
|
1187
|
+
} else {
|
|
1188
|
+
state.conversation_id = nextConv || null;
|
|
1189
|
+
const targetLine = Number(lineNum) || 0;
|
|
1190
|
+
if (targetLine > state.last_line_number) {
|
|
1191
|
+
const adv = captureState.advanceCursor(state, targetLine, { clearSeed: true });
|
|
1192
|
+
if (adv.ok) state = adv.state;
|
|
1193
|
+
} else if (targetLine === state.last_line_number) {
|
|
1194
|
+
// no-op cursor; still refresh identity/datapack below
|
|
1195
|
+
} else if (!existing.ok || existing.missing) {
|
|
1196
|
+
state.last_line_number = targetLine;
|
|
1197
|
+
} else if (conversationChanged && !prevConv) {
|
|
1198
|
+
// First conversation bind on an intent-only outbox — cursor may be 0.
|
|
1199
|
+
state.last_line_number = targetLine;
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
state.agent_id = agentId != null ? agentId : state.agent_id || "";
|
|
1204
|
+
state.agent_id_source = source;
|
|
1205
|
+
state.datapack_id = pin ? pin.datapack_id : null;
|
|
1206
|
+
state.datapack_name = pin ? pin.datapack_name : null;
|
|
1207
|
+
if (state.conversation_id) {
|
|
1208
|
+
if (state.delivery === "needs_conversation" || !state.delivery) {
|
|
1209
|
+
state.delivery = "idle";
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
state.last_activity_at = now;
|
|
1213
|
+
state.updated_at = now;
|
|
1214
|
+
|
|
1215
|
+
return captureState.writeState(sessionId, state);
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
/** Real user-turn line indexes from a parseTranscriptEntries result (PR #262). */
|
|
1219
|
+
function realUserTurnLines(entries) {
|
|
1220
|
+
const lines = [];
|
|
1221
|
+
const startsTurn = turnStartTracker();
|
|
1222
|
+
for (const { entry, line } of entries) {
|
|
1223
|
+
if (startsTurn(entry)) lines.push(line);
|
|
1224
|
+
}
|
|
1225
|
+
return lines;
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
/**
|
|
1229
|
+
* First line that must remain unread after the exchange opening at `userLine`
|
|
1230
|
+
* is durably captured — the next real user turn — or null when none remains.
|
|
1231
|
+
*/
|
|
1232
|
+
function boundaryAfterExchange(turnLines, userLine) {
|
|
1233
|
+
if (typeof userLine !== "number") return null;
|
|
1234
|
+
for (const line of turnLines) {
|
|
1235
|
+
if (line > userLine) return line;
|
|
1236
|
+
}
|
|
1237
|
+
return null;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
function makeDrainResult(partial) {
|
|
1241
|
+
return {
|
|
1242
|
+
status: partial.status || "noop",
|
|
1243
|
+
session_id: partial.session_id || "",
|
|
1244
|
+
captured: partial.captured || 0,
|
|
1245
|
+
queued_remaining: partial.queued_remaining || 0,
|
|
1246
|
+
failure_class: partial.failure_class != null ? partial.failure_class : null,
|
|
1247
|
+
error: partial.error != null ? partial.error : null,
|
|
1248
|
+
cursor_advanced: Boolean(partial.cursor_advanced),
|
|
1249
|
+
last_line_number:
|
|
1250
|
+
partial.last_line_number != null ? partial.last_line_number : 0,
|
|
1251
|
+
lifecycle: partial.lifecycle || "active",
|
|
1252
|
+
delivery: partial.delivery || "idle",
|
|
1253
|
+
next_retry_at: partial.next_retry_at || null,
|
|
1254
|
+
dropped: partial.dropped || 0,
|
|
1255
|
+
};
|
|
356
1256
|
}
|
|
357
1257
|
|
|
358
1258
|
/**
|
|
@@ -367,72 +1267,44 @@ function datapackPinSlug(agentId) {
|
|
|
367
1267
|
return trimmed.replace(/[^A-Za-z0-9._-]+/g, "_").replace(/^_+|_+$/g, "") || COMMON_BUCKET_AGENT_ID;
|
|
368
1268
|
}
|
|
369
1269
|
|
|
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
1270
|
/**
|
|
410
1271
|
* Read the active-datapack pin written by the meko-select-datapack skill.
|
|
411
1272
|
*
|
|
412
1273
|
* The pin is a sidecar file at `<WATERMARK_DIR>/pin-<slug(agent_id)>.json`
|
|
413
1274
|
* with shape `{ datapack_id, datapack_name, selected_at }`. Project-scoped:
|
|
414
1275
|
* keyed by the same `agent_id` the hook derives for memory writes
|
|
415
|
-
* (`claude_code:<repo-basename
|
|
416
|
-
*
|
|
417
|
-
*
|
|
1276
|
+
* (`claude_code:<repo-basename>`, where the basename is the nearest
|
|
1277
|
+
* ancestor of the session cwd containing `.git`), so a pin set in one
|
|
1278
|
+
* Claude Code window for a repo applies to every other window in that
|
|
1279
|
+
* repo, and survives `/clear`, `/compact`, and Claude Code restart.
|
|
418
1280
|
*
|
|
419
|
-
* Hooks are read-only — only the skill writes this file.
|
|
420
|
-
*
|
|
421
|
-
*
|
|
1281
|
+
* Hooks are read-only — only the skill writes this file. The legacy id is
|
|
1282
|
+
* checked only when the repo-root id has no valid pin, preserving pins written
|
|
1283
|
+
* before MEKO-590 without preventing a newly written repo-root pin from
|
|
1284
|
+
* taking precedence.
|
|
422
1285
|
*/
|
|
423
|
-
function readDatapackPin(agentId) {
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
const
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
1286
|
+
function readDatapackPin(agentId, legacyAgentId = null) {
|
|
1287
|
+
const candidates = [...new Set([agentId, legacyAgentId].filter(Boolean))];
|
|
1288
|
+
for (const candidate of candidates) {
|
|
1289
|
+
const slug = datapackPinSlug(candidate);
|
|
1290
|
+
if (!slug) continue;
|
|
1291
|
+
const pinPath = path.join(WATERMARK_DIR, `pin-${slug}.json`);
|
|
1292
|
+
try {
|
|
1293
|
+
const obj = JSON.parse(fs.readFileSync(pinPath, "utf-8"));
|
|
1294
|
+
const id = typeof obj.datapack_id === "string" ? obj.datapack_id.trim() : "";
|
|
1295
|
+
const name = typeof obj.datapack_name === "string" ? obj.datapack_name.trim() : "";
|
|
1296
|
+
if (!id || !name) continue;
|
|
1297
|
+
if (candidate !== agentId) {
|
|
1298
|
+
process.stderr.write(
|
|
1299
|
+
`[meko-capture] datapack pin: using legacy agent_id alias ${candidate}\n`,
|
|
1300
|
+
);
|
|
1301
|
+
}
|
|
1302
|
+
return { datapack_id: id, datapack_name: name, selected_at: obj.selected_at || "" };
|
|
1303
|
+
} catch {
|
|
1304
|
+
// Try the compatibility alias, if any.
|
|
1305
|
+
}
|
|
435
1306
|
}
|
|
1307
|
+
return null;
|
|
436
1308
|
}
|
|
437
1309
|
|
|
438
1310
|
function datapackPinFromWatermark(watermark) {
|
|
@@ -471,9 +1343,14 @@ The user pinned datapack **${pin.datapack_name}** (\`${pin.datapack_id}\`) for t
|
|
|
471
1343
|
* Low-level HTTP POST to the MCP endpoint.
|
|
472
1344
|
* Returns { body: <parsed JSON>, headers: <response headers> }.
|
|
473
1345
|
*/
|
|
474
|
-
function mcpPost(jsonBody) {
|
|
1346
|
+
function mcpPost(jsonBody, options) {
|
|
475
1347
|
const body = JSON.stringify(jsonBody);
|
|
1348
|
+
const timeoutMs =
|
|
1349
|
+
options && Number(options.timeoutMs) > 0
|
|
1350
|
+
? Number(options.timeoutMs)
|
|
1351
|
+
: MEKO_API_TIMEOUT;
|
|
476
1352
|
return new Promise((resolve, reject) => {
|
|
1353
|
+
let deadline = null;
|
|
477
1354
|
const url = new URL(MEKO_MCP_URL);
|
|
478
1355
|
const transport = url.protocol === "https:" ? https : http;
|
|
479
1356
|
const headers = {
|
|
@@ -502,12 +1379,13 @@ function mcpPost(jsonBody) {
|
|
|
502
1379
|
path: url.pathname + url.search,
|
|
503
1380
|
method: "POST",
|
|
504
1381
|
headers,
|
|
505
|
-
timeout:
|
|
1382
|
+
timeout: timeoutMs,
|
|
506
1383
|
},
|
|
507
1384
|
(res) => {
|
|
508
1385
|
let data = "";
|
|
509
1386
|
res.on("data", (chunk) => (data += chunk));
|
|
510
1387
|
res.on("end", () => {
|
|
1388
|
+
clearTimeout(deadline);
|
|
511
1389
|
inflightRequests.delete(req);
|
|
512
1390
|
if (res.statusCode >= 400) {
|
|
513
1391
|
const snippet = data.trim().replace(/\s+/g, " ").slice(0, 200);
|
|
@@ -539,15 +1417,25 @@ function mcpPost(jsonBody) {
|
|
|
539
1417
|
);
|
|
540
1418
|
|
|
541
1419
|
inflightRequests.add(req);
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
reject(err);
|
|
545
|
-
});
|
|
546
|
-
req.on("timeout", () => {
|
|
1420
|
+
const timedOut = () => {
|
|
1421
|
+
clearTimeout(deadline);
|
|
547
1422
|
inflightRequests.delete(req);
|
|
548
1423
|
req.destroy();
|
|
549
1424
|
reject(new Error("MCP request timed out"));
|
|
1425
|
+
};
|
|
1426
|
+
// The socket timeout only measures idle time, and SSE pings reset it, so
|
|
1427
|
+
// also cap the whole request. unref() and the close listener keep an
|
|
1428
|
+
// aborted request (SessionStart's preload budget) from holding the
|
|
1429
|
+
// process open until the deadline.
|
|
1430
|
+
deadline = setTimeout(timedOut, timeoutMs);
|
|
1431
|
+
deadline.unref();
|
|
1432
|
+
req.on("close", () => clearTimeout(deadline));
|
|
1433
|
+
req.on("error", (err) => {
|
|
1434
|
+
clearTimeout(deadline);
|
|
1435
|
+
inflightRequests.delete(req);
|
|
1436
|
+
reject(err);
|
|
550
1437
|
});
|
|
1438
|
+
req.on("timeout", timedOut);
|
|
551
1439
|
req.write(body);
|
|
552
1440
|
req.end();
|
|
553
1441
|
});
|
|
@@ -557,7 +1445,10 @@ function parseMcpResponseBody(data) {
|
|
|
557
1445
|
const trimmed = data.trim();
|
|
558
1446
|
if (!trimmed) return null;
|
|
559
1447
|
|
|
560
|
-
|
|
1448
|
+
// An SSE stream may open with a comment line such as the server's
|
|
1449
|
+
// keepalive `: ping - <timestamp>`, sent every 15s while a slow tool call
|
|
1450
|
+
// is still running.
|
|
1451
|
+
if (!/^(event|data|id|retry)?:/.test(trimmed)) {
|
|
561
1452
|
return JSON.parse(trimmed);
|
|
562
1453
|
}
|
|
563
1454
|
|
|
@@ -584,24 +1475,78 @@ function parseMcpResponseBody(data) {
|
|
|
584
1475
|
return JSON.parse(payload);
|
|
585
1476
|
}
|
|
586
1477
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
} else if (statusMatch) {
|
|
596
|
-
persistentStatus = Number(statusMatch[1]);
|
|
597
|
-
}
|
|
1478
|
+
/**
|
|
1479
|
+
* Collapse a response body to a single-line snippet for error records.
|
|
1480
|
+
* Display/persistence stays short; pattern matching uses the fuller body.
|
|
1481
|
+
*/
|
|
1482
|
+
function errorBodySnippet(err) {
|
|
1483
|
+
const body = err && err.responseBody ? String(err.responseBody) : "";
|
|
1484
|
+
return body.trim().replace(/\s+/g, " ").slice(0, 200);
|
|
1485
|
+
}
|
|
598
1486
|
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
1487
|
+
/**
|
|
1488
|
+
* Full collapsed response body for classifier pattern matching.
|
|
1489
|
+
* mcpPost keeps up to 1000 chars on err.responseBody; the message snippet
|
|
1490
|
+
* is only 200, so reasons past that window are only visible here.
|
|
1491
|
+
*/
|
|
1492
|
+
function errorBodyForMatch(err) {
|
|
1493
|
+
const body = err && err.responseBody ? String(err.responseBody) : "";
|
|
1494
|
+
return body.trim().replace(/\s+/g, " ");
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
function isHtmlErrorDetail(detail) {
|
|
1498
|
+
return /^\s*<|<html|<!doctype/i.test(String(detail || ""));
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
function persistentFailureText(persistent, fallback) {
|
|
1502
|
+
if (!persistent || !persistent.reason) return fallback;
|
|
1503
|
+
const detail = persistent.detail ? String(persistent.detail) : "";
|
|
1504
|
+
if (
|
|
1505
|
+
!detail ||
|
|
1506
|
+
isHtmlErrorDetail(detail) ||
|
|
1507
|
+
String(persistent.reason).includes(detail)
|
|
1508
|
+
) {
|
|
1509
|
+
return persistent.reason;
|
|
604
1510
|
}
|
|
1511
|
+
return `${persistent.reason}: ${detail}`;
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
/**
|
|
1515
|
+
* Classify a capture failure that will not clear on retry.
|
|
1516
|
+
*
|
|
1517
|
+
* Returns null for transient failures. Otherwise:
|
|
1518
|
+
* { code, reason, scope, detail }
|
|
1519
|
+
*
|
|
1520
|
+
* `scope` says WHAT is rejected, which is what the caller needs to decide
|
|
1521
|
+
* whether retrying can ever succeed:
|
|
1522
|
+
* - "account" the account/plan is refusing all writes (quota, entitlement,
|
|
1523
|
+
* 402). Every exchange would fail identically, so the caller
|
|
1524
|
+
* holds the queue: the block clears when billing does, and
|
|
1525
|
+
* dropping would lose the whole session.
|
|
1526
|
+
* - "exchange" the response looks like an edge proxy rejecting THIS
|
|
1527
|
+
* request's body (HTTP 403 with an HTML body — see #13). The
|
|
1528
|
+
* caller may skip one such exchange so later turns still
|
|
1529
|
+
* drain; a second consecutive exchange-scoped failure without
|
|
1530
|
+
* an intervening success is treated as request-wide and held,
|
|
1531
|
+
* because HTML 403 alone does not prove payload-specificity
|
|
1532
|
+
* (missing UA, IP/ASN, rate-limit pages share the shape).
|
|
1533
|
+
*
|
|
1534
|
+
* Body patterns are tested BEFORE the status code. An account-level rejection
|
|
1535
|
+
* that arrives as 402/403 (the common case) would otherwise collapse into an
|
|
1536
|
+
* opaque `http_403` and send the user off regenerating a token that was never
|
|
1537
|
+
* at fault. `detail` is a short body snippet; `reason` is what callers persist
|
|
1538
|
+
* into `last_error` / notices so the real cause survives.
|
|
1539
|
+
* Pass `{ exchange: true }` only from the addMessage path. Initialize and
|
|
1540
|
+
* conversation_create HTML 403s are request-wide, so they stay account-scoped.
|
|
1541
|
+
*/
|
|
1542
|
+
function classifyPersistentCaptureFailure(err, opts) {
|
|
1543
|
+
const options = opts && typeof opts === "object" ? opts : {};
|
|
1544
|
+
const forExchange = options.exchange === true;
|
|
1545
|
+
const message = err && err.message ? String(err.message) : String(err || "");
|
|
1546
|
+
const detail = errorBodySnippet(err);
|
|
1547
|
+
// Search the fuller responseBody (up to mcpPost's 1000-char keep) so a
|
|
1548
|
+
// reason that fell outside the 200-char message snippet is still matched.
|
|
1549
|
+
const normalized = `${message} ${errorBodyForMatch(err)}`.toLowerCase();
|
|
605
1550
|
|
|
606
1551
|
const persistentPatterns = [
|
|
607
1552
|
["free_tier_limit_reached", /free[_\s-]?tier.*limit|free_tier_limit_reached/],
|
|
@@ -611,6 +1556,13 @@ function classifyPersistentCaptureFailure(err) {
|
|
|
611
1556
|
["payment_required", /payment[_\s-]?required/],
|
|
612
1557
|
["subscription_required", /subscription[_\s-]?(required|inactive|expired)/],
|
|
613
1558
|
["entitlement_denied", /entitlement.*(denied|required|missing)|(?:denied|required|missing).*entitlement/],
|
|
1559
|
+
// MEKO-656/MEKO-691: _resolve_datapack_id now returns this code (instead
|
|
1560
|
+
// of raising) when the caller has no ownership/share grant on the
|
|
1561
|
+
// datapack_id it supplied. Account-scoped like entitlement_denied above —
|
|
1562
|
+
// every write against that same datapack_id would fail identically until
|
|
1563
|
+
// access is (re-)granted out of band, so the caller should hold rather
|
|
1564
|
+
// than retry forever or silently drop the turn.
|
|
1565
|
+
["datapack_access_denied", /datapack_access_denied/],
|
|
614
1566
|
];
|
|
615
1567
|
for (const [code, pattern] of persistentPatterns) {
|
|
616
1568
|
if (pattern.test(normalized)) {
|
|
@@ -622,13 +1574,203 @@ function classifyPersistentCaptureFailure(err) {
|
|
|
622
1574
|
payment_required: "capture requires payment",
|
|
623
1575
|
subscription_required: "capture requires an active subscription",
|
|
624
1576
|
entitlement_denied: "the account is not entitled to capture conversations",
|
|
1577
|
+
datapack_access_denied: "the account does not have access to the target datapack",
|
|
625
1578
|
};
|
|
626
|
-
return { code, reason: reasons[code] };
|
|
1579
|
+
return { code, reason: reasons[code], scope: "account", detail };
|
|
627
1580
|
}
|
|
628
1581
|
}
|
|
1582
|
+
|
|
1583
|
+
const statusCode = Number(err && err.statusCode);
|
|
1584
|
+
const statusMatch = normalized.match(/\bmcp http (402|403)\b/);
|
|
1585
|
+
let persistentStatus = null;
|
|
1586
|
+
if (statusCode === 402 || statusCode === 403) {
|
|
1587
|
+
persistentStatus = statusCode;
|
|
1588
|
+
} else if (statusMatch) {
|
|
1589
|
+
persistentStatus = Number(statusMatch[1]);
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
if (persistentStatus === 402) {
|
|
1593
|
+
return {
|
|
1594
|
+
code: "http_402",
|
|
1595
|
+
reason: "Meko rejected capture with HTTP 402 (payment required)",
|
|
1596
|
+
scope: "account",
|
|
1597
|
+
detail,
|
|
1598
|
+
};
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
if (persistentStatus === 403) {
|
|
1602
|
+
// A 403 whose body is an HTML error page never came from the MCP service —
|
|
1603
|
+
// the service answers with JSON. Auth failures arrive as 401 with a JSON
|
|
1604
|
+
// body, so this is not a credential problem. A JSON 403 with no recognized
|
|
1605
|
+
// pattern stays account-scoped: it may apply to every write.
|
|
1606
|
+
//
|
|
1607
|
+
// HTML alone is only a candidate for exchange-scope. The drain loop still
|
|
1608
|
+
// requires an intervening success before a second consecutive drop, because
|
|
1609
|
+
// request-wide edge blocks (rate limit, IP, missing UA historically) share
|
|
1610
|
+
// this shape and would otherwise discard the whole session.
|
|
1611
|
+
const fromEdge = isHtmlErrorDetail(detail);
|
|
1612
|
+
return {
|
|
1613
|
+
code: fromEdge ? "content_rejected_403" : "http_403",
|
|
1614
|
+
// Keep exchange reasons short and free of HTML so SessionStart notices
|
|
1615
|
+
// and drop_reasons do not inject error-page markup into model context.
|
|
1616
|
+
reason: fromEdge
|
|
1617
|
+
? forExchange
|
|
1618
|
+
? "edge filtering rejected the request body (HTTP 403); this exchange's content cannot be stored as-is"
|
|
1619
|
+
: "edge filtering rejected the request (HTTP 403)"
|
|
1620
|
+
: `Meko rejected capture with HTTP 403${detail ? `: ${detail}` : ""}`,
|
|
1621
|
+
scope: fromEdge && forExchange ? "exchange" : "account",
|
|
1622
|
+
detail,
|
|
1623
|
+
};
|
|
1624
|
+
}
|
|
1625
|
+
|
|
629
1626
|
return null;
|
|
630
1627
|
}
|
|
631
1628
|
|
|
1629
|
+
/**
|
|
1630
|
+
* Grace period before a closed session whose transcript has vanished is
|
|
1631
|
+
* finalized. Default 1 day; MEKO_TRANSCRIPT_MISSING_GRACE overrides (seconds).
|
|
1632
|
+
*/
|
|
1633
|
+
function transcriptMissingGraceSeconds() {
|
|
1634
|
+
const raw = process.env.MEKO_TRANSCRIPT_MISSING_GRACE;
|
|
1635
|
+
const n = raw == null || raw === "" ? NaN : Number.parseInt(String(raw), 10);
|
|
1636
|
+
return Number.isFinite(n) && n >= 0 ? n : 86400;
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
function coerceQueued(state) {
|
|
1640
|
+
const n = Number(state && state.queued_exchanges);
|
|
1641
|
+
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
/** Consecutive not_found drains required before opening a replacement. */
|
|
1645
|
+
const MISSING_CONVERSATION_RECOVERY_POLLS = 2;
|
|
1646
|
+
|
|
1647
|
+
function missingConversationStreak(state) {
|
|
1648
|
+
const n = Number(state && state.missing_conversation_streak);
|
|
1649
|
+
return Number.isInteger(n) && n > 0 ? n : 0;
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
/**
|
|
1653
|
+
* Clear failure fields after a drain delivers all outstanding work.
|
|
1654
|
+
*
|
|
1655
|
+
* These fields describe the last attempt. Leaving them set after the queue
|
|
1656
|
+
* drains makes sessionHealthBucket() report a resolved failure as degraded.
|
|
1657
|
+
*/
|
|
1658
|
+
function clearResolvedFailure(state) {
|
|
1659
|
+
state.failure_class = null;
|
|
1660
|
+
state.last_error = null;
|
|
1661
|
+
state.next_retry_at = null;
|
|
1662
|
+
state.attempt_count = 0;
|
|
1663
|
+
state.blocked_reason = null;
|
|
1664
|
+
state.recovered_from_conversation_id = null;
|
|
1665
|
+
state.missing_conversation_streak = null;
|
|
1666
|
+
return state;
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
/**
|
|
1670
|
+
* Detect a conversation the server no longer has.
|
|
1671
|
+
*
|
|
1672
|
+
* `conversation_add_message` answers `not_found` when its conversation id is
|
|
1673
|
+
* gone: deleted server-side, expired, or created against a different Langfuse
|
|
1674
|
+
* project than the one the current datapack resolves to. classifyFailure() has
|
|
1675
|
+
* no pattern for it, so it falls through to the "transient" default and the
|
|
1676
|
+
* outbox retries the same dead id forever: three sessions reached 609, 441 and
|
|
1677
|
+
* 440 attempts holding 105 turns that could never land.
|
|
1678
|
+
*
|
|
1679
|
+
* Callers treat a match as a signal to open a fresh conversation and move the
|
|
1680
|
+
* undelivered tail into it (see recoverMissingConversation).
|
|
1681
|
+
*
|
|
1682
|
+
* The match keys on the server's detail, `Conversation <id> not found`
|
|
1683
|
+
* (src/tools.py). The tool name in the error prefix also contains
|
|
1684
|
+
* "conversation", so matching on the word alone would catch every not_found.
|
|
1685
|
+
* The server sends the same detail when a Langfuse lookup fails transiently or
|
|
1686
|
+
* the trace is too large to read (MEKO-452, MEKO-714), so a match is not proof
|
|
1687
|
+
* the conversation is gone. Recovery waits until the same detail persists
|
|
1688
|
+
* across MISSING_CONVERSATION_RECOVERY_POLLS drains, preserves the cursor, and
|
|
1689
|
+
* never copies turns that the old conversation already accepted.
|
|
1690
|
+
*/
|
|
1691
|
+
function isMissingConversationError(err) {
|
|
1692
|
+
const message = err && err.message ? String(err.message) : String(err || "");
|
|
1693
|
+
const normalized = message.toLowerCase();
|
|
1694
|
+
if (!/not[_\s-]?found/.test(normalized)) return false;
|
|
1695
|
+
return /conversation\s+\S+\s+not\s+found/.test(normalized);
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
/**
|
|
1699
|
+
* Recover from a conversation the server no longer has by opening a new one and
|
|
1700
|
+
* moving the undelivered tail to it.
|
|
1701
|
+
*
|
|
1702
|
+
* The server can also return the same not_found detail for a transient Langfuse
|
|
1703
|
+
* lookup failure. drainSession() calls this only after that detail has persisted
|
|
1704
|
+
* across two drains, and this function keeps last_line_number so turns already
|
|
1705
|
+
* accepted by the old conversation are not copied. conversation_add_message
|
|
1706
|
+
* checks ownership before storing a turn, so the failed exchange and the rest
|
|
1707
|
+
* of the undelivered tail remain safe to move.
|
|
1708
|
+
*
|
|
1709
|
+
* The new conversation is created without `session_id`. The server derives the
|
|
1710
|
+
* id from `session_id`, so passing it again would return the conversation that
|
|
1711
|
+
* just failed, and recovery would keep retrying the dead id.
|
|
1712
|
+
*
|
|
1713
|
+
* On create failure the caller keeps its normal retry path, so a network blip
|
|
1714
|
+
* here costs a retry rather than the queue.
|
|
1715
|
+
*
|
|
1716
|
+
* @returns {Promise<{ok: boolean, state?: object, convId?: string, error?: string}>}
|
|
1717
|
+
*/
|
|
1718
|
+
async function recoverMissingConversation({
|
|
1719
|
+
state,
|
|
1720
|
+
sessionId,
|
|
1721
|
+
agentId,
|
|
1722
|
+
transcriptPath,
|
|
1723
|
+
datapackId = null,
|
|
1724
|
+
}) {
|
|
1725
|
+
let newConvId = null;
|
|
1726
|
+
try {
|
|
1727
|
+
newConvId = await createConversation(
|
|
1728
|
+
sessionId || "unknown",
|
|
1729
|
+
agentId,
|
|
1730
|
+
peekTranscriptMetadata(transcriptPath),
|
|
1731
|
+
datapackId,
|
|
1732
|
+
{ freshId: true },
|
|
1733
|
+
);
|
|
1734
|
+
} catch (err) {
|
|
1735
|
+
return { ok: false, error: `conversation_create failed: ${err.message}` };
|
|
1736
|
+
}
|
|
1737
|
+
if (!newConvId) {
|
|
1738
|
+
return { ok: false, error: "conversation_create returned no ID" };
|
|
1739
|
+
}
|
|
1740
|
+
// A create that returns the failed id did not provide a recovery target.
|
|
1741
|
+
const sameId = (a, b) =>
|
|
1742
|
+
String(a || "").replace(/-/g, "").toLowerCase() ===
|
|
1743
|
+
String(b || "").replace(/-/g, "").toLowerCase();
|
|
1744
|
+
if (sameId(newConvId, state.conversation_id)) {
|
|
1745
|
+
return {
|
|
1746
|
+
ok: false,
|
|
1747
|
+
error: `conversation_create returned the missing conversation ${newConvId}`,
|
|
1748
|
+
};
|
|
1749
|
+
}
|
|
1750
|
+
const next = {
|
|
1751
|
+
...state,
|
|
1752
|
+
conversation_id: newConvId,
|
|
1753
|
+
conversation_epoch: Number(state.conversation_epoch || 0) + 1,
|
|
1754
|
+
in_flight: { seed: null, user_line: null, user_turn_id: null },
|
|
1755
|
+
delivery: "pending",
|
|
1756
|
+
failure_class: null,
|
|
1757
|
+
last_error: null,
|
|
1758
|
+
blocked_reason: null,
|
|
1759
|
+
next_retry_at: null,
|
|
1760
|
+
attempt_count: 0,
|
|
1761
|
+
missing_conversation_streak: null,
|
|
1762
|
+
};
|
|
1763
|
+
// Suppress another replacement until this conversation accepts a turn. A
|
|
1764
|
+
// fresh trace may not be visible yet when the next request reaches a
|
|
1765
|
+
// different server replica.
|
|
1766
|
+
next.recovered_from_conversation_id = state.conversation_id || null;
|
|
1767
|
+
const written = captureState.writeState(sessionId, next);
|
|
1768
|
+
if (!written.ok) {
|
|
1769
|
+
return { ok: false, error: `failed to persist recovered conversation: ${written.error}` };
|
|
1770
|
+
}
|
|
1771
|
+
return { ok: true, state: written.state || next, convId: newConvId };
|
|
1772
|
+
}
|
|
1773
|
+
|
|
632
1774
|
/**
|
|
633
1775
|
* Abort all pending MCP requests. Used by the preload timeout path so the
|
|
634
1776
|
* SessionStart hook can return promptly instead of waiting for a slow server
|
|
@@ -678,14 +1820,17 @@ async function mcpInitialize() {
|
|
|
678
1820
|
});
|
|
679
1821
|
}
|
|
680
1822
|
|
|
681
|
-
async function mcpCall(toolName, args) {
|
|
1823
|
+
async function mcpCall(toolName, args, options) {
|
|
682
1824
|
mcpRequestId++;
|
|
683
|
-
const result = await mcpPost(
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
1825
|
+
const result = await mcpPost(
|
|
1826
|
+
{
|
|
1827
|
+
jsonrpc: "2.0",
|
|
1828
|
+
method: "tools/call",
|
|
1829
|
+
params: { name: toolName, arguments: args },
|
|
1830
|
+
id: mcpRequestId,
|
|
1831
|
+
},
|
|
1832
|
+
options,
|
|
1833
|
+
);
|
|
689
1834
|
return result.body;
|
|
690
1835
|
}
|
|
691
1836
|
|
|
@@ -747,21 +1892,29 @@ function peekTranscriptMetadata(transcriptPath) {
|
|
|
747
1892
|
* best-effort enrichment, not a correctness requirement.
|
|
748
1893
|
*/
|
|
749
1894
|
async function fetchRecentMemories(agentId, datapackId = null, { limit = 10, budget = 2000 } = {}) {
|
|
1895
|
+
// memory_get_all takes no `limit`, and FastMCP rejects an unknown argument
|
|
1896
|
+
// with an isError result, so `limit` caps the page client-side instead.
|
|
750
1897
|
const payload = {
|
|
751
1898
|
agent_id: agentId,
|
|
752
1899
|
conversation_id: "00000000-0000-0000-0000-000000000000",
|
|
753
|
-
limit,
|
|
754
1900
|
};
|
|
755
1901
|
if (datapackId) payload.datapack_id = datapackId;
|
|
756
1902
|
const response = await mcpCall("memory_get_all", payload);
|
|
1903
|
+
if (response && response.result && response.result.isError) {
|
|
1904
|
+
const detail = ((response.result.content || [])[0] || {}).text || "";
|
|
1905
|
+
process.stderr.write(`[meko-capture] memory preload failed: ${truncate(detail.replace(/\s+/g, " "), 300)}\n`);
|
|
1906
|
+
return [];
|
|
1907
|
+
}
|
|
757
1908
|
const result = extractToolResult(response);
|
|
758
1909
|
if (!result) return [];
|
|
759
1910
|
|
|
760
1911
|
const entries = Array.isArray(result) ? result : result.memories || result.results || [];
|
|
761
1912
|
const summaries = [];
|
|
762
1913
|
let used = 0;
|
|
763
|
-
for (const entry of entries) {
|
|
764
|
-
|
|
1914
|
+
for (const entry of entries.slice(0, limit)) {
|
|
1915
|
+
// The meko API returns the text as `data`; `memory` is the mem0 shape
|
|
1916
|
+
// servers returned before MEKO-216.
|
|
1917
|
+
const text = entry.data || entry.memory || entry.text || entry.content || "";
|
|
765
1918
|
if (!text) continue;
|
|
766
1919
|
const line = `- ${String(text).replace(/\s+/g, " ").trim()}`;
|
|
767
1920
|
if (used + line.length + 1 > budget) break;
|
|
@@ -771,12 +1924,31 @@ async function fetchRecentMemories(agentId, datapackId = null, { limit = 10, bud
|
|
|
771
1924
|
return summaries;
|
|
772
1925
|
}
|
|
773
1926
|
|
|
774
|
-
|
|
1927
|
+
/**
|
|
1928
|
+
* The server derives the conversation id from `session_id`, so the same session
|
|
1929
|
+
* always maps to the same conversation. Pass `freshId: true` to omit
|
|
1930
|
+
* `session_id` and have the server mint a new id instead. Recovery needs this:
|
|
1931
|
+
* re-creating with the session id returns the conversation that just failed.
|
|
1932
|
+
*/
|
|
1933
|
+
async function createConversation(
|
|
1934
|
+
sessionId,
|
|
1935
|
+
agentId,
|
|
1936
|
+
metadata = null,
|
|
1937
|
+
datapackId = null,
|
|
1938
|
+
{ freshId = false } = {},
|
|
1939
|
+
) {
|
|
1940
|
+
const clientLabel = {
|
|
1941
|
+
claude_code: "Claude Code",
|
|
1942
|
+
cursor: "Cursor",
|
|
1943
|
+
codex: "Codex",
|
|
1944
|
+
kiro: "Kiro",
|
|
1945
|
+
opencode: "OpenCode",
|
|
1946
|
+
}[resolveHookClient()] || "Coding agent";
|
|
775
1947
|
const payload = {
|
|
776
1948
|
agent_id: agentId,
|
|
777
|
-
title:
|
|
778
|
-
session_id: sessionId,
|
|
1949
|
+
title: `${clientLabel} session (auto-captured)`,
|
|
779
1950
|
};
|
|
1951
|
+
if (!freshId) payload.session_id = sessionId;
|
|
780
1952
|
if (metadata) {
|
|
781
1953
|
payload.metadata = JSON.stringify({
|
|
782
1954
|
source: "hook",
|
|
@@ -788,10 +1960,122 @@ async function createConversation(sessionId, agentId, metadata = null, datapackI
|
|
|
788
1960
|
if (datapackId) payload.datapack_id = datapackId;
|
|
789
1961
|
const response = await mcpCall("conversation_create", payload);
|
|
790
1962
|
const result = extractToolResult(response);
|
|
1963
|
+
// MEKO-592: the server now returns a structured error when no datapack_id
|
|
1964
|
+
// resolves (missing pin, no server-side default). Before this guard the
|
|
1965
|
+
// server returned a success payload with a fabricated conversation id and
|
|
1966
|
+
// every subsequent add_message came back not_found — capture was silently
|
|
1967
|
+
// dead. Propagate the error like conversation_add_message already does so
|
|
1968
|
+
// callers can distinguish "server unreachable" from "action required (pin
|
|
1969
|
+
// a datapack)".
|
|
1970
|
+
if (result && result.error) {
|
|
1971
|
+
const err = new Error(`conversation_create failed: ${JSON.stringify(result.error)}`);
|
|
1972
|
+
err.code = result.error;
|
|
1973
|
+
err.detail = result.detail || "";
|
|
1974
|
+
// Distinguish a server-returned structured error from a Node system error
|
|
1975
|
+
// (e.g. ECONNREFUSED off `fetch`), which also carries `.code`. The catch
|
|
1976
|
+
// site branches on this to keep "MCP unavailable" reserved for transport
|
|
1977
|
+
// failures.
|
|
1978
|
+
err.isMcpToolError = true;
|
|
1979
|
+
throw err;
|
|
1980
|
+
}
|
|
791
1981
|
return result && result.id ? result.id : null;
|
|
792
1982
|
}
|
|
793
1983
|
|
|
794
|
-
|
|
1984
|
+
/**
|
|
1985
|
+
* Ensure the conversation we are about to write to is owned by `resolvedAgentId`.
|
|
1986
|
+
*
|
|
1987
|
+
* The server pins a conversation's owner at `conversation_create` and rejects
|
|
1988
|
+
* `conversation_add_message` / `conversation_update` whose agent_id differs
|
|
1989
|
+
* (src/tools.py → `agent_id_mismatch`). There is no owner-reassign tool. So
|
|
1990
|
+
* when MEKO-385 reconciliation changes the identity (e.g. a conversation the
|
|
1991
|
+
* OLD Codex hook created under `claude_code:<repo>` must now be `codex:<repo>`),
|
|
1992
|
+
* we CANNOT keep writing to the old conversation — every future write would
|
|
1993
|
+
* fail closed and wedge the watermark. Instead we open a NEW conversation
|
|
1994
|
+
* under the corrected agent_id and repoint the watermark at it (approach (a);
|
|
1995
|
+
* no server reassign exists).
|
|
1996
|
+
*
|
|
1997
|
+
* The invariant this preserves: a watermark's `agent_id` always equals the
|
|
1998
|
+
* owner of its `conversation_id`. So callers can detect a stale owner simply
|
|
1999
|
+
* by comparing the cached agent_id against the freshly resolved one.
|
|
2000
|
+
*
|
|
2001
|
+
* On rebucket-create FAILURE the fallback depends on WHY the rebucket was
|
|
2002
|
+
* demanded:
|
|
2003
|
+
* - EXPLICIT override (resolvedSource === "explicit", i.e. the user set
|
|
2004
|
+
* MEKO_AGENT_ID): do NOT fall back to capturing under the stale owner —
|
|
2005
|
+
* that would silently attribute turns to the wrong namespace in direct
|
|
2006
|
+
* violation of the explicit override. Instead HOLD: return `{ hold: true }`
|
|
2007
|
+
* so the caller sends no turns and does not advance the watermark, and a
|
|
2008
|
+
* later hook retries the rebucket. A visible diagnostic is emitted.
|
|
2009
|
+
* - DERIVED (auto-derived id): fall back to the ORIGINAL owner (old
|
|
2010
|
+
* conversation + old agent_id) so writes still succeed and the watermark
|
|
2011
|
+
* advances — strictly better than failing closed; a later hook retries.
|
|
2012
|
+
*
|
|
2013
|
+
* The invariant this preserves: a watermark's `agent_id` always equals the
|
|
2014
|
+
* owner of its `conversation_id`.
|
|
2015
|
+
*
|
|
2016
|
+
* @returns {Promise<{convId: string, agentId: string, source: string,
|
|
2017
|
+
* rebucketed: boolean, hold?: boolean}>}
|
|
2018
|
+
*/
|
|
2019
|
+
async function ensureConversationOwner({
|
|
2020
|
+
convId,
|
|
2021
|
+
cachedOwner,
|
|
2022
|
+
resolvedAgentId,
|
|
2023
|
+
resolvedSource,
|
|
2024
|
+
sessionId,
|
|
2025
|
+
metadata = null,
|
|
2026
|
+
datapackId = null,
|
|
2027
|
+
mcpReady = false,
|
|
2028
|
+
}) {
|
|
2029
|
+
if (!ownerChanged(cachedOwner, resolvedAgentId, convId)) {
|
|
2030
|
+
return { convId, agentId: resolvedAgentId, source: resolvedSource, rebucketed: false };
|
|
2031
|
+
}
|
|
2032
|
+
try {
|
|
2033
|
+
if (!mcpReady) await mcpInitialize();
|
|
2034
|
+
const newId = await createConversation(
|
|
2035
|
+
sessionId || "unknown",
|
|
2036
|
+
resolvedAgentId,
|
|
2037
|
+
metadata,
|
|
2038
|
+
datapackId,
|
|
2039
|
+
);
|
|
2040
|
+
if (newId) {
|
|
2041
|
+
process.stderr.write(
|
|
2042
|
+
`[meko-capture] Rebucketed conversation ${convId} (owner ${cachedOwner || "unknown"}) -> ` +
|
|
2043
|
+
`new ${newId} owned by ${resolvedAgentId} (server pins owner at create).\n`,
|
|
2044
|
+
);
|
|
2045
|
+
return { convId: newId, agentId: resolvedAgentId, source: resolvedSource, rebucketed: true };
|
|
2046
|
+
}
|
|
2047
|
+
process.stderr.write("[meko-capture] Rebucket conversation_create returned no ID.\n");
|
|
2048
|
+
} catch (err) {
|
|
2049
|
+
process.stderr.write(`[meko-capture] Rebucket conversation_create failed (${err.message}).\n`);
|
|
2050
|
+
}
|
|
2051
|
+
// Create failed. An EXPLICIT override must never be captured under the stale
|
|
2052
|
+
// owner — hold and retry rather than write to the wrong namespace.
|
|
2053
|
+
if (resolvedSource === "explicit") {
|
|
2054
|
+
process.stderr.write(
|
|
2055
|
+
`[meko-capture] Explicit MEKO_AGENT_ID override requires rebucketing to ` +
|
|
2056
|
+
`${resolvedAgentId}, but conversation_create failed; HOLDING (no turns sent, ` +
|
|
2057
|
+
`watermark not advanced) so a later hook retries — will not capture under the ` +
|
|
2058
|
+
`stale owner ${cachedOwner || "unknown"}.\n`,
|
|
2059
|
+
);
|
|
2060
|
+
return { convId, agentId: resolvedAgentId, source: resolvedSource, rebucketed: false, hold: true };
|
|
2061
|
+
}
|
|
2062
|
+
// Derived case: fall back to the original owner so writes still succeed.
|
|
2063
|
+
// (When the owner is unknown/empty there is nothing safe to fall back to —
|
|
2064
|
+
// hold instead, since writing under an unknown owner risks agent_id_mismatch.)
|
|
2065
|
+
if (!(cachedOwner || "").trim()) {
|
|
2066
|
+
process.stderr.write(
|
|
2067
|
+
"[meko-capture] Unknown-owner conversation and rebucket create failed; " +
|
|
2068
|
+
"HOLDING (no turns sent) so a later hook retries.\n",
|
|
2069
|
+
);
|
|
2070
|
+
return { convId, agentId: resolvedAgentId, source: resolvedSource, rebucketed: false, hold: true };
|
|
2071
|
+
}
|
|
2072
|
+
process.stderr.write(
|
|
2073
|
+
`[meko-capture] Keeping original owner ${cachedOwner} so writes still succeed.\n`,
|
|
2074
|
+
);
|
|
2075
|
+
return { convId, agentId: cachedOwner, source: "derived", rebucketed: false };
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
async function addMessage(convId, agentId, exchange, datapackId = null, timeoutMs = null) {
|
|
795
2079
|
const seed = `${convId}:${exchange.user_uuid}`;
|
|
796
2080
|
const payload = {
|
|
797
2081
|
conversation_id: convId,
|
|
@@ -802,7 +2086,15 @@ async function addMessage(convId, agentId, exchange, datapackId = null) {
|
|
|
802
2086
|
seed: seed,
|
|
803
2087
|
};
|
|
804
2088
|
if (datapackId) payload.datapack_id = datapackId;
|
|
805
|
-
|
|
2089
|
+
if (exchange.metadata != null) {
|
|
2090
|
+
payload.metadata =
|
|
2091
|
+
typeof exchange.metadata === "string"
|
|
2092
|
+
? exchange.metadata
|
|
2093
|
+
: JSON.stringify(exchange.metadata);
|
|
2094
|
+
}
|
|
2095
|
+
const response = await mcpCall("conversation_add_message", payload, {
|
|
2096
|
+
timeoutMs: timeoutMs || MEKO_ADD_MESSAGE_TIMEOUT,
|
|
2097
|
+
});
|
|
806
2098
|
// FAIL CLOSED. The MCP transport resolves successfully even when the tool
|
|
807
2099
|
// failed, and failures take several shapes: an `isError` result envelope, a
|
|
808
2100
|
// JSON body with an `error` field, or a non-JSON / missing-content / otherwise
|
|
@@ -823,7 +2115,22 @@ async function addMessage(convId, agentId, exchange, datapackId = null) {
|
|
|
823
2115
|
);
|
|
824
2116
|
}
|
|
825
2117
|
if (result.error) {
|
|
826
|
-
|
|
2118
|
+
// Keep the server's detail: isMissingConversationError() keys on it.
|
|
2119
|
+
const detail = typeof result.detail === "string" && result.detail ? `: ${result.detail}` : "";
|
|
2120
|
+
throw new Error(`conversation_add_message failed: ${JSON.stringify(result.error)}${detail}`);
|
|
2121
|
+
}
|
|
2122
|
+
// Contracts: advance only after a structurally valid accepted response that
|
|
2123
|
+
// contains a message ID (server returns message_id; tolerate `id` aliases).
|
|
2124
|
+
const messageId = result.message_id || result.id || null;
|
|
2125
|
+
if (!messageId) {
|
|
2126
|
+
throw new Error(
|
|
2127
|
+
"conversation_add_message: accepted response missing message_id (fail-closed)",
|
|
2128
|
+
);
|
|
2129
|
+
}
|
|
2130
|
+
if (result.status !== "accepted") {
|
|
2131
|
+
throw new Error(
|
|
2132
|
+
`conversation_add_message: unexpected status ${JSON.stringify(result.status)}`,
|
|
2133
|
+
);
|
|
827
2134
|
}
|
|
828
2135
|
return result;
|
|
829
2136
|
}
|
|
@@ -889,17 +2196,28 @@ function sessionCachePath(sessionId) {
|
|
|
889
2196
|
function writeSessionCache(sessionId, payload) {
|
|
890
2197
|
const filePath = sessionCachePath(sessionId);
|
|
891
2198
|
if (!filePath) return;
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
2199
|
+
try {
|
|
2200
|
+
const workspaceKey = payload.workspace_key || "";
|
|
2201
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
2202
|
+
const record = {
|
|
2203
|
+
session_id: sessionId,
|
|
2204
|
+
updated_at: new Date().toISOString(),
|
|
2205
|
+
...payload,
|
|
2206
|
+
};
|
|
2207
|
+
fs.writeFileSync(filePath, JSON.stringify(record));
|
|
2208
|
+
if (workspaceKey) {
|
|
2209
|
+
const safe = workspaceKey.replace(/[^A-Za-z0-9_.-]/g, "_");
|
|
2210
|
+
fs.writeFileSync(
|
|
2211
|
+
path.join(SESSION_CACHE_DIR, `workspace-${safe}.json`),
|
|
2212
|
+
JSON.stringify(record),
|
|
2213
|
+
);
|
|
2214
|
+
}
|
|
2215
|
+
} catch (err) {
|
|
2216
|
+
// Cursor's session cache is a rebuildable convenience; it must never turn
|
|
2217
|
+
// a valid SessionStart context into an empty fallback response.
|
|
2218
|
+
process.stderr.write(
|
|
2219
|
+
`[meko-capture] SessionStart cache write skipped: ${err.message}\n`,
|
|
2220
|
+
);
|
|
903
2221
|
}
|
|
904
2222
|
}
|
|
905
2223
|
|
|
@@ -916,8 +2234,6 @@ function workspaceKeyFromHookInput(hookInput, transcriptPath) {
|
|
|
916
2234
|
return cwd || process.cwd();
|
|
917
2235
|
}
|
|
918
2236
|
|
|
919
|
-
// --- Main ---
|
|
920
|
-
|
|
921
2237
|
// --- SessionStart: create conversation + watermark deterministically ---
|
|
922
2238
|
|
|
923
2239
|
function findMostRecentProjectWatermark(transcriptPath, currentSessionId) {
|
|
@@ -938,6 +2254,7 @@ function findMostRecentProjectWatermark(transcriptPath, currentSessionId) {
|
|
|
938
2254
|
sessionId: sid,
|
|
939
2255
|
conversation_id: wm.conversation_id,
|
|
940
2256
|
agent_id: wm.agent_id || "",
|
|
2257
|
+
agent_id_source: wm.agent_id_source || "",
|
|
941
2258
|
datapack_id: wm.datapack_id || null,
|
|
942
2259
|
datapack_name: wm.datapack_name || null,
|
|
943
2260
|
updated_at: ts,
|
|
@@ -955,17 +2272,9 @@ function buildSessionStartContext(convId, sessionId, agentId, memories, opts) {
|
|
|
955
2272
|
const resolvedAgentId = agentId || COMMON_BUCKET_AGENT_ID;
|
|
956
2273
|
const resumed = Boolean(opts && opts.resumed);
|
|
957
2274
|
const pin = opts && opts.datapackPin ? opts.datapackPin : null;
|
|
958
|
-
const captureError = opts && opts.captureError ? opts.captureError : null;
|
|
959
2275
|
const opening = resumed
|
|
960
2276
|
? `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
2277
|
: `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
2278
|
const memoryBlock =
|
|
970
2279
|
Array.isArray(memories) && memories.length > 0
|
|
971
2280
|
? `
|
|
@@ -981,11 +2290,10 @@ ${memories.join("\n")}`
|
|
|
981
2290
|
return `## Meko Session Active
|
|
982
2291
|
|
|
983
2292
|
${opening}
|
|
984
|
-
${captureWarning}
|
|
985
2293
|
|
|
986
2294
|
### What you MUST do with Meko tools
|
|
987
2295
|
|
|
988
|
-
- **agent_id**: use "${resolvedAgentId}" verbatim for every Meko MCP tool call in this session.
|
|
2296
|
+
- **agent_id**: use "${resolvedAgentId}" verbatim for every Meko MCP tool call in this session. Project-scoped ids have the shape \`<client>:<repo-basename>\`, where the repo root is found by walking parents of the session cwd for \`.git\`, so memories stay scoped to this repo; sessions outside any repo use the bare client name (e.g. \`claude_code\`) rather than a colliding directory basename. A resumed session, or one that re-fires SessionStart (\`/clear\`, \`/compact\`), keeps the bucket its conversation already belongs to, and \`MEKO_AGENT_ID\` overrides either — so take the value above as given rather than re-deriving it yourself. For genuinely cross-project facts (user identity, global preferences) pass agent_id="${COMMON_BUCKET_AGENT_ID}" — that's the common bucket any agent can read regardless of project.
|
|
989
2297
|
- **conversation_id**: Use "${convId}" for all MCP tool calls that accept it
|
|
990
2298
|
|
|
991
2299
|
### Memory capture is automatic only when hook delivery succeeds — do NOT proactively call memory_add
|
|
@@ -999,7 +2307,7 @@ Call memory_add (with the session's agent_id + conversation_id) ONLY for the nar
|
|
|
999
2307
|
|
|
1000
2308
|
### Recall and before destructive actions
|
|
1001
2309
|
|
|
1002
|
-
- User asks "what do you know about X?" → call
|
|
2310
|
+
- User asks "what do you know about X?" → call context_search first (memory, knowledge base, and past conversations in one call). Confirm an empty memory or knowledge-base result with memory_search or knowledgebase_search before saying nothing is stored.
|
|
1003
2311
|
- Before any action that deletes, overwrites, or restructures user content (Write-overwrite, large Edit deletions, rm, git reset --hard, destructive SQL), call memory_search for relevant feedback and confirm with the user if any prior guidance applies.
|
|
1004
2312
|
|
|
1005
2313
|
### Use Meko, not local storage
|
|
@@ -1010,9 +2318,17 @@ Never write memories to local files (e.g., ~/.claude/projects/.../memory/). Meko
|
|
|
1010
2318
|
function buildSessionStartFallbackContext(agentId, opts) {
|
|
1011
2319
|
const resolvedAgentId = agentId || COMMON_BUCKET_AGENT_ID;
|
|
1012
2320
|
const datapackBlock = buildActiveDatapackBlock(opts && opts.datapackPin ? opts.datapackPin : null);
|
|
2321
|
+
// MEKO-592: the "server unreachable" preamble is the wrong story when the
|
|
2322
|
+
// server accepted the call and told us to pin a datapack. Callers pass
|
|
2323
|
+
// reason:"datapack_required" to swap it for setup guidance the agent can
|
|
2324
|
+
// act on.
|
|
2325
|
+
const reason = opts && opts.reason;
|
|
2326
|
+
const preamble = reason === "datapack_required"
|
|
2327
|
+
? "The SessionStart hook reached the Meko MCP server, but no datapack is pinned for this project, so the server declined to create a conversation. **Automatic conversation capture is idle**. Use `datapack_list` to find a datapack id, then invoke the `meko-select-datapack` skill (or your client's equivalent) to pin it. The next drain picks up the fresh pin and starts capturing this session's queued turns; until then, save facts explicitly."
|
|
2328
|
+
: "The SessionStart hook could not reach the Meko MCP server to create a conversation. **Automatic conversation capture is NOT running this session**, so the server-side extraction that normally saves memories for you will not fire. Fall back to saving facts explicitly.";
|
|
1013
2329
|
return `## Meko Memory Instructions
|
|
1014
2330
|
|
|
1015
|
-
|
|
2331
|
+
${preamble}
|
|
1016
2332
|
|
|
1017
2333
|
### What you should do while capture is down
|
|
1018
2334
|
|
|
@@ -1024,7 +2340,7 @@ The SessionStart hook could not reach the Meko MCP server to create a conversati
|
|
|
1024
2340
|
Because nothing is capturing this session, explicit saves are the only path — this is the exception, not the normal mode:
|
|
1025
2341
|
- User shares personal info, preferences, or role → call memory_add immediately
|
|
1026
2342
|
- User shares org conventions or team standards → call memory_add immediately
|
|
1027
|
-
- User asks "what do you know about X?" → call
|
|
2343
|
+
- User asks "what do you know about X?" → call context_search first
|
|
1028
2344
|
|
|
1029
2345
|
### Use Meko, not local storage
|
|
1030
2346
|
|
|
@@ -1074,6 +2390,18 @@ function handleBeforeSubmitPrompt(hookInput) {
|
|
|
1074
2390
|
process.stdout.write(beforeSubmitOutput("allow"));
|
|
1075
2391
|
}
|
|
1076
2392
|
|
|
2393
|
+
function withHealthNotice(context) {
|
|
2394
|
+
try {
|
|
2395
|
+
const { injectHealthNotice } = require("./capture-notices");
|
|
2396
|
+
return injectHealthNotice(context);
|
|
2397
|
+
} catch (err) {
|
|
2398
|
+
process.stderr.write(
|
|
2399
|
+
`[meko-capture] SessionStart health notice skipped: ${err && err.message ? err.message : err}\n`,
|
|
2400
|
+
);
|
|
2401
|
+
return context;
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2404
|
+
|
|
1077
2405
|
async function handleSessionStart(hookInput) {
|
|
1078
2406
|
const transcriptPath =
|
|
1079
2407
|
hookInput.transcript_path ||
|
|
@@ -1082,37 +2410,107 @@ async function handleSessionStart(hookInput) {
|
|
|
1082
2410
|
const sessionId = extractSessionId(hookInput, transcriptPath);
|
|
1083
2411
|
|
|
1084
2412
|
const source = hookInput.source || "";
|
|
1085
|
-
const
|
|
2413
|
+
const sessionCwd = pickSessionCwd(transcriptPath, hookInput);
|
|
2414
|
+
const derivedAgentId = resolveSessionAgentId(transcriptPath, hookInput, sessionCwd);
|
|
2415
|
+
const legacyAgentId = deriveLegacyAgentId({
|
|
2416
|
+
client: resolveHookClient(),
|
|
2417
|
+
cwd: sessionCwd,
|
|
2418
|
+
envOverride: process.env.MEKO_AGENT_ID,
|
|
2419
|
+
});
|
|
2420
|
+
logAgentIdDerivation(sessionCwd, derivedAgentId);
|
|
1086
2421
|
const workspaceKey = workspaceKeyFromHookInput(hookInput, transcriptPath);
|
|
1087
2422
|
|
|
1088
2423
|
// Check if watermark already exists (same session ID re-fires SessionStart, e.g. /clear, /compact)
|
|
1089
2424
|
if (sessionId) {
|
|
1090
2425
|
const wmPath = path.join(WATERMARK_DIR, `${sessionId}.watermark.json`);
|
|
1091
2426
|
const existing = readWatermark(wmPath);
|
|
2427
|
+
if (existing.__corrupt) {
|
|
2428
|
+
process.stderr.write(
|
|
2429
|
+
`[meko-capture] SessionStart: corrupt watermark for ${sessionId} (${existing.error}); blocking.\n`,
|
|
2430
|
+
);
|
|
2431
|
+
const context = withHealthNotice(buildSessionStartFallbackContext(derivedAgentId, {
|
|
2432
|
+
datapackPin: readDatapackPin(derivedAgentId, legacyAgentId),
|
|
2433
|
+
}));
|
|
2434
|
+
process.stdout.write(hookOutput(context));
|
|
2435
|
+
return;
|
|
2436
|
+
}
|
|
1092
2437
|
if (existing.conversation_id) {
|
|
1093
2438
|
process.stderr.write(`[meko-capture] Session ${sessionId}: reusing existing conversation ${existing.conversation_id}\n`);
|
|
1094
2439
|
// 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
|
-
|
|
2440
|
+
// re-fires of SessionStart (e.g. /clear, /compact), BUT re-derive when
|
|
2441
|
+
// the cached value is a stale auto-derived coding identity that no
|
|
2442
|
+
// longer matches the current client — e.g. an old Codex hook wrote
|
|
2443
|
+
// `claude_code:<repo>` before MEKO-385. reconcileCachedAgentId honors
|
|
2444
|
+
// an explicit MEKO_AGENT_ID override (agent_id_source) and upgrades the
|
|
2445
|
+
// legacy literal "agent". See its doc for existing-conversation
|
|
2446
|
+
// consistency (same conversation_id; only future turns re-namespace).
|
|
2447
|
+
const reconciled = reconcileCachedAgentId(
|
|
2448
|
+
existing.agent_id,
|
|
2449
|
+
existing.agent_id_source || "",
|
|
2450
|
+
derivedAgentId,
|
|
2451
|
+
);
|
|
1101
2452
|
const existingDatapackPin = datapackPinFromWatermark(existing);
|
|
2453
|
+
// If the reconciled identity differs from the conversation's owner (the
|
|
2454
|
+
// cached agent_id), open a NEW conversation under it — the server pins
|
|
2455
|
+
// the owner at create and rejects mismatched writes, so keeping the old
|
|
2456
|
+
// conversation_id would wedge every future capture. ensureConversationOwner
|
|
2457
|
+
// is a no-op (no MCP) when the owner is unchanged.
|
|
2458
|
+
const owned = await ensureConversationOwner({
|
|
2459
|
+
convId: existing.conversation_id,
|
|
2460
|
+
cachedOwner: existing.agent_id,
|
|
2461
|
+
resolvedAgentId: reconciled.agentId,
|
|
2462
|
+
resolvedSource: reconciled.source,
|
|
2463
|
+
sessionId,
|
|
2464
|
+
metadata: peekTranscriptMetadata(transcriptPath),
|
|
2465
|
+
datapackId: existingDatapackPin && existingDatapackPin.datapack_id,
|
|
2466
|
+
});
|
|
2467
|
+
// HOLD: rebucket required but conversation_create failed. Leave the
|
|
2468
|
+
// existing watermark untouched (agent_id still equals the owner of its
|
|
2469
|
+
// conversation_id) and inject context under the actual owner so nothing
|
|
2470
|
+
// is written to the wrong namespace; a later hook retries the rebucket.
|
|
2471
|
+
if (owned.hold) {
|
|
2472
|
+
logEffectiveAgentId(derivedAgentId, existing.agent_id, "watermark-hold");
|
|
2473
|
+
const holdContext = buildSessionStartContext(
|
|
2474
|
+
existing.conversation_id,
|
|
2475
|
+
sessionId,
|
|
2476
|
+
existing.agent_id,
|
|
2477
|
+
null,
|
|
2478
|
+
{
|
|
2479
|
+
datapackPin: existingDatapackPin,
|
|
2480
|
+
},
|
|
2481
|
+
);
|
|
2482
|
+
process.stdout.write(hookOutput(withHealthNotice(holdContext)));
|
|
2483
|
+
return;
|
|
2484
|
+
}
|
|
2485
|
+
const resumedAgentId = owned.agentId;
|
|
2486
|
+
logEffectiveAgentId(derivedAgentId, resumedAgentId, "watermark-reconcile");
|
|
2487
|
+
const resumedConvId = owned.convId;
|
|
2488
|
+
// Persist the corrected identity + conversation (+ provenance) so the
|
|
2489
|
+
// checkpoint / PreCompact / SessionEnd hooks read a consistent watermark
|
|
2490
|
+
// (agent_id always equals the owner of conversation_id). On a rebucket
|
|
2491
|
+
// reset last_line_number to 0 so this session's turns replay into the
|
|
2492
|
+
// new conversation; otherwise preserve the prior line count.
|
|
2493
|
+
writeWatermark(
|
|
2494
|
+
wmPath,
|
|
2495
|
+
resumedConvId,
|
|
2496
|
+
owned.rebucketed ? 0 : (existing.last_line_number || 0),
|
|
2497
|
+
resumedAgentId,
|
|
2498
|
+
existingDatapackPin,
|
|
2499
|
+
owned.source,
|
|
2500
|
+
);
|
|
1102
2501
|
// Skip preload on resume (we don't know what's already in context).
|
|
1103
|
-
|
|
1104
|
-
|
|
2502
|
+
let context = withHealthNotice(buildSessionStartContext(
|
|
2503
|
+
resumedConvId,
|
|
1105
2504
|
sessionId,
|
|
1106
2505
|
resumedAgentId,
|
|
1107
2506
|
null,
|
|
1108
2507
|
{
|
|
1109
2508
|
datapackPin: existingDatapackPin,
|
|
1110
|
-
captureError: readCaptureError(resumedAgentId),
|
|
1111
2509
|
},
|
|
1112
|
-
);
|
|
2510
|
+
));
|
|
1113
2511
|
writeSessionCache(sessionId, {
|
|
1114
2512
|
status: "ready",
|
|
1115
|
-
conversation_id:
|
|
2513
|
+
conversation_id: resumedConvId,
|
|
1116
2514
|
agent_id: resumedAgentId,
|
|
1117
2515
|
workspace_key: workspaceKey,
|
|
1118
2516
|
additional_context: context,
|
|
@@ -1132,38 +2530,75 @@ async function handleSessionStart(hookInput) {
|
|
|
1132
2530
|
if (prior && prior.conversation_id) {
|
|
1133
2531
|
const currentLineCount = fs.existsSync(transcriptPath) ? countLines(transcriptPath) : 0;
|
|
1134
2532
|
const wmPath = path.join(WATERMARK_DIR, `${sessionId}.watermark.json`);
|
|
1135
|
-
// Preserve a
|
|
1136
|
-
//
|
|
1137
|
-
//
|
|
1138
|
-
//
|
|
1139
|
-
// literal "agent"
|
|
1140
|
-
//
|
|
1141
|
-
const
|
|
1142
|
-
|
|
1143
|
-
|
|
2533
|
+
// Preserve a user-set bucket (explicit MEKO_AGENT_ID, or a project-scoped
|
|
2534
|
+
// id from the original session) across the resume, but re-derive a stale
|
|
2535
|
+
// auto-derived coding identity that no longer matches the current client
|
|
2536
|
+
// (e.g. an old Codex hook wrote claude_code:<repo> pre-MEKO-385) and
|
|
2537
|
+
// upgrade the legacy literal "agent". Mirrors the same-session reuse
|
|
2538
|
+
// branch above via reconcileCachedAgentId.
|
|
2539
|
+
const reconciled = reconcileCachedAgentId(
|
|
2540
|
+
prior.agent_id,
|
|
2541
|
+
prior.agent_id_source || "",
|
|
2542
|
+
agentId,
|
|
2543
|
+
);
|
|
1144
2544
|
const priorDatapackPin = datapackPinFromWatermark(prior);
|
|
2545
|
+
// Rebucket to a new conversation when the reconciled identity differs
|
|
2546
|
+
// from the prior conversation's owner (server pins owner at create).
|
|
2547
|
+
const owned = await ensureConversationOwner({
|
|
2548
|
+
convId: prior.conversation_id,
|
|
2549
|
+
cachedOwner: prior.agent_id,
|
|
2550
|
+
resolvedAgentId: reconciled.agentId,
|
|
2551
|
+
resolvedSource: reconciled.source,
|
|
2552
|
+
sessionId,
|
|
2553
|
+
metadata: peekTranscriptMetadata(transcriptPath),
|
|
2554
|
+
datapackId: priorDatapackPin && priorDatapackPin.datapack_id,
|
|
2555
|
+
});
|
|
2556
|
+
// HOLD: rebucket required but conversation_create failed. Do not write a
|
|
2557
|
+
// watermark for this resumed session (no owner we can safely write under)
|
|
2558
|
+
// — inject context under the prior owner and let a later hook retry.
|
|
2559
|
+
if (owned.hold) {
|
|
2560
|
+
logEffectiveAgentId(derivedAgentId, prior.agent_id, "resume-hold");
|
|
2561
|
+
const holdContext = buildSessionStartContext(
|
|
2562
|
+
prior.conversation_id,
|
|
2563
|
+
sessionId,
|
|
2564
|
+
prior.agent_id,
|
|
2565
|
+
null,
|
|
2566
|
+
{
|
|
2567
|
+
resumed: true,
|
|
2568
|
+
datapackPin: priorDatapackPin,
|
|
2569
|
+
},
|
|
2570
|
+
);
|
|
2571
|
+
process.stdout.write(hookOutput(withHealthNotice(holdContext)));
|
|
2572
|
+
return;
|
|
2573
|
+
}
|
|
2574
|
+
const resumedAgentId = owned.agentId;
|
|
2575
|
+
logEffectiveAgentId(derivedAgentId, resumedAgentId, "resume-reconcile");
|
|
2576
|
+
const resumedConvId = owned.convId;
|
|
2577
|
+
// On a fresh rebucketed conversation, start the watermark at 0 so this
|
|
2578
|
+
// session's turns replay into it. Otherwise keep currentLineCount so the
|
|
2579
|
+
// replayed copied history isn't re-ingested.
|
|
1145
2580
|
writeWatermark(
|
|
1146
2581
|
wmPath,
|
|
1147
|
-
|
|
1148
|
-
currentLineCount,
|
|
2582
|
+
resumedConvId,
|
|
2583
|
+
owned.rebucketed ? 0 : currentLineCount,
|
|
1149
2584
|
resumedAgentId,
|
|
1150
2585
|
priorDatapackPin,
|
|
2586
|
+
owned.source,
|
|
1151
2587
|
);
|
|
1152
|
-
process.stderr.write(`[meko-capture] Session ${sessionId}: resuming conversation ${
|
|
1153
|
-
|
|
1154
|
-
|
|
2588
|
+
process.stderr.write(`[meko-capture] Session ${sessionId}: resuming conversation ${resumedConvId} from ${prior.sessionId}, skipping ${owned.rebucketed ? 0 : currentLineCount} copied lines\n`);
|
|
2589
|
+
let context = withHealthNotice(buildSessionStartContext(
|
|
2590
|
+
resumedConvId,
|
|
1155
2591
|
sessionId,
|
|
1156
2592
|
resumedAgentId,
|
|
1157
2593
|
null,
|
|
1158
2594
|
{
|
|
1159
2595
|
resumed: true,
|
|
1160
2596
|
datapackPin: priorDatapackPin,
|
|
1161
|
-
captureError: readCaptureError(resumedAgentId),
|
|
1162
2597
|
},
|
|
1163
|
-
);
|
|
2598
|
+
));
|
|
1164
2599
|
writeSessionCache(sessionId, {
|
|
1165
2600
|
status: "ready",
|
|
1166
|
-
conversation_id:
|
|
2601
|
+
conversation_id: resumedConvId,
|
|
1167
2602
|
agent_id: resumedAgentId,
|
|
1168
2603
|
workspace_key: workspaceKey,
|
|
1169
2604
|
additional_context: context,
|
|
@@ -1176,8 +2611,29 @@ async function handleSessionStart(hookInput) {
|
|
|
1176
2611
|
|
|
1177
2612
|
// Create conversation via MCP. Attach session metadata (cwd / gitBranch /
|
|
1178
2613
|
// startedAt) from the transcript header so hook traces match the migrator.
|
|
2614
|
+
// Intent-first: durable outbox BEFORE any network call so create failures
|
|
2615
|
+
// leave a recoverable needs_conversation state (Capture V2).
|
|
1179
2616
|
const transcriptMetadata = peekTranscriptMetadata(transcriptPath);
|
|
1180
|
-
const datapackPin = readDatapackPin(agentId);
|
|
2617
|
+
const datapackPin = readDatapackPin(agentId, legacyAgentId);
|
|
2618
|
+
const agentIdSource = (process.env.MEKO_AGENT_ID || "").trim() ? "explicit" : "derived";
|
|
2619
|
+
if (sessionId) {
|
|
2620
|
+
const intent = captureState.writeSessionIntent(sessionId, {
|
|
2621
|
+
client: resolveHookClient() || "",
|
|
2622
|
+
agent_id: agentId,
|
|
2623
|
+
agent_id_source: agentIdSource,
|
|
2624
|
+
transcript_path: transcriptPath || null,
|
|
2625
|
+
datapack_id: datapackPin ? datapackPin.datapack_id : null,
|
|
2626
|
+
datapack_name: datapackPin ? datapackPin.datapack_name : null,
|
|
2627
|
+
});
|
|
2628
|
+
if (!intent.ok) {
|
|
2629
|
+
process.stderr.write(
|
|
2630
|
+
`[meko-capture] SessionStart: writeSessionIntent failed (${intent.failure_class}: ${intent.error}).\n`,
|
|
2631
|
+
);
|
|
2632
|
+
const context = withHealthNotice(buildSessionStartFallbackContext(agentId, { datapackPin }));
|
|
2633
|
+
process.stdout.write(hookOutput(context));
|
|
2634
|
+
return;
|
|
2635
|
+
}
|
|
2636
|
+
}
|
|
1181
2637
|
let convId = null;
|
|
1182
2638
|
try {
|
|
1183
2639
|
await mcpInitialize();
|
|
@@ -1188,16 +2644,49 @@ async function handleSessionStart(hookInput) {
|
|
|
1188
2644
|
datapackPin && datapackPin.datapack_id,
|
|
1189
2645
|
);
|
|
1190
2646
|
} catch (err) {
|
|
1191
|
-
|
|
1192
|
-
|
|
2647
|
+
// MEKO-592: distinguish "server unreachable" (network/init failure) from
|
|
2648
|
+
// "server accepted the call and told us to pin a datapack". The latter
|
|
2649
|
+
// is user action, not a server outage — logging it as "MCP unavailable"
|
|
2650
|
+
// sends the reporter down the wrong troubleshooting path.
|
|
2651
|
+
const isDatapackRequired = err && err.code === "datapack_id_required";
|
|
2652
|
+
if (isDatapackRequired) {
|
|
2653
|
+
process.stderr.write(
|
|
2654
|
+
`[meko-capture] SessionStart: no datapack pinned; auto-capture is idle. Pin one with datapack_list + meko-select-datapack; the next drain picks up the fresh pin.\n`,
|
|
2655
|
+
);
|
|
2656
|
+
} else if (err && err.isMcpToolError) {
|
|
2657
|
+
// Server accepted the call and returned a structured error (e.g.
|
|
2658
|
+
// invalid_metadata, create_failed). It is not "MCP unavailable"; print
|
|
2659
|
+
// the code so the reporter isn't sent down the network path. Node
|
|
2660
|
+
// system errors (ECONNREFUSED, ETIMEDOUT) also carry `.code`, so we
|
|
2661
|
+
// gate on the marker set by createConversation.
|
|
2662
|
+
process.stderr.write(
|
|
2663
|
+
`[meko-capture] SessionStart: server rejected conversation_create (${err.code}: ${err.detail || err.message}). Falling back to agent-driven setup.\n`,
|
|
2664
|
+
);
|
|
2665
|
+
} else {
|
|
2666
|
+
process.stderr.write(`[meko-capture] SessionStart: MCP unavailable (${err.message}). Falling back to agent-driven setup.\n`);
|
|
2667
|
+
}
|
|
2668
|
+
const contextOpts = { datapackPin: readDatapackPin(agentId, legacyAgentId) };
|
|
2669
|
+
if (isDatapackRequired) contextOpts.reason = "datapack_required";
|
|
2670
|
+
let context = withHealthNotice(buildSessionStartFallbackContext(agentId, contextOpts));
|
|
1193
2671
|
if (sessionId) {
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
2672
|
+
// MEKO-592: state was written by writeSessionIntent above with
|
|
2673
|
+
// `delivery: "needs_conversation"`, which is the Capture V2 marker
|
|
2674
|
+
// that `recover` and drain look for. Don't overwrite it here - the
|
|
2675
|
+
// first drain classifies the failure and updates the health bucket.
|
|
2676
|
+
// The prior `status: "action_required"` cache write was dead code
|
|
2677
|
+
// (nothing reads the session cache for `--capture-status`), so keep
|
|
2678
|
+
// `"error"` and leave the health signal to state on the next drain.
|
|
2679
|
+
try {
|
|
2680
|
+
writeSessionCache(sessionId, {
|
|
2681
|
+
status: "error",
|
|
2682
|
+
agent_id: agentId,
|
|
2683
|
+
workspace_key: workspaceKey,
|
|
2684
|
+
error: err && err.message ? String(err.message).slice(0, 240) : "MCP unavailable",
|
|
2685
|
+
additional_context: context,
|
|
2686
|
+
});
|
|
2687
|
+
} catch (cacheErr) {
|
|
2688
|
+
process.stderr.write(`[meko-capture] SessionStart cache write skipped: ${cacheErr.message}\n`);
|
|
2689
|
+
}
|
|
1201
2690
|
}
|
|
1202
2691
|
process.stdout.write(hookOutput(context));
|
|
1203
2692
|
return;
|
|
@@ -1205,15 +2694,21 @@ async function handleSessionStart(hookInput) {
|
|
|
1205
2694
|
|
|
1206
2695
|
if (!convId) {
|
|
1207
2696
|
process.stderr.write("[meko-capture] SessionStart: conversation_create returned no ID.\n");
|
|
1208
|
-
|
|
2697
|
+
let context = withHealthNotice(buildSessionStartFallbackContext(agentId, {
|
|
2698
|
+
datapackPin: readDatapackPin(agentId, legacyAgentId),
|
|
2699
|
+
}));
|
|
1209
2700
|
if (sessionId) {
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
2701
|
+
try {
|
|
2702
|
+
writeSessionCache(sessionId, {
|
|
2703
|
+
status: "error",
|
|
2704
|
+
agent_id: agentId,
|
|
2705
|
+
workspace_key: workspaceKey,
|
|
2706
|
+
error: "conversation_create returned no ID",
|
|
2707
|
+
additional_context: context,
|
|
2708
|
+
});
|
|
2709
|
+
} catch (cacheErr) {
|
|
2710
|
+
process.stderr.write(`[meko-capture] SessionStart cache write skipped: ${cacheErr.message}\n`);
|
|
2711
|
+
}
|
|
1217
2712
|
}
|
|
1218
2713
|
process.stdout.write(hookOutput(context));
|
|
1219
2714
|
return;
|
|
@@ -1232,49 +2727,994 @@ async function handleSessionStart(hookInput) {
|
|
|
1232
2727
|
|
|
1233
2728
|
if (sessionId) {
|
|
1234
2729
|
const wmPath = path.join(WATERMARK_DIR, `${sessionId}.watermark.json`);
|
|
1235
|
-
writeWatermark(wmPath, convId, 0, agentId, datapackPin);
|
|
2730
|
+
writeWatermark(wmPath, convId, 0, agentId, datapackPin, agentIdSource);
|
|
1236
2731
|
process.stderr.write(
|
|
1237
2732
|
`[meko-capture] SessionStart: created conversation ${convId}, agent_id=${agentId}, preloaded ${memories.length} memories, watermark at ${wmPath}\n`,
|
|
1238
2733
|
);
|
|
1239
2734
|
}
|
|
1240
2735
|
|
|
1241
|
-
|
|
2736
|
+
let context = withHealthNotice(buildSessionStartContext(convId, sessionId, agentId, memories, {
|
|
1242
2737
|
datapackPin,
|
|
1243
|
-
|
|
1244
|
-
});
|
|
2738
|
+
}));
|
|
1245
2739
|
if (sessionId) {
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
2740
|
+
try {
|
|
2741
|
+
writeSessionCache(sessionId, {
|
|
2742
|
+
status: "ready",
|
|
2743
|
+
conversation_id: convId,
|
|
2744
|
+
agent_id: agentId,
|
|
2745
|
+
workspace_key: workspaceKey,
|
|
2746
|
+
additional_context: context,
|
|
2747
|
+
});
|
|
2748
|
+
} catch (cacheErr) {
|
|
2749
|
+
process.stderr.write(`[meko-capture] SessionStart cache write skipped: ${cacheErr.message}\n`);
|
|
2750
|
+
}
|
|
1253
2751
|
}
|
|
1254
2752
|
process.stdout.write(hookOutput(context));
|
|
1255
2753
|
}
|
|
1256
2754
|
|
|
2755
|
+
// --- Drain engine (Capture V2) ---
|
|
2756
|
+
|
|
2757
|
+
/**
|
|
2758
|
+
* Persist a post-accept cursor advance. Returns writeState result.
|
|
2759
|
+
* Used after structurally valid addMessage acceptance only.
|
|
2760
|
+
*/
|
|
2761
|
+
function persistExchangeCheckpoint(sessionId, state, nextLine, extras) {
|
|
2762
|
+
const adv = captureState.advanceCursor(state, nextLine, { clearSeed: true });
|
|
2763
|
+
if (!adv.ok) return adv;
|
|
2764
|
+
const next = {
|
|
2765
|
+
...adv.state,
|
|
2766
|
+
delivery: extras.delivery != null ? extras.delivery : "pending",
|
|
2767
|
+
queued_exchanges:
|
|
2768
|
+
extras.queued_exchanges != null
|
|
2769
|
+
? extras.queued_exchanges
|
|
2770
|
+
: Math.max(0, (state.queued_exchanges || 0) - 1),
|
|
2771
|
+
attempt_count: 0,
|
|
2772
|
+
next_retry_at: null,
|
|
2773
|
+
failure_class: null,
|
|
2774
|
+
last_error: null,
|
|
2775
|
+
blocked_reason: null,
|
|
2776
|
+
last_failure_scope: null,
|
|
2777
|
+
recovered_from_conversation_id: null,
|
|
2778
|
+
missing_conversation_streak: null,
|
|
2779
|
+
last_success_at: new Date().toISOString(),
|
|
2780
|
+
consecutive_exchange_drops:
|
|
2781
|
+
extras.consecutive_exchange_drops != null
|
|
2782
|
+
? extras.consecutive_exchange_drops
|
|
2783
|
+
: 0,
|
|
2784
|
+
in_flight: { seed: null, user_line: null, user_turn_id: null },
|
|
2785
|
+
};
|
|
2786
|
+
if (extras.lifecycle) next.lifecycle = extras.lifecycle;
|
|
2787
|
+
if (extras.dropped_exchanges != null) {
|
|
2788
|
+
next.dropped_exchanges = extras.dropped_exchanges;
|
|
2789
|
+
}
|
|
2790
|
+
if (extras.last_drop_reason !== undefined) {
|
|
2791
|
+
next.last_drop_reason = extras.last_drop_reason;
|
|
2792
|
+
}
|
|
2793
|
+
if (extras.last_success_at !== undefined) {
|
|
2794
|
+
next.last_success_at = extras.last_success_at;
|
|
2795
|
+
}
|
|
2796
|
+
if (extras.agent_id != null) next.agent_id = extras.agent_id;
|
|
2797
|
+
if (extras.agent_id_source != null) next.agent_id_source = extras.agent_id_source;
|
|
2798
|
+
if (extras.conversation_id != null) next.conversation_id = extras.conversation_id;
|
|
2799
|
+
if (extras.datapack_id !== undefined) next.datapack_id = extras.datapack_id;
|
|
2800
|
+
if (extras.datapack_name !== undefined) next.datapack_name = extras.datapack_name;
|
|
2801
|
+
// Crash-injection hook for tests: server accepted, local checkpoint skipped.
|
|
2802
|
+
if (process.env.MEKO_CAPTURE_TEST_CRASH_AFTER_ACCEPT === "1") {
|
|
2803
|
+
return {
|
|
2804
|
+
ok: false,
|
|
2805
|
+
failure_class: "transient",
|
|
2806
|
+
error: "MEKO_CAPTURE_TEST_CRASH_AFTER_ACCEPT",
|
|
2807
|
+
state: next,
|
|
2808
|
+
crashed_after_accept: true,
|
|
2809
|
+
};
|
|
2810
|
+
}
|
|
2811
|
+
return captureState.writeState(sessionId, next);
|
|
2812
|
+
}
|
|
2813
|
+
|
|
2814
|
+
/**
|
|
2815
|
+
* Build a synthetic interrupted exchange for a trailing incomplete user turn.
|
|
2816
|
+
*/
|
|
2817
|
+
function buildInterruptedExchange(turn, sessionId) {
|
|
2818
|
+
const input = turnInput(turn) || "";
|
|
2819
|
+
// A turn held only because injected context (a skill body or a task
|
|
2820
|
+
// notification) never got its follow-up reply already has a real answer.
|
|
2821
|
+
// Keep it rather than replacing it with the interrupted marker.
|
|
2822
|
+
if (
|
|
2823
|
+
turn.assistantEntries.length > 0 &&
|
|
2824
|
+
!(turn.codexStreaming && !turn.codexComplete)
|
|
2825
|
+
) {
|
|
2826
|
+
const { output, reasoning } = renderTurnBody(turn);
|
|
2827
|
+
if (output) {
|
|
2828
|
+
return {
|
|
2829
|
+
user_uuid: turn.userMsg.uuid || "unknown",
|
|
2830
|
+
user_line: turn.userLine,
|
|
2831
|
+
next_line_number: null,
|
|
2832
|
+
input,
|
|
2833
|
+
output,
|
|
2834
|
+
reasoning,
|
|
2835
|
+
timestamp:
|
|
2836
|
+
turn.assistantEntries[turn.assistantEntries.length - 1].timestamp ||
|
|
2837
|
+
turn.userMsg.timestamp ||
|
|
2838
|
+
"",
|
|
2839
|
+
};
|
|
2840
|
+
}
|
|
2841
|
+
}
|
|
2842
|
+
return {
|
|
2843
|
+
user_uuid: turn.userMsg.uuid || "unknown",
|
|
2844
|
+
user_line: turn.userLine,
|
|
2845
|
+
next_line_number: null,
|
|
2846
|
+
input,
|
|
2847
|
+
output: INTERRUPTED_ASSISTANT_OUTPUT,
|
|
2848
|
+
reasoning: "",
|
|
2849
|
+
metadata: {
|
|
2850
|
+
capture_status: "interrupted",
|
|
2851
|
+
session_id: sessionId,
|
|
2852
|
+
user_line: turn.userLine,
|
|
2853
|
+
},
|
|
2854
|
+
timestamp: turn.userMsg.timestamp || "",
|
|
2855
|
+
};
|
|
2856
|
+
}
|
|
2857
|
+
|
|
2858
|
+
/**
|
|
2859
|
+
* Unified drain entry used by checkpoint, pre-compact, session-end, and CLI
|
|
2860
|
+
* recover/drain. Acquires a drain lease, delivers sequentially with batch
|
|
2861
|
+
* limits, and advances the cursor only after valid accepted message IDs.
|
|
2862
|
+
*
|
|
2863
|
+
* @param {object} options
|
|
2864
|
+
* @param {string} options.session_id
|
|
2865
|
+
* @param {string} [options.transcript_path]
|
|
2866
|
+
* @param {"checkpoint"|"pre-compact"|"session-end"|"drain"|"recover"} [options.mode]
|
|
2867
|
+
* @param {"active"|"closing"|"closed"} [options.lifecycle]
|
|
2868
|
+
* @param {boolean} [options.confirmed_dead]
|
|
2869
|
+
* @param {boolean} [options.recorded_owner_only]
|
|
2870
|
+
* @param {number} [options.max_exchanges]
|
|
2871
|
+
* @param {boolean} [options.force_retry] One-shot daemon-start override for
|
|
2872
|
+
* persistent failures only.
|
|
2873
|
+
* @returns {Promise<object>} DrainResult
|
|
2874
|
+
*/
|
|
2875
|
+
async function drainSession(options) {
|
|
2876
|
+
const opts = options && typeof options === "object" ? options : {};
|
|
2877
|
+
const sessionId = String(opts.session_id || "").trim();
|
|
2878
|
+
if (!sessionId) {
|
|
2879
|
+
return makeDrainResult({
|
|
2880
|
+
status: "blocked",
|
|
2881
|
+
failure_class: "persistent",
|
|
2882
|
+
error: "session_id required",
|
|
2883
|
+
});
|
|
2884
|
+
}
|
|
2885
|
+
|
|
2886
|
+
const mode = opts.mode || "drain";
|
|
2887
|
+
const configuredBatch = captureState.maxCaptureBatchSize();
|
|
2888
|
+
const maxBatch =
|
|
2889
|
+
opts.max_exchanges != null
|
|
2890
|
+
? Math.min(
|
|
2891
|
+
configuredBatch,
|
|
2892
|
+
Math.max(1, Number(opts.max_exchanges) || 1),
|
|
2893
|
+
)
|
|
2894
|
+
: configuredBatch;
|
|
2895
|
+
const recordedOwnerOnly = Boolean(opts.recorded_owner_only);
|
|
2896
|
+
const finalizeInterrupted =
|
|
2897
|
+
Boolean(opts.confirmed_dead) ||
|
|
2898
|
+
opts.lifecycle === "closing" ||
|
|
2899
|
+
opts.lifecycle === "closed" ||
|
|
2900
|
+
mode === "session-end";
|
|
2901
|
+
|
|
2902
|
+
const leaseAcq = captureState.acquireLease(sessionId);
|
|
2903
|
+
if (!leaseAcq.ok) {
|
|
2904
|
+
return makeDrainResult({
|
|
2905
|
+
status: "retry_wait",
|
|
2906
|
+
session_id: sessionId,
|
|
2907
|
+
failure_class: leaseAcq.failure_class || "ownership",
|
|
2908
|
+
error: leaseAcq.error || "lease held",
|
|
2909
|
+
delivery: "retry_wait",
|
|
2910
|
+
});
|
|
2911
|
+
}
|
|
2912
|
+
const leaseToken = leaseAcq.lease && leaseAcq.lease.token;
|
|
2913
|
+
|
|
2914
|
+
try {
|
|
2915
|
+
const loaded = captureState.readState(sessionId);
|
|
2916
|
+
if (!loaded.ok && !loaded.missing) {
|
|
2917
|
+
return makeDrainResult({
|
|
2918
|
+
status: "blocked",
|
|
2919
|
+
session_id: sessionId,
|
|
2920
|
+
failure_class: loaded.failure_class || "state_corrupt",
|
|
2921
|
+
error: loaded.error || "corrupt state",
|
|
2922
|
+
delivery: "blocked",
|
|
2923
|
+
});
|
|
2924
|
+
}
|
|
2925
|
+
|
|
2926
|
+
const stateExisted = Boolean(loaded.ok);
|
|
2927
|
+
const nowIso = new Date().toISOString();
|
|
2928
|
+
let state = loaded.ok
|
|
2929
|
+
? { ...loaded.state }
|
|
2930
|
+
: {
|
|
2931
|
+
schema_version: 2,
|
|
2932
|
+
session_id: sessionId,
|
|
2933
|
+
client: "",
|
|
2934
|
+
agent_id: "",
|
|
2935
|
+
agent_id_source: "",
|
|
2936
|
+
transcript_path: null,
|
|
2937
|
+
datapack_id: null,
|
|
2938
|
+
datapack_name: null,
|
|
2939
|
+
created_at: nowIso,
|
|
2940
|
+
updated_at: nowIso,
|
|
2941
|
+
last_activity_at: nowIso,
|
|
2942
|
+
conversation_id: null,
|
|
2943
|
+
conversation_epoch: 0,
|
|
2944
|
+
last_line_number: 0,
|
|
2945
|
+
in_flight: { seed: null, user_line: null, user_turn_id: null },
|
|
2946
|
+
lifecycle: "active",
|
|
2947
|
+
delivery: "needs_conversation",
|
|
2948
|
+
queued_exchanges: 0,
|
|
2949
|
+
attempt_count: 0,
|
|
2950
|
+
next_retry_at: null,
|
|
2951
|
+
failure_class: null,
|
|
2952
|
+
last_error: null,
|
|
2953
|
+
last_success_at: null,
|
|
2954
|
+
blocked_reason: null,
|
|
2955
|
+
consecutive_exchange_drops: 0,
|
|
2956
|
+
};
|
|
2957
|
+
const transcriptPath =
|
|
2958
|
+
opts.transcript_path ||
|
|
2959
|
+
state.transcript_path ||
|
|
2960
|
+
null;
|
|
2961
|
+
|
|
2962
|
+
const persistedLifecycle = state.lifecycle;
|
|
2963
|
+
if (opts.lifecycle && opts.lifecycle !== state.lifecycle) {
|
|
2964
|
+
state.lifecycle = opts.lifecycle;
|
|
2965
|
+
} else if (mode === "session-end" && state.lifecycle === "active") {
|
|
2966
|
+
state.lifecycle = "closing";
|
|
2967
|
+
}
|
|
2968
|
+
if (transcriptPath && !state.transcript_path) {
|
|
2969
|
+
state.transcript_path = transcriptPath;
|
|
2970
|
+
}
|
|
2971
|
+
|
|
2972
|
+
const retryAtMs = state.next_retry_at
|
|
2973
|
+
? Date.parse(state.next_retry_at)
|
|
2974
|
+
: NaN;
|
|
2975
|
+
const nowMs = Date.now();
|
|
2976
|
+
const persistentRetryDue =
|
|
2977
|
+
state.failure_class === "persistent" &&
|
|
2978
|
+
state.last_failure_scope === "exchange" &&
|
|
2979
|
+
Number.isFinite(retryAtMs) &&
|
|
2980
|
+
retryAtMs <= nowMs;
|
|
2981
|
+
if (
|
|
2982
|
+
state.failure_class &&
|
|
2983
|
+
Number.isFinite(retryAtMs) &&
|
|
2984
|
+
retryAtMs > nowMs &&
|
|
2985
|
+
!(Boolean(opts.force_retry) && state.failure_class === "persistent")
|
|
2986
|
+
) {
|
|
2987
|
+
return makeDrainResult({
|
|
2988
|
+
status: "retry_wait",
|
|
2989
|
+
session_id: sessionId,
|
|
2990
|
+
queued_remaining: state.queued_exchanges || 0,
|
|
2991
|
+
failure_class: state.failure_class,
|
|
2992
|
+
error: state.last_error || "retry not due",
|
|
2993
|
+
last_line_number: state.last_line_number || 0,
|
|
2994
|
+
lifecycle: state.lifecycle,
|
|
2995
|
+
delivery: state.delivery,
|
|
2996
|
+
next_retry_at: state.next_retry_at,
|
|
2997
|
+
});
|
|
2998
|
+
}
|
|
2999
|
+
|
|
3000
|
+
if (!transcriptPath || !fs.existsSync(transcriptPath)) {
|
|
3001
|
+
// The transcript is the only source of turns, so a closed session whose
|
|
3002
|
+
// transcript is gone has nothing left to deliver — ever. Left alone it
|
|
3003
|
+
// sits at "pending" forever and holds the health rollup below healthy:
|
|
3004
|
+
// 26 such tombstones had accumulated, the oldest 16.7 days old, none of
|
|
3005
|
+
// them carrying a single deliverable turn.
|
|
3006
|
+
//
|
|
3007
|
+
// Only a session that is closing/closed AND has been idle past the grace
|
|
3008
|
+
// period is finalized. An ACTIVE session whose transcript has not
|
|
3009
|
+
// appeared yet must stay pending so it can still deliver, and the grace
|
|
3010
|
+
// period covers a transcript that is briefly unreadable (an unmounted
|
|
3011
|
+
// volume, a permissions blip) rather than genuinely gone.
|
|
3012
|
+
const idleMs = state.last_activity_at
|
|
3013
|
+
? Date.now() - Date.parse(state.last_activity_at)
|
|
3014
|
+
: 0;
|
|
3015
|
+
const graceMs = transcriptMissingGraceSeconds() * 1000;
|
|
3016
|
+
const finalizable =
|
|
3017
|
+
(persistedLifecycle === "closing" || persistedLifecycle === "closed") &&
|
|
3018
|
+
Number.isFinite(idleMs) &&
|
|
3019
|
+
idleMs >= graceMs &&
|
|
3020
|
+
coerceQueued(state) === 0;
|
|
3021
|
+
if (stateExisted && finalizable) {
|
|
3022
|
+
state.lifecycle = "closed";
|
|
3023
|
+
state.delivery = "complete";
|
|
3024
|
+
state.queued_exchanges = 0;
|
|
3025
|
+
clearResolvedFailure(state);
|
|
3026
|
+
captureState.writeState(sessionId, state);
|
|
3027
|
+
process.stderr.write(
|
|
3028
|
+
`[meko-capture] Finalizing ${sessionId}: transcript is gone and the session ` +
|
|
3029
|
+
`closed ${Math.round(idleMs / 86400000)}d ago with nothing queued; ` +
|
|
3030
|
+
`nothing left to deliver.\n`,
|
|
3031
|
+
);
|
|
3032
|
+
return makeDrainResult({
|
|
3033
|
+
status: "noop",
|
|
3034
|
+
session_id: sessionId,
|
|
3035
|
+
last_line_number: state.last_line_number || 0,
|
|
3036
|
+
lifecycle: state.lifecycle,
|
|
3037
|
+
delivery: state.delivery,
|
|
3038
|
+
error: "transcript missing; finalized",
|
|
3039
|
+
});
|
|
3040
|
+
}
|
|
3041
|
+
// A recovery sweep marks abandoned sessions as closing while it drains
|
|
3042
|
+
// them. Do not persist that override when the transcript is absent: the
|
|
3043
|
+
// stored active lifecycle is the evidence that this session may return.
|
|
3044
|
+
if (mode === "recover" && persistedLifecycle === "active") {
|
|
3045
|
+
state.lifecycle = persistedLifecycle;
|
|
3046
|
+
}
|
|
3047
|
+
if (stateExisted) captureState.writeState(sessionId, state);
|
|
3048
|
+
return makeDrainResult({
|
|
3049
|
+
status: "noop",
|
|
3050
|
+
session_id: sessionId,
|
|
3051
|
+
last_line_number: state.last_line_number || 0,
|
|
3052
|
+
lifecycle: state.lifecycle,
|
|
3053
|
+
delivery: state.delivery,
|
|
3054
|
+
error: "transcript missing",
|
|
3055
|
+
});
|
|
3056
|
+
}
|
|
3057
|
+
|
|
3058
|
+
let convId = state.conversation_id || "";
|
|
3059
|
+
let lastLine = state.last_line_number || 0;
|
|
3060
|
+
const currentLines = countLines(transcriptPath);
|
|
3061
|
+
let datapackPin = datapackPinFromWatermark({
|
|
3062
|
+
datapack_id: state.datapack_id,
|
|
3063
|
+
datapack_name: state.datapack_name,
|
|
3064
|
+
});
|
|
3065
|
+
|
|
3066
|
+
// MEKO-592: SessionStart freezes state.datapack_id at the value the pin
|
|
3067
|
+
// resolved to when the session opened. If the session started before the
|
|
3068
|
+
// user pinned a datapack, that value is null and every drain here would
|
|
3069
|
+
// resend it, hitting datapack_id_required against the guarded server on
|
|
3070
|
+
// every retry. Re-read the pin file when state has none so a mid-session
|
|
3071
|
+
// pin actually recovers this session's queued turns instead of forcing
|
|
3072
|
+
// the user to start a fresh session.
|
|
3073
|
+
if (!datapackPin && (state.agent_id || "").trim()) {
|
|
3074
|
+
const freshPin = readDatapackPin(state.agent_id);
|
|
3075
|
+
if (freshPin && freshPin.datapack_id) {
|
|
3076
|
+
datapackPin = freshPin;
|
|
3077
|
+
state.datapack_id = freshPin.datapack_id;
|
|
3078
|
+
state.datapack_name = freshPin.datapack_name || null;
|
|
3079
|
+
}
|
|
3080
|
+
}
|
|
3081
|
+
|
|
3082
|
+
let effectiveAgentId = state.agent_id || "";
|
|
3083
|
+
let effectiveSource = state.agent_id_source || "";
|
|
3084
|
+
|
|
3085
|
+
if (!recordedOwnerOnly) {
|
|
3086
|
+
const reconciled = reconcileCachedAgentId(
|
|
3087
|
+
state.agent_id,
|
|
3088
|
+
state.agent_id_source || "",
|
|
3089
|
+
resolveSessionAgentId(transcriptPath),
|
|
3090
|
+
);
|
|
3091
|
+
effectiveAgentId = reconciled.agentId;
|
|
3092
|
+
effectiveSource = reconciled.source;
|
|
3093
|
+
} else if (!(state.agent_id || "").trim()) {
|
|
3094
|
+
return makeDrainResult({
|
|
3095
|
+
status: "noop",
|
|
3096
|
+
session_id: sessionId,
|
|
3097
|
+
last_line_number: lastLine,
|
|
3098
|
+
lifecycle: state.lifecycle,
|
|
3099
|
+
delivery: state.delivery,
|
|
3100
|
+
error: "recorded owner missing",
|
|
3101
|
+
});
|
|
3102
|
+
}
|
|
3103
|
+
|
|
3104
|
+
// Nothing-new early exit BEFORE network (preserve quiet skip for EOF hooks).
|
|
3105
|
+
if (convId && currentLines <= lastLine && !finalizeInterrupted) {
|
|
3106
|
+
if (
|
|
3107
|
+
stateExisted &&
|
|
3108
|
+
!recordedOwnerOnly &&
|
|
3109
|
+
effectiveAgentId !== state.agent_id
|
|
3110
|
+
) {
|
|
3111
|
+
state.agent_id = effectiveAgentId;
|
|
3112
|
+
state.agent_id_source = effectiveSource;
|
|
3113
|
+
captureState.writeState(sessionId, state);
|
|
3114
|
+
}
|
|
3115
|
+
return makeDrainResult({
|
|
3116
|
+
status: "noop",
|
|
3117
|
+
session_id: sessionId,
|
|
3118
|
+
last_line_number: lastLine,
|
|
3119
|
+
lifecycle: state.lifecycle,
|
|
3120
|
+
delivery: state.delivery || "idle",
|
|
3121
|
+
});
|
|
3122
|
+
}
|
|
3123
|
+
|
|
3124
|
+
try {
|
|
3125
|
+
await mcpInitialize();
|
|
3126
|
+
if (!convId) {
|
|
3127
|
+
convId = await createConversation(
|
|
3128
|
+
sessionId,
|
|
3129
|
+
effectiveAgentId,
|
|
3130
|
+
peekTranscriptMetadata(transcriptPath),
|
|
3131
|
+
datapackPin && datapackPin.datapack_id,
|
|
3132
|
+
);
|
|
3133
|
+
if (!convId) {
|
|
3134
|
+
throw new Error("conversation_create returned no ID");
|
|
3135
|
+
}
|
|
3136
|
+
state.conversation_id = convId;
|
|
3137
|
+
state.delivery = currentLines > lastLine ? "pending" : "idle";
|
|
3138
|
+
state.failure_class = null;
|
|
3139
|
+
state.last_error = null;
|
|
3140
|
+
state.next_retry_at = null;
|
|
3141
|
+
const created = captureState.writeState(sessionId, state);
|
|
3142
|
+
if (!created.ok) {
|
|
3143
|
+
throw new Error(
|
|
3144
|
+
`failed to persist created conversation: ${created.error}`,
|
|
3145
|
+
);
|
|
3146
|
+
}
|
|
3147
|
+
}
|
|
3148
|
+
} catch (err) {
|
|
3149
|
+
const persistent = classifyPersistentCaptureFailure(err);
|
|
3150
|
+
const failureClass = persistent
|
|
3151
|
+
? "persistent"
|
|
3152
|
+
: captureState.classifyFailure(err);
|
|
3153
|
+
const failureText = persistentFailureText(persistent, err.message);
|
|
3154
|
+
const pendingExchanges = extractExchanges(transcriptPath, lastLine).length;
|
|
3155
|
+
// Only persist failure onto an existing outbox — never invent a watermark
|
|
3156
|
+
// just because initialize failed (SessionStart owns intent creation).
|
|
3157
|
+
if (stateExisted) {
|
|
3158
|
+
state.delivery = failureClass === "transient" ? "retry_wait" : "blocked";
|
|
3159
|
+
state.failure_class = failureClass;
|
|
3160
|
+
state.last_error = failureText;
|
|
3161
|
+
state.last_failure_scope = persistent ? persistent.scope : null;
|
|
3162
|
+
state.attempt_count = (state.attempt_count || 0) + 1;
|
|
3163
|
+
state.next_retry_at = captureState.nextRetryAt({
|
|
3164
|
+
failure_class: failureClass,
|
|
3165
|
+
attempt_count: state.attempt_count,
|
|
3166
|
+
});
|
|
3167
|
+
state.queued_exchanges = pendingExchanges;
|
|
3168
|
+
captureState.writeState(sessionId, state);
|
|
3169
|
+
}
|
|
3170
|
+
process.stderr.write(
|
|
3171
|
+
`[meko-capture] Failed to initialize MCP session: ${failureText}\n`,
|
|
3172
|
+
);
|
|
3173
|
+
return makeDrainResult({
|
|
3174
|
+
status: failureClass === "transient" ? "retry_wait" : "blocked",
|
|
3175
|
+
session_id: sessionId,
|
|
3176
|
+
queued_remaining: pendingExchanges,
|
|
3177
|
+
failure_class: failureClass,
|
|
3178
|
+
error: failureText,
|
|
3179
|
+
last_line_number: lastLine,
|
|
3180
|
+
lifecycle: state.lifecycle,
|
|
3181
|
+
delivery: stateExisted ? state.delivery : "needs_conversation",
|
|
3182
|
+
});
|
|
3183
|
+
}
|
|
3184
|
+
|
|
3185
|
+
let extractFrom = lastLine;
|
|
3186
|
+
if (!recordedOwnerOnly) {
|
|
3187
|
+
const owned = await ensureConversationOwner({
|
|
3188
|
+
convId,
|
|
3189
|
+
cachedOwner: state.agent_id,
|
|
3190
|
+
resolvedAgentId: effectiveAgentId,
|
|
3191
|
+
resolvedSource: effectiveSource,
|
|
3192
|
+
sessionId,
|
|
3193
|
+
metadata: peekTranscriptMetadata(transcriptPath),
|
|
3194
|
+
datapackId: datapackPin && datapackPin.datapack_id,
|
|
3195
|
+
mcpReady: true,
|
|
3196
|
+
});
|
|
3197
|
+
if (owned.hold) {
|
|
3198
|
+
return makeDrainResult({
|
|
3199
|
+
status: "retry_wait",
|
|
3200
|
+
session_id: sessionId,
|
|
3201
|
+
failure_class: "ownership",
|
|
3202
|
+
error: "rebucket hold",
|
|
3203
|
+
last_line_number: lastLine,
|
|
3204
|
+
lifecycle: state.lifecycle,
|
|
3205
|
+
delivery: state.delivery,
|
|
3206
|
+
});
|
|
3207
|
+
}
|
|
3208
|
+
effectiveAgentId = owned.agentId;
|
|
3209
|
+
effectiveSource = owned.source;
|
|
3210
|
+
if (owned.rebucketed) {
|
|
3211
|
+
const rb = captureState.rebucket(state, owned.convId);
|
|
3212
|
+
if (!rb.ok) {
|
|
3213
|
+
return makeDrainResult({
|
|
3214
|
+
status: "blocked",
|
|
3215
|
+
session_id: sessionId,
|
|
3216
|
+
failure_class: rb.failure_class || "persistent",
|
|
3217
|
+
error: rb.error,
|
|
3218
|
+
last_line_number: lastLine,
|
|
3219
|
+
lifecycle: state.lifecycle,
|
|
3220
|
+
delivery: "blocked",
|
|
3221
|
+
});
|
|
3222
|
+
}
|
|
3223
|
+
state = rb.state;
|
|
3224
|
+
state.agent_id = effectiveAgentId;
|
|
3225
|
+
state.agent_id_source = effectiveSource;
|
|
3226
|
+
captureState.writeState(sessionId, state);
|
|
3227
|
+
convId = owned.convId;
|
|
3228
|
+
extractFrom = 0;
|
|
3229
|
+
lastLine = 0;
|
|
3230
|
+
} else {
|
|
3231
|
+
convId = owned.convId;
|
|
3232
|
+
state.agent_id = effectiveAgentId;
|
|
3233
|
+
state.agent_id_source = effectiveSource;
|
|
3234
|
+
}
|
|
3235
|
+
}
|
|
3236
|
+
|
|
3237
|
+
const entries = parseTranscriptEntries(transcriptPath, extractFrom);
|
|
3238
|
+
const built = buildExchanges(entries);
|
|
3239
|
+
let allExchanges = built.exchanges.slice();
|
|
3240
|
+
let pendingFromLine = built.pendingFromLine;
|
|
3241
|
+
const turns = built.turns || [];
|
|
3242
|
+
const turnLines = realUserTurnLines(entries);
|
|
3243
|
+
let safeLine = pendingFromLine !== null ? pendingFromLine : currentLines;
|
|
3244
|
+
|
|
3245
|
+
// Closing / dead: finalize trailing incomplete user turn exactly once.
|
|
3246
|
+
let synthesizedInterrupted = false;
|
|
3247
|
+
if (finalizeInterrupted && pendingFromLine !== null && turns.length > 0) {
|
|
3248
|
+
const trailing = turns[turns.length - 1];
|
|
3249
|
+
if (trailing && trailing.userLine === pendingFromLine) {
|
|
3250
|
+
allExchanges.push(buildInterruptedExchange(trailing, sessionId));
|
|
3251
|
+
synthesizedInterrupted = true;
|
|
3252
|
+
pendingFromLine = null;
|
|
3253
|
+
safeLine = currentLines;
|
|
3254
|
+
}
|
|
3255
|
+
}
|
|
3256
|
+
|
|
3257
|
+
if (allExchanges.length === 0) {
|
|
3258
|
+
if (pendingFromLine !== null) {
|
|
3259
|
+
process.stderr.write(
|
|
3260
|
+
`[meko-capture] ${mode}: session ${sessionId} produced zero ` +
|
|
3261
|
+
`exchanges but a trailing turn is still awaiting completion; holding ` +
|
|
3262
|
+
`watermark at line ${safeLine} so it is captured whole on a later run.\n`,
|
|
3263
|
+
);
|
|
3264
|
+
state.delivery = "pending";
|
|
3265
|
+
state.queued_exchanges = 1;
|
|
3266
|
+
} else {
|
|
3267
|
+
state.delivery =
|
|
3268
|
+
finalizeInterrupted || state.lifecycle === "closing"
|
|
3269
|
+
? "complete"
|
|
3270
|
+
: "idle";
|
|
3271
|
+
if (finalizeInterrupted || state.lifecycle === "closing") {
|
|
3272
|
+
state.lifecycle = "closed";
|
|
3273
|
+
}
|
|
3274
|
+
state.queued_exchanges = 0;
|
|
3275
|
+
clearResolvedFailure(state);
|
|
3276
|
+
}
|
|
3277
|
+
state.agent_id = effectiveAgentId;
|
|
3278
|
+
state.agent_id_source = effectiveSource;
|
|
3279
|
+
state.conversation_id = convId;
|
|
3280
|
+
if (safeLine >= state.last_line_number) {
|
|
3281
|
+
const adv = captureState.advanceCursor(state, safeLine, { clearSeed: true });
|
|
3282
|
+
if (adv.ok) state = adv.state;
|
|
3283
|
+
}
|
|
3284
|
+
const written = captureState.writeState(sessionId, state);
|
|
3285
|
+
const s = written.ok ? written.state : state;
|
|
3286
|
+
return makeDrainResult({
|
|
3287
|
+
status: "ok",
|
|
3288
|
+
session_id: sessionId,
|
|
3289
|
+
captured: 0,
|
|
3290
|
+
queued_remaining: s.queued_exchanges || 0,
|
|
3291
|
+
cursor_advanced: (s.last_line_number || 0) > lastLine,
|
|
3292
|
+
last_line_number: s.last_line_number || 0,
|
|
3293
|
+
lifecycle: s.lifecycle,
|
|
3294
|
+
delivery: s.delivery,
|
|
3295
|
+
});
|
|
3296
|
+
}
|
|
3297
|
+
|
|
3298
|
+
const exchanges = allExchanges.slice(0, maxBatch);
|
|
3299
|
+
// PreCompact blocks the user, so it keeps the short timeout; background
|
|
3300
|
+
// drains wait for a slow server to finish storing a large turn.
|
|
3301
|
+
const addMessageTimeoutMs =
|
|
3302
|
+
mode === "pre-compact" ? MEKO_API_TIMEOUT : MEKO_ADD_MESSAGE_TIMEOUT;
|
|
3303
|
+
const leaseTtl = Math.max(
|
|
3304
|
+
captureState.leaseTtlSeconds(),
|
|
3305
|
+
Math.ceil(addMessageTimeoutMs / 1000) + 30,
|
|
3306
|
+
);
|
|
3307
|
+
// Set when renewal fails: "ownership" means another worker took the
|
|
3308
|
+
// lease; anything else means this worker couldn't write it.
|
|
3309
|
+
let leaseFailure = null;
|
|
3310
|
+
let captured = 0;
|
|
3311
|
+
let failed = 0;
|
|
3312
|
+
let dropped = 0;
|
|
3313
|
+
// Exchange-scoped HTML 403s may be payload-specific OR request-wide.
|
|
3314
|
+
// Allow one consecutive drop without an intervening success so a single
|
|
3315
|
+
// XSS turn cannot wedge the session; a second consecutive one holds the
|
|
3316
|
+
// queue because it may be a session-wide edge block.
|
|
3317
|
+
let consecutiveExchangeDrops = Number.isInteger(
|
|
3318
|
+
state.consecutive_exchange_drops,
|
|
3319
|
+
)
|
|
3320
|
+
? state.consecutive_exchange_drops
|
|
3321
|
+
: 0;
|
|
3322
|
+
// Consume the backoff drop on the first exchange of this drain, success
|
|
3323
|
+
// or failure, so a later HTML 403 in the same batch cannot also skip.
|
|
3324
|
+
let allowExchangeDropAfterBackoff = persistentRetryDue;
|
|
3325
|
+
let cursorAdvanced = false;
|
|
3326
|
+
let watermarkLine = extractFrom;
|
|
3327
|
+
state.delivery = "draining";
|
|
3328
|
+
state.queued_exchanges = allExchanges.length;
|
|
3329
|
+
captureState.writeState(sessionId, state);
|
|
3330
|
+
|
|
3331
|
+
for (const exchange of exchanges) {
|
|
3332
|
+
// Keep the lease ahead of a slow add_message so another drain can't
|
|
3333
|
+
// reclaim it mid-batch. If renewal fails, stop without sending: either
|
|
3334
|
+
// another drain owns the session, or this worker can't write the lease.
|
|
3335
|
+
if (leaseToken) {
|
|
3336
|
+
const renewed = captureState.renewLease(sessionId, leaseToken, {
|
|
3337
|
+
ttlSeconds: leaseTtl,
|
|
3338
|
+
});
|
|
3339
|
+
if (!renewed.ok) {
|
|
3340
|
+
leaseFailure = {
|
|
3341
|
+
failure_class: renewed.failure_class === "ownership" ? "ownership" : "transient",
|
|
3342
|
+
error: renewed.error || "lease renewal failed",
|
|
3343
|
+
};
|
|
3344
|
+
break;
|
|
3345
|
+
}
|
|
3346
|
+
}
|
|
3347
|
+
const canDropAfterBackoff = allowExchangeDropAfterBackoff;
|
|
3348
|
+
allowExchangeDropAfterBackoff = false;
|
|
3349
|
+
const seed = `${convId}:${exchange.user_uuid}`;
|
|
3350
|
+
state.in_flight = {
|
|
3351
|
+
seed,
|
|
3352
|
+
user_line:
|
|
3353
|
+
typeof exchange.user_line === "number" ? exchange.user_line : null,
|
|
3354
|
+
user_turn_id: exchange.user_uuid || null,
|
|
3355
|
+
};
|
|
3356
|
+
captureState.writeState(sessionId, state);
|
|
3357
|
+
|
|
3358
|
+
try {
|
|
3359
|
+
await addMessage(
|
|
3360
|
+
convId,
|
|
3361
|
+
effectiveAgentId,
|
|
3362
|
+
exchange,
|
|
3363
|
+
datapackPin && datapackPin.datapack_id,
|
|
3364
|
+
addMessageTimeoutMs,
|
|
3365
|
+
);
|
|
3366
|
+
const boundary = boundaryAfterExchange(turnLines, exchange.user_line);
|
|
3367
|
+
const nextLine =
|
|
3368
|
+
boundary != null
|
|
3369
|
+
? boundary
|
|
3370
|
+
: exchange.next_line_number != null
|
|
3371
|
+
? exchange.next_line_number
|
|
3372
|
+
: safeLine;
|
|
3373
|
+
const remainingAfter = allExchanges.length - captured - dropped - 1;
|
|
3374
|
+
const ck = persistExchangeCheckpoint(sessionId, state, nextLine, {
|
|
3375
|
+
delivery:
|
|
3376
|
+
remainingAfter > 0 || allExchanges.length > maxBatch
|
|
3377
|
+
? "pending"
|
|
3378
|
+
: "idle",
|
|
3379
|
+
queued_exchanges: Math.max(0, remainingAfter),
|
|
3380
|
+
agent_id: effectiveAgentId,
|
|
3381
|
+
agent_id_source: effectiveSource,
|
|
3382
|
+
conversation_id: convId,
|
|
3383
|
+
datapack_id: datapackPin ? datapackPin.datapack_id : null,
|
|
3384
|
+
datapack_name: datapackPin ? datapackPin.datapack_name : null,
|
|
3385
|
+
});
|
|
3386
|
+
if (!ck.ok) {
|
|
3387
|
+
failed++;
|
|
3388
|
+
const failureClass = ck.failure_class || "transient";
|
|
3389
|
+
state.delivery = "retry_wait";
|
|
3390
|
+
state.failure_class = failureClass;
|
|
3391
|
+
state.last_error = ck.error;
|
|
3392
|
+
state.attempt_count = (state.attempt_count || 0) + 1;
|
|
3393
|
+
state.next_retry_at = captureState.nextRetryAt({
|
|
3394
|
+
failure_class: failureClass,
|
|
3395
|
+
attempt_count: state.attempt_count,
|
|
3396
|
+
});
|
|
3397
|
+
state.in_flight = {
|
|
3398
|
+
seed,
|
|
3399
|
+
user_line:
|
|
3400
|
+
typeof exchange.user_line === "number" ? exchange.user_line : null,
|
|
3401
|
+
user_turn_id: exchange.user_uuid || null,
|
|
3402
|
+
};
|
|
3403
|
+
captureState.writeState(sessionId, state);
|
|
3404
|
+
process.stderr.write(
|
|
3405
|
+
`[meko-capture] Accepted but checkpoint failed (uuid=${exchange.user_uuid}): ${ck.error}\n`,
|
|
3406
|
+
);
|
|
3407
|
+
break;
|
|
3408
|
+
}
|
|
3409
|
+
state = ck.state;
|
|
3410
|
+
watermarkLine = state.last_line_number;
|
|
3411
|
+
captured++;
|
|
3412
|
+
consecutiveExchangeDrops = 0;
|
|
3413
|
+
cursorAdvanced = true;
|
|
3414
|
+
} catch (err) {
|
|
3415
|
+
// A conversation the server no longer has never heals, so retrying the
|
|
3416
|
+
// same id burns attempts and strands every queued turn. Open a fresh
|
|
3417
|
+
// conversation and move the undelivered tail into it. Return immediately
|
|
3418
|
+
// rather than falling through to the post-loop cursor advance.
|
|
3419
|
+
//
|
|
3420
|
+
// A single not_found can be a transient Langfuse lookup. Wait until the
|
|
3421
|
+
// same detail persists across two drains before opening a replacement,
|
|
3422
|
+
// so one blip does not fork a live conversation.
|
|
3423
|
+
//
|
|
3424
|
+
// Recover at most once before the replacement accepts a turn. If the
|
|
3425
|
+
// replacement reports not_found first, the fresh trace may not be
|
|
3426
|
+
// visible on another replica yet. Retry it rather than opening one
|
|
3427
|
+
// conversation per attempt. A successful checkpoint clears the guard,
|
|
3428
|
+
// so a later deletion can recover again.
|
|
3429
|
+
const missingConversation = isMissingConversationError(err);
|
|
3430
|
+
if (missingConversation) {
|
|
3431
|
+
state.missing_conversation_streak = missingConversationStreak(state) + 1;
|
|
3432
|
+
} else if (state.missing_conversation_streak) {
|
|
3433
|
+
state.missing_conversation_streak = null;
|
|
3434
|
+
}
|
|
3435
|
+
const alreadyRecovered = Boolean(state.recovered_from_conversation_id);
|
|
3436
|
+
const streakPersisted =
|
|
3437
|
+
missingConversationStreak(state) >= MISSING_CONVERSATION_RECOVERY_POLLS;
|
|
3438
|
+
if (missingConversation && alreadyRecovered) {
|
|
3439
|
+
process.stderr.write(
|
|
3440
|
+
`[meko-capture] Conversation ${convId} reports not_found, but it already replaced ` +
|
|
3441
|
+
`${state.recovered_from_conversation_id}; retrying it instead of opening another.\n`,
|
|
3442
|
+
);
|
|
3443
|
+
} else if (missingConversation && !streakPersisted) {
|
|
3444
|
+
process.stderr.write(
|
|
3445
|
+
`[meko-capture] Conversation ${convId} reports not_found ` +
|
|
3446
|
+
`(${missingConversationStreak(state)}/${MISSING_CONVERSATION_RECOVERY_POLLS}); ` +
|
|
3447
|
+
`retrying it before opening a new conversation.\n`,
|
|
3448
|
+
);
|
|
3449
|
+
}
|
|
3450
|
+
if (missingConversation && !alreadyRecovered && streakPersisted) {
|
|
3451
|
+
const recovered = await recoverMissingConversation({
|
|
3452
|
+
state,
|
|
3453
|
+
sessionId,
|
|
3454
|
+
agentId: effectiveAgentId,
|
|
3455
|
+
transcriptPath,
|
|
3456
|
+
datapackId: datapackPin && datapackPin.datapack_id,
|
|
3457
|
+
});
|
|
3458
|
+
if (recovered.ok) {
|
|
3459
|
+
process.stderr.write(
|
|
3460
|
+
`[meko-capture] Conversation ${convId} is gone server-side (${err.message}); ` +
|
|
3461
|
+
`moving ${allExchanges.length - captured} queued exchange(s) into new conversation ` +
|
|
3462
|
+
`${recovered.convId}.\n`,
|
|
3463
|
+
);
|
|
3464
|
+
return makeDrainResult({
|
|
3465
|
+
status: "retry_wait",
|
|
3466
|
+
session_id: sessionId,
|
|
3467
|
+
captured,
|
|
3468
|
+
queued_remaining: allExchanges.length - captured,
|
|
3469
|
+
last_line_number: recovered.state.last_line_number || 0,
|
|
3470
|
+
lifecycle: recovered.state.lifecycle,
|
|
3471
|
+
delivery: recovered.state.delivery,
|
|
3472
|
+
});
|
|
3473
|
+
}
|
|
3474
|
+
process.stderr.write(
|
|
3475
|
+
`[meko-capture] Conversation ${convId} is gone server-side but recovery failed ` +
|
|
3476
|
+
`(${recovered.error}); retrying.\n`,
|
|
3477
|
+
);
|
|
3478
|
+
}
|
|
3479
|
+
const persistent = classifyPersistentCaptureFailure(err, {
|
|
3480
|
+
exchange: true,
|
|
3481
|
+
});
|
|
3482
|
+
|
|
3483
|
+
// An exchange-scoped persistent rejection *may* be deterministic for
|
|
3484
|
+
// this exchange only (edge filtering matched a signature inside the
|
|
3485
|
+
// transcript text). Holding forever behind a true payload rejection
|
|
3486
|
+
// wedges the rest of the session (#13). HTML 403 alone is not proof
|
|
3487
|
+
// it is payload-specific — request-wide edge blocks share that shape
|
|
3488
|
+
// — so allow at most one consecutive drop without an intervening
|
|
3489
|
+
// success; a second consecutive failure holds for retry.
|
|
3490
|
+
if (
|
|
3491
|
+
persistent &&
|
|
3492
|
+
persistent.scope === "exchange" &&
|
|
3493
|
+
(consecutiveExchangeDrops < 1 || canDropAfterBackoff)
|
|
3494
|
+
) {
|
|
3495
|
+
const boundary = boundaryAfterExchange(turnLines, exchange.user_line);
|
|
3496
|
+
const nextLine =
|
|
3497
|
+
boundary != null
|
|
3498
|
+
? boundary
|
|
3499
|
+
: exchange.next_line_number != null
|
|
3500
|
+
? exchange.next_line_number
|
|
3501
|
+
: safeLine;
|
|
3502
|
+
const remainingAfter =
|
|
3503
|
+
allExchanges.length - captured - dropped - 1;
|
|
3504
|
+
const drop = persistExchangeCheckpoint(sessionId, state, nextLine, {
|
|
3505
|
+
delivery:
|
|
3506
|
+
remainingAfter > 0 || allExchanges.length > maxBatch
|
|
3507
|
+
? "pending"
|
|
3508
|
+
: "idle",
|
|
3509
|
+
queued_exchanges: Math.max(0, remainingAfter),
|
|
3510
|
+
// Nothing was stored, so the last-success stamp must not move.
|
|
3511
|
+
last_success_at: state.last_success_at || null,
|
|
3512
|
+
dropped_exchanges: (state.dropped_exchanges || 0) + 1,
|
|
3513
|
+
last_drop_reason: persistent.reason,
|
|
3514
|
+
consecutive_exchange_drops: consecutiveExchangeDrops + 1,
|
|
3515
|
+
agent_id: effectiveAgentId,
|
|
3516
|
+
agent_id_source: effectiveSource,
|
|
3517
|
+
conversation_id: convId,
|
|
3518
|
+
datapack_id: datapackPin ? datapackPin.datapack_id : null,
|
|
3519
|
+
datapack_name: datapackPin ? datapackPin.datapack_name : null,
|
|
3520
|
+
});
|
|
3521
|
+
if (drop.ok) {
|
|
3522
|
+
state = drop.state;
|
|
3523
|
+
watermarkLine = state.last_line_number;
|
|
3524
|
+
dropped++;
|
|
3525
|
+
consecutiveExchangeDrops = state.consecutive_exchange_drops;
|
|
3526
|
+
cursorAdvanced = true;
|
|
3527
|
+
process.stderr.write(
|
|
3528
|
+
`[meko-capture] Dropped un-storable exchange (uuid=${exchange.user_uuid}): ` +
|
|
3529
|
+
`${persistent.reason}. Cursor advanced to line ${watermarkLine}; ` +
|
|
3530
|
+
`capture continues for the rest of this session.\n`,
|
|
3531
|
+
);
|
|
3532
|
+
continue;
|
|
3533
|
+
}
|
|
3534
|
+
// Cursor advance itself failed — fall through and hold, rather than
|
|
3535
|
+
// losing the exchange without a durable record that it was dropped.
|
|
3536
|
+
process.stderr.write(
|
|
3537
|
+
`[meko-capture] Could not record dropped exchange (uuid=${exchange.user_uuid}): ${drop.error}\n`,
|
|
3538
|
+
);
|
|
3539
|
+
} else if (persistent && persistent.scope === "exchange") {
|
|
3540
|
+
process.stderr.write(
|
|
3541
|
+
`[meko-capture] Holding after consecutive edge 403s (uuid=${exchange.user_uuid}): ` +
|
|
3542
|
+
`${persistent.reason}. A second consecutive HTML 403 without an ` +
|
|
3543
|
+
`intervening success may be request-wide; queue is held for retry.\n`,
|
|
3544
|
+
);
|
|
3545
|
+
}
|
|
3546
|
+
|
|
3547
|
+
failed++;
|
|
3548
|
+
const failureClass = persistent
|
|
3549
|
+
? "persistent"
|
|
3550
|
+
: captureState.classifyFailure(err);
|
|
3551
|
+
state.delivery =
|
|
3552
|
+
failureClass === "transient" ? "retry_wait" : "blocked";
|
|
3553
|
+
state.failure_class = failureClass;
|
|
3554
|
+
// Prefer the classifier reason so quota/entitlement text survives into
|
|
3555
|
+
// state and SessionStart notices instead of an opaque http_403.
|
|
3556
|
+
state.last_error = persistentFailureText(persistent, err.message);
|
|
3557
|
+
state.last_failure_scope = persistent ? persistent.scope : null;
|
|
3558
|
+
state.attempt_count = (state.attempt_count || 0) + 1;
|
|
3559
|
+
state.next_retry_at = captureState.nextRetryAt({
|
|
3560
|
+
failure_class: failureClass,
|
|
3561
|
+
attempt_count: state.attempt_count,
|
|
3562
|
+
});
|
|
3563
|
+
state.queued_exchanges = allExchanges.length - captured - dropped;
|
|
3564
|
+
captureState.writeState(sessionId, state);
|
|
3565
|
+
process.stderr.write(
|
|
3566
|
+
`[meko-capture] Failed to add message (uuid=${exchange.user_uuid}): ${state.last_error}\n`,
|
|
3567
|
+
);
|
|
3568
|
+
break;
|
|
3569
|
+
}
|
|
3570
|
+
}
|
|
3571
|
+
|
|
3572
|
+
// Dropped exchanges are resolved, not held: they can never be stored, so
|
|
3573
|
+
// they must not count as backlog or the session reads as permanently behind.
|
|
3574
|
+
const held = allExchanges.length - captured - dropped;
|
|
3575
|
+
if (leaseFailure) {
|
|
3576
|
+
// Progress so far is already checkpointed. Nothing is recorded as a
|
|
3577
|
+
// delivery failure: the server never saw the next exchange.
|
|
3578
|
+
const lost = leaseFailure.failure_class === "ownership";
|
|
3579
|
+
process.stderr.write(
|
|
3580
|
+
lost
|
|
3581
|
+
? `[meko-capture] drain lease lost after ${captured} exchange(s); leaving the rest to its new owner.\n`
|
|
3582
|
+
: `[meko-capture] could not renew the drain lease after ${captured} exchange(s): ${leaseFailure.error}; stopping this batch.\n`,
|
|
3583
|
+
);
|
|
3584
|
+
return makeDrainResult({
|
|
3585
|
+
status: "retry_wait",
|
|
3586
|
+
session_id: sessionId,
|
|
3587
|
+
captured,
|
|
3588
|
+
dropped,
|
|
3589
|
+
queued_remaining: held,
|
|
3590
|
+
failure_class: leaseFailure.failure_class,
|
|
3591
|
+
error: lost
|
|
3592
|
+
? "drain lease lost to another worker"
|
|
3593
|
+
: `drain lease renewal failed: ${leaseFailure.error}`,
|
|
3594
|
+
cursor_advanced: cursorAdvanced,
|
|
3595
|
+
last_line_number: state.last_line_number || 0,
|
|
3596
|
+
lifecycle: state.lifecycle,
|
|
3597
|
+
delivery: state.delivery,
|
|
3598
|
+
});
|
|
3599
|
+
}
|
|
3600
|
+
if (failed === 0 && allExchanges.length <= maxBatch && pendingFromLine === null) {
|
|
3601
|
+
watermarkLine = safeLine;
|
|
3602
|
+
if (watermarkLine >= state.last_line_number) {
|
|
3603
|
+
const adv = captureState.advanceCursor(state, watermarkLine, {
|
|
3604
|
+
clearSeed: true,
|
|
3605
|
+
});
|
|
3606
|
+
if (adv.ok) state = adv.state;
|
|
3607
|
+
}
|
|
3608
|
+
if (finalizeInterrupted || state.lifecycle === "closing") {
|
|
3609
|
+
state.lifecycle = "closed";
|
|
3610
|
+
state.delivery = "complete";
|
|
3611
|
+
state.queued_exchanges = 0;
|
|
3612
|
+
} else {
|
|
3613
|
+
state.delivery = "idle";
|
|
3614
|
+
state.queued_exchanges = 0;
|
|
3615
|
+
}
|
|
3616
|
+
clearResolvedFailure(state);
|
|
3617
|
+
captureState.writeState(sessionId, state);
|
|
3618
|
+
cursorAdvanced =
|
|
3619
|
+
captured > 0 || dropped > 0 || cursorAdvanced || synthesizedInterrupted;
|
|
3620
|
+
} else if (failed === 0 && allExchanges.length > maxBatch) {
|
|
3621
|
+
state.delivery = "pending";
|
|
3622
|
+
state.queued_exchanges = held;
|
|
3623
|
+
captureState.writeState(sessionId, state);
|
|
3624
|
+
process.stderr.write(
|
|
3625
|
+
`[meko-capture] captured batch of ${captured}/${allExchanges.length} exchange(s); ` +
|
|
3626
|
+
`checkpointed through line ${watermarkLine}.\n`,
|
|
3627
|
+
);
|
|
3628
|
+
} else if (failed > 0) {
|
|
3629
|
+
process.stderr.write(
|
|
3630
|
+
`[meko-capture] capture stopped at first failure; ` +
|
|
3631
|
+
`holding watermark at line ${state.last_line_number} for retry.\n`,
|
|
3632
|
+
);
|
|
3633
|
+
}
|
|
3634
|
+
|
|
3635
|
+
if (dropped > 0) {
|
|
3636
|
+
process.stderr.write(
|
|
3637
|
+
`[meko-capture] ${dropped} exchange(s) could not be stored and were ` +
|
|
3638
|
+
`skipped so the rest of the session still captures.\n`,
|
|
3639
|
+
);
|
|
3640
|
+
}
|
|
3641
|
+
|
|
3642
|
+
const finalState = captureState.readState(sessionId);
|
|
3643
|
+
const s = finalState.ok ? finalState.state : state;
|
|
3644
|
+
let status = "ok";
|
|
3645
|
+
if (failed > 0) {
|
|
3646
|
+
status =
|
|
3647
|
+
s.failure_class === "transient" || s.delivery === "retry_wait"
|
|
3648
|
+
? "retry_wait"
|
|
3649
|
+
: s.delivery === "blocked"
|
|
3650
|
+
? "blocked"
|
|
3651
|
+
: "partial";
|
|
3652
|
+
} else if (held > 0) {
|
|
3653
|
+
status = "partial";
|
|
3654
|
+
}
|
|
3655
|
+
|
|
3656
|
+
return makeDrainResult({
|
|
3657
|
+
status,
|
|
3658
|
+
session_id: sessionId,
|
|
3659
|
+
captured,
|
|
3660
|
+
dropped,
|
|
3661
|
+
queued_remaining: held,
|
|
3662
|
+
failure_class: failed > 0 ? s.failure_class || null : null,
|
|
3663
|
+
error: failed > 0 ? s.last_error || null : null,
|
|
3664
|
+
cursor_advanced: cursorAdvanced,
|
|
3665
|
+
last_line_number: s.last_line_number || watermarkLine,
|
|
3666
|
+
lifecycle: s.lifecycle,
|
|
3667
|
+
delivery: s.delivery,
|
|
3668
|
+
});
|
|
3669
|
+
} finally {
|
|
3670
|
+
if (leaseToken) captureState.releaseLease(sessionId, leaseToken);
|
|
3671
|
+
}
|
|
3672
|
+
}
|
|
3673
|
+
|
|
1257
3674
|
// --- Main ---
|
|
1258
3675
|
|
|
1259
3676
|
async function main() {
|
|
1260
3677
|
const hookType = process.argv[2];
|
|
1261
|
-
|
|
1262
|
-
|
|
3678
|
+
const known = [
|
|
3679
|
+
"session-start",
|
|
3680
|
+
"before-submit-prompt",
|
|
3681
|
+
"pre-compact",
|
|
3682
|
+
"session-end",
|
|
3683
|
+
"checkpoint",
|
|
3684
|
+
"drain",
|
|
3685
|
+
"recover",
|
|
3686
|
+
];
|
|
3687
|
+
if (!hookType || !known.includes(hookType)) {
|
|
3688
|
+
process.stderr.write(
|
|
3689
|
+
"Usage: capture.js <session-start|before-submit-prompt|pre-compact|session-end|checkpoint|drain|recover>\n",
|
|
3690
|
+
);
|
|
1263
3691
|
process.exit(1);
|
|
1264
3692
|
}
|
|
1265
3693
|
|
|
1266
|
-
// Read hook input from stdin
|
|
1267
3694
|
let hookInput;
|
|
1268
3695
|
try {
|
|
1269
3696
|
const stdin = fs.readFileSync(0, "utf-8");
|
|
1270
|
-
hookInput = JSON.parse(stdin);
|
|
3697
|
+
hookInput = JSON.parse(stdin || "{}");
|
|
1271
3698
|
} catch (err) {
|
|
1272
|
-
process.stderr.write(
|
|
1273
|
-
|
|
3699
|
+
process.stderr.write(
|
|
3700
|
+
`[meko-capture] Failed to parse hook input from stdin: ${err.message}\n`,
|
|
3701
|
+
);
|
|
3702
|
+
if (hookType === "drain" || hookType === "recover") {
|
|
3703
|
+
process.stdout.write(
|
|
3704
|
+
JSON.stringify(
|
|
3705
|
+
makeDrainResult({
|
|
3706
|
+
status: "blocked",
|
|
3707
|
+
failure_class: "persistent",
|
|
3708
|
+
error: `invalid stdin JSON: ${err.message}`,
|
|
3709
|
+
}),
|
|
3710
|
+
) + "\n",
|
|
3711
|
+
);
|
|
3712
|
+
} else {
|
|
3713
|
+
process.stdout.write(hookOutput(""));
|
|
3714
|
+
}
|
|
1274
3715
|
return;
|
|
1275
3716
|
}
|
|
1276
3717
|
|
|
1277
|
-
// SessionStart: create conversation + watermark, output context
|
|
1278
3718
|
if (hookType === "session-start") {
|
|
1279
3719
|
await handleSessionStart(hookInput);
|
|
1280
3720
|
return;
|
|
@@ -1285,143 +3725,82 @@ async function main() {
|
|
|
1285
3725
|
return;
|
|
1286
3726
|
}
|
|
1287
3727
|
|
|
1288
|
-
// PreCompact / SessionEnd: capture transcript exchanges
|
|
1289
3728
|
const transcriptPath =
|
|
1290
3729
|
hookInput.transcript_path ||
|
|
1291
|
-
(hookInput.hookSpecificInput || {}).transcript_path
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
3730
|
+
(hookInput.hookSpecificInput || {}).transcript_path ||
|
|
3731
|
+
null;
|
|
3732
|
+
const sessionId =
|
|
3733
|
+
(hookInput.session_id && String(hookInput.session_id)) ||
|
|
3734
|
+
(transcriptPath ? path.basename(transcriptPath, ".jsonl") : "") ||
|
|
3735
|
+
extractSessionId(hookInput, transcriptPath);
|
|
3736
|
+
|
|
3737
|
+
const drainOpts = {
|
|
3738
|
+
session_id: sessionId,
|
|
3739
|
+
transcript_path: transcriptPath,
|
|
3740
|
+
mode: hookType,
|
|
3741
|
+
lifecycle: hookInput.lifecycle,
|
|
3742
|
+
confirmed_dead: Boolean(hookInput.confirmed_dead),
|
|
3743
|
+
recorded_owner_only: Boolean(hookInput.recorded_owner_only),
|
|
3744
|
+
max_exchanges:
|
|
3745
|
+
hookInput.max_exchanges != null
|
|
3746
|
+
? Number(hookInput.max_exchanges)
|
|
3747
|
+
: undefined,
|
|
3748
|
+
force_retry: Boolean(hookInput.force_retry),
|
|
3749
|
+
};
|
|
1297
3750
|
|
|
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;
|
|
3751
|
+
// recover alias: single-session drain under recorded owner + dead finalization
|
|
3752
|
+
if (hookType === "recover") {
|
|
3753
|
+
drainOpts.recorded_owner_only =
|
|
3754
|
+
hookInput.recorded_owner_only !== undefined
|
|
3755
|
+
? Boolean(hookInput.recorded_owner_only)
|
|
3756
|
+
: true;
|
|
3757
|
+
drainOpts.confirmed_dead =
|
|
3758
|
+
hookInput.confirmed_dead !== undefined
|
|
3759
|
+
? Boolean(hookInput.confirmed_dead)
|
|
3760
|
+
: true;
|
|
1319
3761
|
}
|
|
1320
3762
|
|
|
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(""));
|
|
3763
|
+
if (hookType === "drain" || hookType === "recover") {
|
|
3764
|
+
const result = await drainSession(drainOpts);
|
|
3765
|
+
process.stdout.write(JSON.stringify(result) + "\n");
|
|
1341
3766
|
return;
|
|
1342
3767
|
}
|
|
1343
3768
|
|
|
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
|
-
);
|
|
3769
|
+
if (!transcriptPath || !fs.existsSync(transcriptPath)) {
|
|
1351
3770
|
process.stdout.write(hookOutput(""));
|
|
1352
3771
|
return;
|
|
1353
3772
|
}
|
|
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
|
-
);
|
|
3773
|
+
if (hookType === "session-end") {
|
|
3774
|
+
drainOpts.lifecycle = "closing";
|
|
1413
3775
|
}
|
|
1414
|
-
writeWatermark(wmPath, convId, watermarkLine, agentId, datapackPin);
|
|
1415
3776
|
|
|
1416
|
-
|
|
3777
|
+
const result = await drainSession(drainOpts);
|
|
1417
3778
|
const context =
|
|
1418
|
-
hookType === "pre-compact"
|
|
1419
|
-
? `Pre-compact: captured ${captured} exchanges to Meko conversation
|
|
3779
|
+
hookType === "pre-compact" && result.captured > 0
|
|
3780
|
+
? `Pre-compact: captured ${result.captured} exchanges to Meko conversation`
|
|
1420
3781
|
: "";
|
|
1421
3782
|
process.stdout.write(hookOutput(context));
|
|
1422
3783
|
}
|
|
1423
3784
|
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
3785
|
+
module.exports = {
|
|
3786
|
+
deriveAgentId,
|
|
3787
|
+
deriveLegacyAgentId,
|
|
3788
|
+
drainSession,
|
|
3789
|
+
makeDrainResult,
|
|
3790
|
+
boundaryAfterExchange,
|
|
3791
|
+
realUserTurnLines,
|
|
3792
|
+
INTERRUPTED_ASSISTANT_OUTPUT,
|
|
3793
|
+
buildExchanges,
|
|
3794
|
+
buildInterruptedExchange,
|
|
3795
|
+
extractExchanges,
|
|
3796
|
+
addMessage,
|
|
3797
|
+
isMissingConversationError,
|
|
3798
|
+
recoverMissingConversation,
|
|
3799
|
+
};
|
|
3800
|
+
|
|
3801
|
+
if (require.main === module) {
|
|
3802
|
+
main().catch((err) => {
|
|
3803
|
+
process.stderr.write(`[meko-capture] Fatal error: ${err.message}\n`);
|
|
3804
|
+
process.stdout.write(hookOutput(""));
|
|
3805
|
+
});
|
|
3806
|
+
}
|