@yugabytedb/meko-mcp 2.2.0 → 2.5.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 +168 -61
- package/assets/hooks-handlers/lib/capture-cleanup-cli.js +2134 -0
- package/assets/hooks-handlers/lib/capture-notices.js +224 -14
- package/assets/hooks-handlers/lib/capture-state.js +206 -1
- package/assets/hooks-handlers/lib/capture-status-cli.js +6 -2
- package/assets/hooks-handlers/lib/capture.js +1364 -139
- package/assets/hooks-handlers/lib/checkpoint-timer.js +445 -18
- package/assets/hooks-handlers/lib/journal-bridge.js +218 -0
- package/assets/hooks-handlers/lib/kiro-handler.js +26 -4
- package/assets/hooks-handlers/lib/opencode-handler.js +643 -0
- package/assets/hooks-handlers/lib/opencode-normalizer.js +145 -0
- package/assets/hooks-handlers/lib/terminal-handoff.js +10 -1
- package/assets/opencode-plugin/meko.js +213 -0
- package/assets/scripts/meko-statusline.sh +102 -19
- package/assets/skills/meko-mcp-tools/SKILL.md +59 -16
- package/assets/skills/meko-mcp-tools/references/_sections.md +2 -2
- package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +15 -7
- package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +275 -19
- package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +7 -4
- package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +39 -11
- package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +10 -6
- package/assets/skills/meko-mcp-tools/references/tools-overview.md +44 -29
- package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +79 -14
- package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +47 -1
- package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +6 -39
- package/assets/skills/meko-mcp-tools-desktop/SKILL.md +57 -21
- package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +4 -4
- 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 +8 -6
- package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +284 -38
- package/assets/skills/meko-mcp-tools-desktop/references/tools-known-limitations.md +32 -10
- package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +16 -12
- package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +46 -31
- package/assets/skills/meko-mcp-tools-desktop/references/tools-rag-workflow.md +79 -14
- package/assets/skills/meko-mcp-tools-desktop/references/tools-troubleshooting.md +69 -1
- package/assets/skills/meko-select-datapack-desktop/SKILL.md +93 -14
- package/assets/skills/meko-select-datapack-desktop/references/cookbook.md +44 -12
- package/assets/skills/meko-select-datapack-desktop/references/role-display-future.md +14 -10
- package/assets/skills/meko-select-datapack-desktop/references/troubleshooting.md +17 -5
- package/bin/create-meko-setup.mjs +186 -30
- package/lib/autofix.mjs +100 -0
- package/lib/browser-login.mjs +495 -0
- package/lib/capture-cleanup.mjs +63 -0
- package/lib/clients/claude-code.mjs +1 -0
- package/lib/clients/codex.mjs +80 -35
- package/lib/clients/common.mjs +7 -0
- package/lib/clients/index.mjs +2 -0
- package/lib/clients/kiro.mjs +5 -5
- package/lib/clients/opencode.mjs +537 -0
- package/lib/config.mjs +68 -1
- package/lib/installer.mjs +18 -2
- package/lib/migrate/adapters/claude-code.mjs +11 -2
- package/lib/migrate/adapters/codex.mjs +9 -2
- 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/datapack.mjs +254 -16
- package/lib/migrate/id.mjs +195 -13
- package/lib/migrate/mcp-client.mjs +59 -1
- package/lib/migrate/opencode-normalizer.mjs +132 -0
- package/lib/migrate/orchestrator.mjs +294 -78
- package/lib/migrate/progress.mjs +239 -5
- package/lib/migrate/registry.mjs +72 -0
- package/lib/migrate.mjs +97 -9
- package/lib/validate.mjs +111 -22
- package/package.json +5 -5
- package/scripts/bundle-assets.mjs +4 -0
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
* node capture.js checkpoint # stdin hook JSON → drainSession
|
|
14
14
|
* node capture.js drain # stdin JSON → drain one session; stdout DrainResult
|
|
15
15
|
* node capture.js recover # alias of drain (single-session recovery)
|
|
16
|
+
* node capture.js move # stdin {"session_id"} → move the unsent
|
|
17
|
+
* # tail to a new conversation; stdout
|
|
18
|
+
* # {ok, from, to, last_line_number} or
|
|
19
|
+
* # {ok:false, busy?, error}
|
|
16
20
|
*
|
|
17
21
|
* drain / recover stdin contract (Workstream C):
|
|
18
22
|
* {
|
|
@@ -28,10 +32,17 @@
|
|
|
28
32
|
* Environment:
|
|
29
33
|
* MEKO_MCP_URL MCP server URL (default: http://localhost:8000/mcp)
|
|
30
34
|
* MEKO_AGENT_ID Agent identifier override; if unset, derived from the
|
|
31
|
-
* session's cwd
|
|
32
|
-
*
|
|
35
|
+
* session's cwd by walking parents for `.git` and taking
|
|
36
|
+
* the repo root's basename, as `claude_code:<repo>` (or
|
|
37
|
+
* the bare client name if the session is outside a repo,
|
|
38
|
+
* so sibling subdirectories in unrelated repos can't
|
|
39
|
+
* silently collide). See deriveAgentId().
|
|
33
40
|
* MEKO_API_KEY API key for Cloud Meko auth (optional, omit for local)
|
|
34
41
|
* MEKO_API_TIMEOUT Request timeout in seconds (default: 10)
|
|
42
|
+
* MEKO_ADD_MESSAGE_TIMEOUT Timeout for conversation_add_message in
|
|
43
|
+
* seconds (default: 60). Storing a large turn can take
|
|
44
|
+
* the server more than 15s. PreCompact runs in the
|
|
45
|
+
* foreground and keeps MEKO_API_TIMEOUT instead.
|
|
35
46
|
* MEKO_WATERMARK_DIR Watermark directory (default: ~/.claude/meko-capture)
|
|
36
47
|
* MEKO_MAX_CAPTURE_BATCH_SIZE Max exchanges per drain (default 5, min 1)
|
|
37
48
|
*/
|
|
@@ -52,16 +63,73 @@ const MEKO_MCP_URL = process.env.MEKO_MCP_URL || "http://localhost:8000/mcp";
|
|
|
52
63
|
const COMMON_BUCKET_AGENT_ID = "meko_agent";
|
|
53
64
|
const AGENT_ID_MAX_LEN = 64;
|
|
54
65
|
|
|
66
|
+
/**
|
|
67
|
+
* The user's home directory, read fresh so a re-pointed `$HOME` (tests,
|
|
68
|
+
* sandboxes) is honored. Mirror of `homeBoundary` in
|
|
69
|
+
* installer/lib/migrate/id.mjs.
|
|
70
|
+
*/
|
|
71
|
+
function homeBoundary() {
|
|
72
|
+
const fromEnv = (process.env.HOME || "").trim();
|
|
73
|
+
if (fromEnv) return path.resolve(fromEnv);
|
|
74
|
+
try {
|
|
75
|
+
const h = os.homedir();
|
|
76
|
+
return h ? path.resolve(h) : null;
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Walk parents of `cwd` looking for a `.git` entry (directory for a normal
|
|
84
|
+
* checkout, file for a submodule / linked worktree). Returns the absolute
|
|
85
|
+
* repo-root path, or `null` if none is found. Mirror of `resolveRepoRoot`
|
|
86
|
+
* in installer/lib/migrate/id.mjs.
|
|
87
|
+
*
|
|
88
|
+
* The walk stops at `$HOME` (from below only, so a git-tracked home is still
|
|
89
|
+
* its own repo root): without that bound, dotfiles-under-git would make every
|
|
90
|
+
* non-repo directory under home derive `claude_code:<username>`.
|
|
91
|
+
*/
|
|
92
|
+
function resolveRepoRoot(cwd) {
|
|
93
|
+
if (!cwd || typeof cwd !== "string") return null;
|
|
94
|
+
let dir;
|
|
95
|
+
try {
|
|
96
|
+
dir = path.resolve(cwd);
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
const start = dir;
|
|
101
|
+
const home = homeBoundary();
|
|
102
|
+
let prev = null;
|
|
103
|
+
while (dir && dir !== prev) {
|
|
104
|
+
// A directory below a git-tracked home must not inherit home's `.git`.
|
|
105
|
+
if (home && dir === home && dir !== start) return null;
|
|
106
|
+
try {
|
|
107
|
+
fs.statSync(path.join(dir, ".git"));
|
|
108
|
+
return dir;
|
|
109
|
+
} catch {
|
|
110
|
+
// keep walking
|
|
111
|
+
}
|
|
112
|
+
// Check home itself for `.git`, then stop. A non-git home must not
|
|
113
|
+
// inherit repository state from /Users, /home, or /.
|
|
114
|
+
if (home && dir === home) return null;
|
|
115
|
+
prev = dir;
|
|
116
|
+
dir = path.dirname(dir);
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
55
121
|
/**
|
|
56
122
|
* Derive an `agent_id` for a Meko write. Mirror of `deriveAgentId` in
|
|
57
123
|
* installer/lib/migrate/id.mjs — kept inline because capture.js is published
|
|
58
124
|
* in a separate package with no module dependencies on the installer.
|
|
59
125
|
*
|
|
60
126
|
* - `envOverride` (typically MEKO_AGENT_ID) wins if non-empty.
|
|
61
|
-
* - Coding clients (claude_code, cursor, codex, kiro) →
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
127
|
+
* - Coding clients (claude_code, cursor, codex, kiro, opencode) →
|
|
128
|
+
* `<client>:<repo-basename>`, where the basename comes from the
|
|
129
|
+
* nearest ancestor of `cwd` that contains `.git`. Lowercased,
|
|
130
|
+
* non-`[a-z0-9-]` runs collapse to `-`. Outside a repo → bare
|
|
131
|
+
* client name (keeps sibling `docs`/`src`/`webapp` directories in
|
|
132
|
+
* unrelated repos from collapsing into one agent_id — MEKO-590).
|
|
65
133
|
* - Loose clients (claude-desktop) → `claude_desktop`.
|
|
66
134
|
* - Anything else → `meko_agent` (common cross-project bucket).
|
|
67
135
|
*/
|
|
@@ -74,13 +142,16 @@ function deriveAgentId(opts) {
|
|
|
74
142
|
client !== "claude_code" &&
|
|
75
143
|
client !== "cursor" &&
|
|
76
144
|
client !== "codex" &&
|
|
77
|
-
client !== "kiro"
|
|
145
|
+
client !== "kiro" &&
|
|
146
|
+
client !== "opencode"
|
|
78
147
|
) {
|
|
79
148
|
return COMMON_BUCKET_AGENT_ID;
|
|
80
149
|
}
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
150
|
+
const repoRoot = opts && Object.prototype.hasOwnProperty.call(opts, "repoRoot")
|
|
151
|
+
? opts.repoRoot
|
|
152
|
+
: resolveRepoRoot(opts && opts.cwd);
|
|
153
|
+
if (!repoRoot) return client;
|
|
154
|
+
const project = path.basename(repoRoot).toLowerCase()
|
|
84
155
|
.replace(/[^a-z0-9-]+/g, "-")
|
|
85
156
|
.replace(/^-+|-+$/g, "")
|
|
86
157
|
.slice(0, AGENT_ID_MAX_LEN);
|
|
@@ -88,6 +159,31 @@ function deriveAgentId(opts) {
|
|
|
88
159
|
return `${client}:${project}`;
|
|
89
160
|
}
|
|
90
161
|
|
|
162
|
+
/**
|
|
163
|
+
* Pre-MEKO-590 coding-agent derivation. Existing pin filenames and session
|
|
164
|
+
* state may still use this cwd-leaf id, so it remains a read-only alias while
|
|
165
|
+
* new sessions use the repo-root id from `deriveAgentId`.
|
|
166
|
+
*/
|
|
167
|
+
function deriveLegacyAgentId(opts) {
|
|
168
|
+
const envOverride = opts && typeof opts.envOverride === "string"
|
|
169
|
+
? opts.envOverride.trim()
|
|
170
|
+
: "";
|
|
171
|
+
if (envOverride) return envOverride;
|
|
172
|
+
const client = opts && opts.client ? String(opts.client).trim() : "";
|
|
173
|
+
if (client === "claude-desktop" || client === "claude_desktop") {
|
|
174
|
+
return "claude_desktop";
|
|
175
|
+
}
|
|
176
|
+
if (!["claude_code", "cursor", "codex", "kiro"].includes(client)) {
|
|
177
|
+
return COMMON_BUCKET_AGENT_ID;
|
|
178
|
+
}
|
|
179
|
+
const rawBase = opts && opts.cwd ? path.basename(opts.cwd) : "";
|
|
180
|
+
const project = rawBase.toLowerCase()
|
|
181
|
+
.replace(/[^a-z0-9-]+/g, "-")
|
|
182
|
+
.replace(/^-+|-+$/g, "")
|
|
183
|
+
.slice(0, AGENT_ID_MAX_LEN);
|
|
184
|
+
return project ? `${client}:${project}` : client;
|
|
185
|
+
}
|
|
186
|
+
|
|
91
187
|
/**
|
|
92
188
|
* Map MEKO_HOOK_CLIENT to a known coding-client identity. The installer
|
|
93
189
|
* prefixes each client's hook commands with `MEKO_HOOK_CLIENT=<client>`;
|
|
@@ -98,7 +194,7 @@ function deriveAgentId(opts) {
|
|
|
98
194
|
*/
|
|
99
195
|
function resolveHookClient() {
|
|
100
196
|
const client = (process.env.MEKO_HOOK_CLIENT || "").trim();
|
|
101
|
-
if (client === "cursor" || client === "codex" || client === "kiro") return client;
|
|
197
|
+
if (client === "cursor" || client === "codex" || client === "kiro" || client === "opencode") return client;
|
|
102
198
|
return "claude_code";
|
|
103
199
|
}
|
|
104
200
|
|
|
@@ -114,9 +210,7 @@ function resolveHookClient() {
|
|
|
114
210
|
* 4. cwd from the hook input.
|
|
115
211
|
* 5. process.cwd() — last resort if no transcript metadata yet.
|
|
116
212
|
*/
|
|
117
|
-
function
|
|
118
|
-
const envOverride = process.env.MEKO_AGENT_ID;
|
|
119
|
-
if (envOverride && envOverride.trim()) return envOverride.trim();
|
|
213
|
+
function pickSessionCwd(transcriptPath, hookInput) {
|
|
120
214
|
const meta = transcriptPath ? peekTranscriptMetadata(transcriptPath) : null;
|
|
121
215
|
const nested = hookInput && hookInput.hookSpecificInput && typeof hookInput.hookSpecificInput === "object"
|
|
122
216
|
? hookInput.hookSpecificInput
|
|
@@ -127,7 +221,7 @@ function resolveSessionAgentId(transcriptPath, hookInput = null) {
|
|
|
127
221
|
const nestedRoots = Array.isArray(nested.workspace_roots) ? nested.workspace_roots : [];
|
|
128
222
|
const workspaceRoot = [...roots, ...nestedRoots]
|
|
129
223
|
.find((item) => typeof item === "string" && item.trim());
|
|
130
|
-
|
|
224
|
+
return (
|
|
131
225
|
(meta && meta.cwd) ||
|
|
132
226
|
hookInput?.workspace_path ||
|
|
133
227
|
hookInput?.workspacePath ||
|
|
@@ -138,14 +232,71 @@ function resolveSessionAgentId(transcriptPath, hookInput = null) {
|
|
|
138
232
|
workspaceRoot ||
|
|
139
233
|
hookInput?.cwd ||
|
|
140
234
|
nested.cwd ||
|
|
141
|
-
process.cwd()
|
|
235
|
+
process.cwd()
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function resolveSessionAgentId(transcriptPath, hookInput = null, precomputedCwd = null) {
|
|
240
|
+
const envOverride = process.env.MEKO_AGENT_ID;
|
|
241
|
+
if (envOverride && envOverride.trim()) return envOverride.trim();
|
|
242
|
+
// SessionStart picks the cwd once and passes it in, so the transcript is
|
|
243
|
+
// peeked once per hook invocation rather than once per consumer.
|
|
244
|
+
const cwd = precomputedCwd || pickSessionCwd(transcriptPath, hookInput);
|
|
142
245
|
const client = resolveHookClient();
|
|
143
246
|
return deriveAgentId({ client, cwd, envOverride });
|
|
144
247
|
}
|
|
145
248
|
|
|
249
|
+
/**
|
|
250
|
+
* Emit a one-line derivation log to stderr so future MEKO-590–class
|
|
251
|
+
* collisions ("why did session X land under agent_id Y?") are diagnosable
|
|
252
|
+
* from capture logs. Called once at SessionStart, not on every drain, so
|
|
253
|
+
* test assertions on downstream hooks stay stable.
|
|
254
|
+
*
|
|
255
|
+
* Takes the already-picked `cwd` so the log reports the SAME cwd the
|
|
256
|
+
* derivation actually used (Cursor's `workspace_roots` included — the
|
|
257
|
+
* hook's raw cwd is `~/.cursor`, which would print misleadingly) without
|
|
258
|
+
* a second transcript peek.
|
|
259
|
+
*
|
|
260
|
+
* Reports `derivedAgentId`, NOT necessarily the id this session writes
|
|
261
|
+
* under: the watermark-reuse and resume branches run
|
|
262
|
+
* `reconcileCachedAgentId` afterwards and may preserve a cached bucket.
|
|
263
|
+
* `logEffectiveAgentId` closes that gap on those paths.
|
|
264
|
+
*/
|
|
265
|
+
function logAgentIdDerivation(cwd, derivedAgentId) {
|
|
266
|
+
const envOverride = (process.env.MEKO_AGENT_ID || "").trim();
|
|
267
|
+
if (envOverride) {
|
|
268
|
+
process.stderr.write(
|
|
269
|
+
`[meko-capture] deriveAgentId: source=env-override derivedAgentId=${derivedAgentId}\n`,
|
|
270
|
+
);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
const client = resolveHookClient();
|
|
274
|
+
const repoRoot = resolveRepoRoot(cwd);
|
|
275
|
+
process.stderr.write(
|
|
276
|
+
`[meko-capture] deriveAgentId: client=${client} cwd=${cwd || ""} ` +
|
|
277
|
+
`resolvedRepoRoot=${repoRoot || "<none>"} derivedAgentId=${derivedAgentId}\n`,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Log the agent_id this session actually writes under, when a cached bucket
|
|
283
|
+
* won over the fresh derivation (`/clear`, `/compact`, resume, or a HOLD).
|
|
284
|
+
* Silent when it matches, so the common path stays one line.
|
|
285
|
+
*
|
|
286
|
+
* Without this, "why did session X land under agent_id Y?" is unanswerable
|
|
287
|
+
* from the logs in exactly the cases where the answer is not the derivation.
|
|
288
|
+
*/
|
|
289
|
+
function logEffectiveAgentId(derivedAgentId, effectiveAgentId, reason) {
|
|
290
|
+
if (!effectiveAgentId || effectiveAgentId === derivedAgentId) return;
|
|
291
|
+
process.stderr.write(
|
|
292
|
+
`[meko-capture] deriveAgentId: effectiveAgentId=${effectiveAgentId} ` +
|
|
293
|
+
`(overrides derivedAgentId=${derivedAgentId}; reason=${reason})\n`,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
146
297
|
// Coding clients that derive a `<client>:<repo>` agent_id. Kept in
|
|
147
298
|
// sync with deriveAgentId's coding-client branch.
|
|
148
|
-
const CODING_CLIENTS = ["claude_code", "cursor", "codex", "kiro"];
|
|
299
|
+
const CODING_CLIENTS = ["claude_code", "cursor", "codex", "kiro", "opencode"];
|
|
149
300
|
|
|
150
301
|
/**
|
|
151
302
|
* Extract the client segment of an agent_id: the part before the first `:`,
|
|
@@ -269,6 +420,12 @@ function ownerChanged(cachedAgentId, resolvedAgentId, convId) {
|
|
|
269
420
|
|
|
270
421
|
const MEKO_API_KEY = process.env.MEKO_API_KEY || "";
|
|
271
422
|
const MEKO_API_TIMEOUT = parseInt(process.env.MEKO_API_TIMEOUT || "10", 10) * 1000;
|
|
423
|
+
const MEKO_ADD_MESSAGE_TIMEOUT = (() => {
|
|
424
|
+
// A value that doesn't parse would reach the lease TTL as NaN and throw
|
|
425
|
+
// inside renewLease, so fall back to the default instead.
|
|
426
|
+
const n = Number.parseInt(process.env.MEKO_ADD_MESSAGE_TIMEOUT || "", 10);
|
|
427
|
+
return (Number.isFinite(n) && n > 0 ? n : 60) * 1000;
|
|
428
|
+
})();
|
|
272
429
|
const WATERMARK_DIR =
|
|
273
430
|
process.env.MEKO_WATERMARK_DIR ||
|
|
274
431
|
path.join(os.homedir(), ".claude", "meko-capture");
|
|
@@ -395,6 +552,197 @@ function extractToolResultContent(msg) {
|
|
|
395
552
|
return parts.length > 0 ? "TOOL RESULT:\n" + parts.join("\n") : "";
|
|
396
553
|
}
|
|
397
554
|
|
|
555
|
+
// Turn inputs that are Claude Code bookkeeping, not something the user typed.
|
|
556
|
+
// A turn whose rendered input matches is never captured.
|
|
557
|
+
const SKIP_INPUT_PATTERN =
|
|
558
|
+
/^<(system-reminder|command-|local-command|available-deferred)/;
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Render a Claude Code skill or prompt command entry as the command the user
|
|
562
|
+
* typed. Claude Code writes `/commit fix typo` as
|
|
563
|
+
* `<command-message>commit</command-message><command-name>/commit</command-name>`
|
|
564
|
+
* plus an optional `<command-args>fix typo</command-args>`, and writes the
|
|
565
|
+
* skill or command body as the `isMeta` entry that follows. Built-in commands
|
|
566
|
+
* (`/clear`, `/model`, `/compact`, …) lead with `<command-name>` instead and
|
|
567
|
+
* have no body; they return "" here, so they never pull a following `isMeta`
|
|
568
|
+
* entry into their turn and always keep their raw, filtered input.
|
|
569
|
+
*/
|
|
570
|
+
function skillCommandInput(text) {
|
|
571
|
+
if (typeof text !== "string" || !/^<command-message>/.test(text)) {
|
|
572
|
+
return "";
|
|
573
|
+
}
|
|
574
|
+
const name = /<command-name>([\s\S]*?)<\/command-name>/.exec(text);
|
|
575
|
+
if (!name) return "";
|
|
576
|
+
const args = /<command-args>([\s\S]*?)<\/command-args>/.exec(text);
|
|
577
|
+
return [name[1].trim(), args ? args[1].trim() : ""].filter(Boolean).join(" ");
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Is this a Claude Code background-task or subagent notification? Claude Code
|
|
582
|
+
* writes these as plain (non-isMeta) `type: "user"` entries whose content is a
|
|
583
|
+
* `<task-notification>…<summary>…</summary>…</task-notification>` block, and
|
|
584
|
+
* newer versions also tag them `origin: { kind: "task-notification" }`.
|
|
585
|
+
*/
|
|
586
|
+
function isTaskNotification(entry) {
|
|
587
|
+
if (entry.type !== "user" || isToolResultMessage(entry)) return false;
|
|
588
|
+
if (entry.origin && entry.origin.kind === "task-notification") return true;
|
|
589
|
+
return /^\s*<task-notification>/.test(
|
|
590
|
+
extractText((entry.message || {}).content),
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Render a task notification that has to open its own turn as a one-line
|
|
596
|
+
* input. The full block (which can carry a subagent's whole report in
|
|
597
|
+
* `<result>`) goes to reasoning instead, so it is never read as user input.
|
|
598
|
+
*/
|
|
599
|
+
function taskNotificationInput(text) {
|
|
600
|
+
const summary = /<summary>([\s\S]*?)<\/summary>/.exec(text || "");
|
|
601
|
+
return `Task notification: ${summary ? summary[1].trim() : "background task update"}`;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Is this a subagent's hand-back report? Claude Code delivers it as an
|
|
606
|
+
* `isMeta` user entry tagged `origin: { kind: "peer", handback: true }`, with
|
|
607
|
+
* text starting "Another Claude session sent a message: <agent-message …>".
|
|
608
|
+
* Like a task notification, it is not user input and belongs to the open
|
|
609
|
+
* turn. Other peer messages (a request from an independent session, with no
|
|
610
|
+
* `handback`) are real input and still open their own turn.
|
|
611
|
+
*/
|
|
612
|
+
function isSubagentHandback(entry) {
|
|
613
|
+
if (entry.type !== "user" || isToolResultMessage(entry)) return false;
|
|
614
|
+
const origin = entry.origin;
|
|
615
|
+
return !!origin && origin.kind === "peer" && origin.handback === true;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** Render a hand-back that has to open its own turn as a one-line input. */
|
|
619
|
+
function handbackInput(entry) {
|
|
620
|
+
const origin = entry.origin || {};
|
|
621
|
+
const name = origin.name || origin.from || "";
|
|
622
|
+
return name ? `Agent message: ${name}` : "Agent message";
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const SKILL_BODY_PATTERN = /^Base directory for this skill: *(\S+)/;
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Is this an `isMeta` entry Claude Code injected while loading a skill (the
|
|
629
|
+
* skill body, or a notice such as "Skill /commit was loaded earlier")? The
|
|
630
|
+
* model-invoked shape carries `sourceToolUseID`; a body from a typed `/skill`
|
|
631
|
+
* is recognized by its "Base directory for this skill:" first line.
|
|
632
|
+
*/
|
|
633
|
+
function isSkillContext(entry) {
|
|
634
|
+
if (entry.type !== "user" || entry.isMeta !== true) return false;
|
|
635
|
+
if (isToolResultMessage(entry)) return false;
|
|
636
|
+
if (entry.sourceToolUseID) return true;
|
|
637
|
+
return SKILL_BODY_PATTERN.test(extractText((entry.message || {}).content));
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** Context injected into a turn rather than typed by the user. */
|
|
641
|
+
function isInjectedContext(entry) {
|
|
642
|
+
return isTaskNotification(entry) || isSubagentHandback(entry) || isSkillContext(entry);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Render skill context that has to open its own turn (see isTurnStart) as a
|
|
647
|
+
* one-line input naming the skill. The body goes to reasoning instead.
|
|
648
|
+
*/
|
|
649
|
+
function skillContextInput(text) {
|
|
650
|
+
const dir = SKILL_BODY_PATTERN.exec(text || "");
|
|
651
|
+
const name = dir ? path.basename(dir[1].replace(/\/+$/, "")) : "";
|
|
652
|
+
return name ? `Skill loaded: ${name}` : "Skill context loaded";
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* The rendered input for a turn. Skill-loading commands are unwrapped, and
|
|
657
|
+
* a turn opened by injected context gets a one-line summary so that context
|
|
658
|
+
* is never sent as user input.
|
|
659
|
+
*/
|
|
660
|
+
function turnInput(turn) {
|
|
661
|
+
const text = extractText((turn.userMsg.message || {}).content);
|
|
662
|
+
if (isTaskNotification(turn.userMsg)) return taskNotificationInput(text);
|
|
663
|
+
if (isSubagentHandback(turn.userMsg)) return handbackInput(turn.userMsg);
|
|
664
|
+
if (isSkillContext(turn.userMsg)) return skillContextInput(text);
|
|
665
|
+
// Only a skill command that actually got its body is shown as `/name args`.
|
|
666
|
+
return turn.injectedEntries.some((e) => e.isMeta === true && !isSubagentHandback(e))
|
|
667
|
+
? skillCommandInput(text) || text
|
|
668
|
+
: text;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/** Will a turn with this rendered input be captured? */
|
|
672
|
+
function isCapturableInput(input) {
|
|
673
|
+
return !!input && !SKIP_INPUT_PATTERN.test(input);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Does this user entry open a new turn? `prev` is the parsed entry just
|
|
678
|
+
* before it, or null. `insideInjectedRun` is true while consecutive injected
|
|
679
|
+
* `isMeta` entries continue after a skill-loading command or tool call.
|
|
680
|
+
* `openTurn` is the `{ userMsg, injectedEntries }` of the turn an earlier entry
|
|
681
|
+
* in this parse window opened, or null.
|
|
682
|
+
*
|
|
683
|
+
* Claude Code injects skill bodies ("Base directory for this skill: …") as
|
|
684
|
+
* `type: "user"` entries with `isMeta: true`. They belong to the turn that
|
|
685
|
+
* loaded the skill, not to a new one:
|
|
686
|
+
* - the model called the Skill tool → the entry carries `sourceToolUseID`;
|
|
687
|
+
* - the user typed `/skill` → the entry follows the `<command-message>`
|
|
688
|
+
* entry (see skillCommandInput).
|
|
689
|
+
* Other `isMeta` user entries (scheduled and /loop prompts, image captions,
|
|
690
|
+
* requests from other sessions) still open a turn.
|
|
691
|
+
*
|
|
692
|
+
* Task notifications (see isTaskNotification) and subagent hand-backs (see
|
|
693
|
+
* isSubagentHandback) are not user input either, so they also stay in the
|
|
694
|
+
* open turn and the assistant's reaction joins it — unless that turn is never
|
|
695
|
+
* captured (a `/compact` or `<local-command-…>` turn), which would drop the
|
|
696
|
+
* reaction with it.
|
|
697
|
+
*/
|
|
698
|
+
function isTurnStart(entry, { prev = null, insideInjectedRun = false, openTurn = null } = {}) {
|
|
699
|
+
if (entry.type !== "user" || isToolResultMessage(entry)) return false;
|
|
700
|
+
// Nothing to fold into: the turn this entry belongs to was captured by an
|
|
701
|
+
// earlier drain (a notification arriving after the checkpoint), or a cursor
|
|
702
|
+
// persisted by an older capture version points straight at an injected
|
|
703
|
+
// entry. Anchor a turn here so the assistant reply that follows is kept.
|
|
704
|
+
if (!openTurn) return true;
|
|
705
|
+
if (isTaskNotification(entry) || isSubagentHandback(entry)) {
|
|
706
|
+
return !isCapturableInput(turnInput(openTurn));
|
|
707
|
+
}
|
|
708
|
+
if (entry.isMeta !== true) return true;
|
|
709
|
+
if (entry.sourceToolUseID) return false;
|
|
710
|
+
if (insideInjectedRun) return false;
|
|
711
|
+
const prevIsSkillCommand =
|
|
712
|
+
prev != null &&
|
|
713
|
+
prev.type === "user" &&
|
|
714
|
+
skillCommandInput(extractText((prev.message || {}).content)) !== "";
|
|
715
|
+
return !prevIsSkillCommand;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Stateful wrapper over isTurnStart for a pass over parsed entries, so
|
|
720
|
+
* buildExchanges and realUserTurnLines apply the same turn-start rule.
|
|
721
|
+
* Returns `startsTurn(entry) → boolean`; call it once per entry, in order.
|
|
722
|
+
*/
|
|
723
|
+
function turnStartTracker() {
|
|
724
|
+
let prev = null;
|
|
725
|
+
let insideInjectedRun = false;
|
|
726
|
+
// Mirrors the turn buildExchanges is assembling, so turnInput can render it.
|
|
727
|
+
let openTurn = null;
|
|
728
|
+
return (entry) => {
|
|
729
|
+
const startsTurn = isTurnStart(entry, { prev, insideInjectedRun, openTurn });
|
|
730
|
+
const isUserText = entry.type === "user" && !isToolResultMessage(entry);
|
|
731
|
+
// A hand-back is isMeta too, but it never continues a skill's run.
|
|
732
|
+
const isMetaUser =
|
|
733
|
+
isUserText && entry.isMeta === true && !isSubagentHandback(entry);
|
|
734
|
+
insideInjectedRun =
|
|
735
|
+
isMetaUser && (entry.sourceToolUseID != null || !startsTurn);
|
|
736
|
+
if (startsTurn) {
|
|
737
|
+
openTurn = { userMsg: entry, injectedEntries: [] };
|
|
738
|
+
} else if (openTurn && isUserText) {
|
|
739
|
+
openTurn.injectedEntries.push(entry);
|
|
740
|
+
}
|
|
741
|
+
prev = entry;
|
|
742
|
+
return startsTurn;
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
|
|
398
746
|
// --- Turn-assembly exchange extraction ---
|
|
399
747
|
|
|
400
748
|
// Codex turn-completion boundary discriminator.
|
|
@@ -524,13 +872,63 @@ function parseTranscriptEntries(filePath, startLine) {
|
|
|
524
872
|
return entries;
|
|
525
873
|
}
|
|
526
874
|
|
|
875
|
+
/**
|
|
876
|
+
* Render a turn's assistant side: assistant text becomes `output`; thinking,
|
|
877
|
+
* tool calls, tool results and injected entries become `reasoning`.
|
|
878
|
+
*/
|
|
879
|
+
function renderTurnBody(ex) {
|
|
880
|
+
const outputParts = [];
|
|
881
|
+
const reasoningParts = [];
|
|
882
|
+
|
|
883
|
+
for (const aEntry of ex.assistantEntries) {
|
|
884
|
+
const content = (aEntry.message || {}).content;
|
|
885
|
+
if (!Array.isArray(content)) continue;
|
|
886
|
+
|
|
887
|
+
const text = extractText(content);
|
|
888
|
+
if (text) outputParts.push(text);
|
|
889
|
+
|
|
890
|
+
const thinking = extractThinking(content);
|
|
891
|
+
if (thinking) reasoningParts.push("THINKING:\n" + thinking);
|
|
892
|
+
|
|
893
|
+
const toolCalls = extractToolCalls(content);
|
|
894
|
+
if (toolCalls) reasoningParts.push(toolCalls);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
for (const trEntry of ex.toolResultEntries) {
|
|
898
|
+
const resultText = extractToolResultContent(trEntry);
|
|
899
|
+
if (resultText) reasoningParts.push(resultText);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
const injected = isInjectedContext(ex.userMsg)
|
|
903
|
+
? [ex.userMsg, ...ex.injectedEntries]
|
|
904
|
+
: ex.injectedEntries;
|
|
905
|
+
for (const injEntry of injected) {
|
|
906
|
+
const text = extractText((injEntry.message || {}).content);
|
|
907
|
+
if (text) {
|
|
908
|
+
const label = isTaskNotification(injEntry)
|
|
909
|
+
? "TASK NOTIFICATION:\n"
|
|
910
|
+
: isSubagentHandback(injEntry)
|
|
911
|
+
? "AGENT MESSAGE:\n"
|
|
912
|
+
: "INJECTED CONTEXT:\n";
|
|
913
|
+
reasoningParts.push(label + truncate(text, MAX_TOOL_RESULT_LEN));
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
return {
|
|
918
|
+
output: outputParts.join("\n\n"),
|
|
919
|
+
reasoning: reasoningParts.join("\n---\n"),
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
|
|
527
923
|
/**
|
|
528
924
|
* Group parsed entries into exchanges using turn-assembly, then render each
|
|
529
925
|
* exchange to the { input, output, reasoning } shape addMessage sends.
|
|
530
926
|
*
|
|
531
|
-
* A real user message (no toolUseResult) starts a new exchange.
|
|
532
|
-
*
|
|
533
|
-
*
|
|
927
|
+
* A real user message (no toolUseResult) starts a new exchange. Skill bodies
|
|
928
|
+
* and related injected `isMeta` entries stay in the turn that loaded them, and
|
|
929
|
+
* task notifications stay in the open turn (see isTurnStart). Other user
|
|
930
|
+
* messages start the next exchange. Assistant lines and tool-result user lines
|
|
931
|
+
* belong to the current exchange.
|
|
534
932
|
*
|
|
535
933
|
* `items` is the `{ entry, line }` list from parseTranscriptEntries. Returns
|
|
536
934
|
* `{ exchanges, pendingFromLine }`:
|
|
@@ -549,19 +947,25 @@ function parseTranscriptEntries(filePath, startLine) {
|
|
|
549
947
|
* complete trailing turn, which advances fully.
|
|
550
948
|
*/
|
|
551
949
|
function buildExchanges(items) {
|
|
552
|
-
const skipPattern =
|
|
553
|
-
/^<(system-reminder|command-|local-command|available-deferred)/;
|
|
554
950
|
const turns = [];
|
|
555
951
|
let current = null;
|
|
556
952
|
|
|
953
|
+
const startsTurn = turnStartTracker();
|
|
557
954
|
for (const { entry, line } of items) {
|
|
558
|
-
if (
|
|
955
|
+
if (startsTurn(entry)) {
|
|
559
956
|
if (current) turns.push(current);
|
|
560
957
|
current = {
|
|
561
958
|
userMsg: entry,
|
|
562
959
|
userLine: line,
|
|
563
960
|
assistantEntries: [],
|
|
564
961
|
toolResultEntries: [],
|
|
962
|
+
// Skill bodies, other isMeta text and task notifications Claude Code
|
|
963
|
+
// writes as user entries inside this turn (see isTurnStart).
|
|
964
|
+
injectedEntries: [],
|
|
965
|
+
// A turn with injected context is incomplete until an assistant entry
|
|
966
|
+
// arrives after its latest injection. Assistant entries before that
|
|
967
|
+
// injection do not make the turn safe for a checkpoint to consume.
|
|
968
|
+
assistantAfterInjection: false,
|
|
565
969
|
// Codex streaming turns end at a completion boundary (phase:
|
|
566
970
|
// final_answer, or a task_complete event_msg). `codexStreaming` marks a
|
|
567
971
|
// turn that emitted commentary and is therefore mid-stream until a
|
|
@@ -574,6 +978,9 @@ function buildExchanges(items) {
|
|
|
574
978
|
} else if (current) {
|
|
575
979
|
if (entry.type === "assistant") {
|
|
576
980
|
current.assistantEntries.push(entry);
|
|
981
|
+
if (current.injectedEntries.length > 0) {
|
|
982
|
+
current.assistantAfterInjection = true;
|
|
983
|
+
}
|
|
577
984
|
if (entry.codexPhase === CODEX_FINAL_ANSWER_PHASE) {
|
|
578
985
|
current.codexComplete = true;
|
|
579
986
|
} else if (entry.codexPhase === CODEX_COMMENTARY_PHASE) {
|
|
@@ -583,6 +990,11 @@ function buildExchanges(items) {
|
|
|
583
990
|
current.codexComplete = true;
|
|
584
991
|
} else if (entry.type === "user" && isToolResultMessage(entry)) {
|
|
585
992
|
current.toolResultEntries.push(entry);
|
|
993
|
+
} else if (entry.type === "user") {
|
|
994
|
+
current.injectedEntries.push(entry);
|
|
995
|
+
// Each injection needs its own reply: a second skill body or
|
|
996
|
+
// notification re-opens the hold until an assistant entry follows it.
|
|
997
|
+
current.assistantAfterInjection = false;
|
|
586
998
|
}
|
|
587
999
|
}
|
|
588
1000
|
}
|
|
@@ -602,11 +1014,12 @@ function buildExchanges(items) {
|
|
|
602
1014
|
const last = turns.length > 0 ? turns[turns.length - 1] : null;
|
|
603
1015
|
const lastIncomplete = last
|
|
604
1016
|
? last.assistantEntries.length === 0 ||
|
|
1017
|
+
(last.injectedEntries.length > 0 && !last.assistantAfterInjection) ||
|
|
605
1018
|
(last.codexStreaming && !last.codexComplete)
|
|
606
1019
|
: false;
|
|
607
1020
|
if (last && lastIncomplete) {
|
|
608
|
-
const lastInput =
|
|
609
|
-
if (
|
|
1021
|
+
const lastInput = turnInput(last);
|
|
1022
|
+
if (isCapturableInput(lastInput)) {
|
|
610
1023
|
pendingFromLine = last.userLine;
|
|
611
1024
|
}
|
|
612
1025
|
}
|
|
@@ -622,31 +1035,11 @@ function buildExchanges(items) {
|
|
|
622
1035
|
pendingFromLine !== null && t === turns.length - 1;
|
|
623
1036
|
if (isPendingTrailing) continue;
|
|
624
1037
|
|
|
625
|
-
const input =
|
|
626
|
-
if (!
|
|
1038
|
+
const input = turnInput(ex);
|
|
1039
|
+
if (!isCapturableInput(input)) continue;
|
|
627
1040
|
if (ex.assistantEntries.length === 0) continue;
|
|
628
1041
|
|
|
629
|
-
const
|
|
630
|
-
const reasoningParts = [];
|
|
631
|
-
|
|
632
|
-
for (const aEntry of ex.assistantEntries) {
|
|
633
|
-
const content = (aEntry.message || {}).content;
|
|
634
|
-
if (!Array.isArray(content)) continue;
|
|
635
|
-
|
|
636
|
-
const text = extractText(content);
|
|
637
|
-
if (text) outputParts.push(text);
|
|
638
|
-
|
|
639
|
-
const thinking = extractThinking(content);
|
|
640
|
-
if (thinking) reasoningParts.push("THINKING:\n" + thinking);
|
|
641
|
-
|
|
642
|
-
const toolCalls = extractToolCalls(content);
|
|
643
|
-
if (toolCalls) reasoningParts.push(toolCalls);
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
for (const trEntry of ex.toolResultEntries) {
|
|
647
|
-
const resultText = extractToolResultContent(trEntry);
|
|
648
|
-
if (resultText) reasoningParts.push(resultText);
|
|
649
|
-
}
|
|
1042
|
+
const { output, reasoning } = renderTurnBody(ex);
|
|
650
1043
|
|
|
651
1044
|
results.push({
|
|
652
1045
|
user_uuid: ex.userMsg.uuid || "unknown",
|
|
@@ -658,8 +1051,8 @@ function buildExchanges(items) {
|
|
|
658
1051
|
next_line_number:
|
|
659
1052
|
t + 1 < turns.length ? turns[t + 1].userLine : null,
|
|
660
1053
|
input: input,
|
|
661
|
-
output
|
|
662
|
-
reasoning
|
|
1054
|
+
output,
|
|
1055
|
+
reasoning,
|
|
663
1056
|
timestamp:
|
|
664
1057
|
ex.assistantEntries[ex.assistantEntries.length - 1].timestamp ||
|
|
665
1058
|
ex.userMsg.timestamp ||
|
|
@@ -767,6 +1160,7 @@ function writeWatermark(wmPath, convId, lineNum, agentId, datapackPin = null, ag
|
|
|
767
1160
|
last_error: null,
|
|
768
1161
|
last_success_at: null,
|
|
769
1162
|
blocked_reason: null,
|
|
1163
|
+
consecutive_exchange_drops: 0,
|
|
770
1164
|
};
|
|
771
1165
|
|
|
772
1166
|
const existingPin =
|
|
@@ -828,10 +1222,9 @@ function writeWatermark(wmPath, convId, lineNum, agentId, datapackPin = null, ag
|
|
|
828
1222
|
/** Real user-turn line indexes from a parseTranscriptEntries result (PR #262). */
|
|
829
1223
|
function realUserTurnLines(entries) {
|
|
830
1224
|
const lines = [];
|
|
1225
|
+
const startsTurn = turnStartTracker();
|
|
831
1226
|
for (const { entry, line } of entries) {
|
|
832
|
-
if (
|
|
833
|
-
lines.push(line);
|
|
834
|
-
}
|
|
1227
|
+
if (startsTurn(entry)) lines.push(line);
|
|
835
1228
|
}
|
|
836
1229
|
return lines;
|
|
837
1230
|
}
|
|
@@ -862,6 +1255,7 @@ function makeDrainResult(partial) {
|
|
|
862
1255
|
lifecycle: partial.lifecycle || "active",
|
|
863
1256
|
delivery: partial.delivery || "idle",
|
|
864
1257
|
next_retry_at: partial.next_retry_at || null,
|
|
1258
|
+
dropped: partial.dropped || 0,
|
|
865
1259
|
};
|
|
866
1260
|
}
|
|
867
1261
|
|
|
@@ -883,27 +1277,38 @@ function datapackPinSlug(agentId) {
|
|
|
883
1277
|
* The pin is a sidecar file at `<WATERMARK_DIR>/pin-<slug(agent_id)>.json`
|
|
884
1278
|
* with shape `{ datapack_id, datapack_name, selected_at }`. Project-scoped:
|
|
885
1279
|
* keyed by the same `agent_id` the hook derives for memory writes
|
|
886
|
-
* (`claude_code:<repo-basename
|
|
887
|
-
*
|
|
888
|
-
*
|
|
1280
|
+
* (`claude_code:<repo-basename>`, where the basename is the nearest
|
|
1281
|
+
* ancestor of the session cwd containing `.git`), so a pin set in one
|
|
1282
|
+
* Claude Code window for a repo applies to every other window in that
|
|
1283
|
+
* repo, and survives `/clear`, `/compact`, and Claude Code restart.
|
|
889
1284
|
*
|
|
890
|
-
* Hooks are read-only — only the skill writes this file.
|
|
891
|
-
*
|
|
892
|
-
*
|
|
1285
|
+
* Hooks are read-only — only the skill writes this file. The legacy id is
|
|
1286
|
+
* checked only when the repo-root id has no valid pin, preserving pins written
|
|
1287
|
+
* before MEKO-590 without preventing a newly written repo-root pin from
|
|
1288
|
+
* taking precedence.
|
|
893
1289
|
*/
|
|
894
|
-
function readDatapackPin(agentId) {
|
|
895
|
-
const
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
const
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
1290
|
+
function readDatapackPin(agentId, legacyAgentId = null) {
|
|
1291
|
+
const candidates = [...new Set([agentId, legacyAgentId].filter(Boolean))];
|
|
1292
|
+
for (const candidate of candidates) {
|
|
1293
|
+
const slug = datapackPinSlug(candidate);
|
|
1294
|
+
if (!slug) continue;
|
|
1295
|
+
const pinPath = path.join(WATERMARK_DIR, `pin-${slug}.json`);
|
|
1296
|
+
try {
|
|
1297
|
+
const obj = JSON.parse(fs.readFileSync(pinPath, "utf-8"));
|
|
1298
|
+
const id = typeof obj.datapack_id === "string" ? obj.datapack_id.trim() : "";
|
|
1299
|
+
const name = typeof obj.datapack_name === "string" ? obj.datapack_name.trim() : "";
|
|
1300
|
+
if (!id || !name) continue;
|
|
1301
|
+
if (candidate !== agentId) {
|
|
1302
|
+
process.stderr.write(
|
|
1303
|
+
`[meko-capture] datapack pin: using legacy agent_id alias ${candidate}\n`,
|
|
1304
|
+
);
|
|
1305
|
+
}
|
|
1306
|
+
return { datapack_id: id, datapack_name: name, selected_at: obj.selected_at || "" };
|
|
1307
|
+
} catch {
|
|
1308
|
+
// Try the compatibility alias, if any.
|
|
1309
|
+
}
|
|
906
1310
|
}
|
|
1311
|
+
return null;
|
|
907
1312
|
}
|
|
908
1313
|
|
|
909
1314
|
function datapackPinFromWatermark(watermark) {
|
|
@@ -942,9 +1347,14 @@ The user pinned datapack **${pin.datapack_name}** (\`${pin.datapack_id}\`) for t
|
|
|
942
1347
|
* Low-level HTTP POST to the MCP endpoint.
|
|
943
1348
|
* Returns { body: <parsed JSON>, headers: <response headers> }.
|
|
944
1349
|
*/
|
|
945
|
-
function mcpPost(jsonBody) {
|
|
1350
|
+
function mcpPost(jsonBody, options) {
|
|
946
1351
|
const body = JSON.stringify(jsonBody);
|
|
1352
|
+
const timeoutMs =
|
|
1353
|
+
options && Number(options.timeoutMs) > 0
|
|
1354
|
+
? Number(options.timeoutMs)
|
|
1355
|
+
: MEKO_API_TIMEOUT;
|
|
947
1356
|
return new Promise((resolve, reject) => {
|
|
1357
|
+
let deadline = null;
|
|
948
1358
|
const url = new URL(MEKO_MCP_URL);
|
|
949
1359
|
const transport = url.protocol === "https:" ? https : http;
|
|
950
1360
|
const headers = {
|
|
@@ -973,12 +1383,13 @@ function mcpPost(jsonBody) {
|
|
|
973
1383
|
path: url.pathname + url.search,
|
|
974
1384
|
method: "POST",
|
|
975
1385
|
headers,
|
|
976
|
-
timeout:
|
|
1386
|
+
timeout: timeoutMs,
|
|
977
1387
|
},
|
|
978
1388
|
(res) => {
|
|
979
1389
|
let data = "";
|
|
980
1390
|
res.on("data", (chunk) => (data += chunk));
|
|
981
1391
|
res.on("end", () => {
|
|
1392
|
+
clearTimeout(deadline);
|
|
982
1393
|
inflightRequests.delete(req);
|
|
983
1394
|
if (res.statusCode >= 400) {
|
|
984
1395
|
const snippet = data.trim().replace(/\s+/g, " ").slice(0, 200);
|
|
@@ -1010,15 +1421,25 @@ function mcpPost(jsonBody) {
|
|
|
1010
1421
|
);
|
|
1011
1422
|
|
|
1012
1423
|
inflightRequests.add(req);
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
reject(err);
|
|
1016
|
-
});
|
|
1017
|
-
req.on("timeout", () => {
|
|
1424
|
+
const timedOut = () => {
|
|
1425
|
+
clearTimeout(deadline);
|
|
1018
1426
|
inflightRequests.delete(req);
|
|
1019
1427
|
req.destroy();
|
|
1020
1428
|
reject(new Error("MCP request timed out"));
|
|
1429
|
+
};
|
|
1430
|
+
// The socket timeout only measures idle time, and SSE pings reset it, so
|
|
1431
|
+
// also cap the whole request. unref() and the close listener keep an
|
|
1432
|
+
// aborted request (SessionStart's preload budget) from holding the
|
|
1433
|
+
// process open until the deadline.
|
|
1434
|
+
deadline = setTimeout(timedOut, timeoutMs);
|
|
1435
|
+
deadline.unref();
|
|
1436
|
+
req.on("close", () => clearTimeout(deadline));
|
|
1437
|
+
req.on("error", (err) => {
|
|
1438
|
+
clearTimeout(deadline);
|
|
1439
|
+
inflightRequests.delete(req);
|
|
1440
|
+
reject(err);
|
|
1021
1441
|
});
|
|
1442
|
+
req.on("timeout", timedOut);
|
|
1022
1443
|
req.write(body);
|
|
1023
1444
|
req.end();
|
|
1024
1445
|
});
|
|
@@ -1028,7 +1449,10 @@ function parseMcpResponseBody(data) {
|
|
|
1028
1449
|
const trimmed = data.trim();
|
|
1029
1450
|
if (!trimmed) return null;
|
|
1030
1451
|
|
|
1031
|
-
|
|
1452
|
+
// An SSE stream may open with a comment line such as the server's
|
|
1453
|
+
// keepalive `: ping - <timestamp>`, sent every 15s while a slow tool call
|
|
1454
|
+
// is still running.
|
|
1455
|
+
if (!/^(event|data|id|retry)?:/.test(trimmed)) {
|
|
1032
1456
|
return JSON.parse(trimmed);
|
|
1033
1457
|
}
|
|
1034
1458
|
|
|
@@ -1055,24 +1479,78 @@ function parseMcpResponseBody(data) {
|
|
|
1055
1479
|
return JSON.parse(payload);
|
|
1056
1480
|
}
|
|
1057
1481
|
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
} else if (statusMatch) {
|
|
1067
|
-
persistentStatus = Number(statusMatch[1]);
|
|
1068
|
-
}
|
|
1482
|
+
/**
|
|
1483
|
+
* Collapse a response body to a single-line snippet for error records.
|
|
1484
|
+
* Display/persistence stays short; pattern matching uses the fuller body.
|
|
1485
|
+
*/
|
|
1486
|
+
function errorBodySnippet(err) {
|
|
1487
|
+
const body = err && err.responseBody ? String(err.responseBody) : "";
|
|
1488
|
+
return body.trim().replace(/\s+/g, " ").slice(0, 200);
|
|
1489
|
+
}
|
|
1069
1490
|
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1491
|
+
/**
|
|
1492
|
+
* Full collapsed response body for classifier pattern matching.
|
|
1493
|
+
* mcpPost keeps up to 1000 chars on err.responseBody; the message snippet
|
|
1494
|
+
* is only 200, so reasons past that window are only visible here.
|
|
1495
|
+
*/
|
|
1496
|
+
function errorBodyForMatch(err) {
|
|
1497
|
+
const body = err && err.responseBody ? String(err.responseBody) : "";
|
|
1498
|
+
return body.trim().replace(/\s+/g, " ");
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
function isHtmlErrorDetail(detail) {
|
|
1502
|
+
return /^\s*<|<html|<!doctype/i.test(String(detail || ""));
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
function persistentFailureText(persistent, fallback) {
|
|
1506
|
+
if (!persistent || !persistent.reason) return fallback;
|
|
1507
|
+
const detail = persistent.detail ? String(persistent.detail) : "";
|
|
1508
|
+
if (
|
|
1509
|
+
!detail ||
|
|
1510
|
+
isHtmlErrorDetail(detail) ||
|
|
1511
|
+
String(persistent.reason).includes(detail)
|
|
1512
|
+
) {
|
|
1513
|
+
return persistent.reason;
|
|
1075
1514
|
}
|
|
1515
|
+
return `${persistent.reason}: ${detail}`;
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
/**
|
|
1519
|
+
* Classify a capture failure that will not clear on retry.
|
|
1520
|
+
*
|
|
1521
|
+
* Returns null for transient failures. Otherwise:
|
|
1522
|
+
* { code, reason, scope, detail }
|
|
1523
|
+
*
|
|
1524
|
+
* `scope` says WHAT is rejected, which is what the caller needs to decide
|
|
1525
|
+
* whether retrying can ever succeed:
|
|
1526
|
+
* - "account" the account/plan is refusing all writes (quota, entitlement,
|
|
1527
|
+
* 402). Every exchange would fail identically, so the caller
|
|
1528
|
+
* holds the queue: the block clears when billing does, and
|
|
1529
|
+
* dropping would lose the whole session.
|
|
1530
|
+
* - "exchange" the response looks like an edge proxy rejecting THIS
|
|
1531
|
+
* request's body (HTTP 403 with an HTML body — see #13). The
|
|
1532
|
+
* caller may skip one such exchange so later turns still
|
|
1533
|
+
* drain; a second consecutive exchange-scoped failure without
|
|
1534
|
+
* an intervening success is treated as request-wide and held,
|
|
1535
|
+
* because HTML 403 alone does not prove payload-specificity
|
|
1536
|
+
* (missing UA, IP/ASN, rate-limit pages share the shape).
|
|
1537
|
+
*
|
|
1538
|
+
* Body patterns are tested BEFORE the status code. An account-level rejection
|
|
1539
|
+
* that arrives as 402/403 (the common case) would otherwise collapse into an
|
|
1540
|
+
* opaque `http_403` and send the user off regenerating a token that was never
|
|
1541
|
+
* at fault. `detail` is a short body snippet; `reason` is what callers persist
|
|
1542
|
+
* into `last_error` / notices so the real cause survives.
|
|
1543
|
+
* Pass `{ exchange: true }` only from the addMessage path. Initialize and
|
|
1544
|
+
* conversation_create HTML 403s are request-wide, so they stay account-scoped.
|
|
1545
|
+
*/
|
|
1546
|
+
function classifyPersistentCaptureFailure(err, opts) {
|
|
1547
|
+
const options = opts && typeof opts === "object" ? opts : {};
|
|
1548
|
+
const forExchange = options.exchange === true;
|
|
1549
|
+
const message = err && err.message ? String(err.message) : String(err || "");
|
|
1550
|
+
const detail = errorBodySnippet(err);
|
|
1551
|
+
// Search the fuller responseBody (up to mcpPost's 1000-char keep) so a
|
|
1552
|
+
// reason that fell outside the 200-char message snippet is still matched.
|
|
1553
|
+
const normalized = `${message} ${errorBodyForMatch(err)}`.toLowerCase();
|
|
1076
1554
|
|
|
1077
1555
|
const persistentPatterns = [
|
|
1078
1556
|
["free_tier_limit_reached", /free[_\s-]?tier.*limit|free_tier_limit_reached/],
|
|
@@ -1082,6 +1560,16 @@ function classifyPersistentCaptureFailure(err) {
|
|
|
1082
1560
|
["payment_required", /payment[_\s-]?required/],
|
|
1083
1561
|
["subscription_required", /subscription[_\s-]?(required|inactive|expired)/],
|
|
1084
1562
|
["entitlement_denied", /entitlement.*(denied|required|missing)|(?:denied|required|missing).*entitlement/],
|
|
1563
|
+
// MEKO-656/MEKO-691: _resolve_datapack_id now returns this code (instead
|
|
1564
|
+
// of raising) when the caller has no ownership/share grant on the
|
|
1565
|
+
// datapack_id it supplied. Account-scoped like entitlement_denied above —
|
|
1566
|
+
// every write against that same datapack_id would fail identically until
|
|
1567
|
+
// access is (re-)granted out of band, so the caller should hold rather
|
|
1568
|
+
// than retry forever or silently drop the turn.
|
|
1569
|
+
["datapack_access_denied", /datapack_access_denied/],
|
|
1570
|
+
// MEKO-876: a viewer of a shared datapack can't record conversations.
|
|
1571
|
+
// Account-scoped like the above: it stays refused until the role changes.
|
|
1572
|
+
["read_only_access", /read_only_access/],
|
|
1085
1573
|
];
|
|
1086
1574
|
for (const [code, pattern] of persistentPatterns) {
|
|
1087
1575
|
if (pattern.test(normalized)) {
|
|
@@ -1093,13 +1581,309 @@ function classifyPersistentCaptureFailure(err) {
|
|
|
1093
1581
|
payment_required: "capture requires payment",
|
|
1094
1582
|
subscription_required: "capture requires an active subscription",
|
|
1095
1583
|
entitlement_denied: "the account is not entitled to capture conversations",
|
|
1584
|
+
datapack_access_denied: "the account does not have access to the target datapack",
|
|
1585
|
+
read_only_access: "the account has viewer (read-only) access to the target datapack",
|
|
1096
1586
|
};
|
|
1097
|
-
return { code, reason: reasons[code] };
|
|
1587
|
+
return { code, reason: reasons[code], scope: "account", detail };
|
|
1098
1588
|
}
|
|
1099
1589
|
}
|
|
1590
|
+
|
|
1591
|
+
const statusCode = Number(err && err.statusCode);
|
|
1592
|
+
const statusMatch = normalized.match(/\bmcp http (402|403)\b/);
|
|
1593
|
+
let persistentStatus = null;
|
|
1594
|
+
if (statusCode === 402 || statusCode === 403) {
|
|
1595
|
+
persistentStatus = statusCode;
|
|
1596
|
+
} else if (statusMatch) {
|
|
1597
|
+
persistentStatus = Number(statusMatch[1]);
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
if (persistentStatus === 402) {
|
|
1601
|
+
return {
|
|
1602
|
+
code: "http_402",
|
|
1603
|
+
reason: "Meko rejected capture with HTTP 402 (payment required)",
|
|
1604
|
+
scope: "account",
|
|
1605
|
+
detail,
|
|
1606
|
+
};
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
if (persistentStatus === 403) {
|
|
1610
|
+
// A 403 whose body is an HTML error page never came from the MCP service —
|
|
1611
|
+
// the service answers with JSON. Auth failures arrive as 401 with a JSON
|
|
1612
|
+
// body, so this is not a credential problem. A JSON 403 with no recognized
|
|
1613
|
+
// pattern stays account-scoped: it may apply to every write.
|
|
1614
|
+
//
|
|
1615
|
+
// HTML alone is only a candidate for exchange-scope. The drain loop still
|
|
1616
|
+
// requires an intervening success before a second consecutive drop, because
|
|
1617
|
+
// request-wide edge blocks (rate limit, IP, missing UA historically) share
|
|
1618
|
+
// this shape and would otherwise discard the whole session.
|
|
1619
|
+
const fromEdge = isHtmlErrorDetail(detail);
|
|
1620
|
+
return {
|
|
1621
|
+
code: fromEdge ? "content_rejected_403" : "http_403",
|
|
1622
|
+
// Keep exchange reasons short and free of HTML so SessionStart notices
|
|
1623
|
+
// and drop_reasons do not inject error-page markup into model context.
|
|
1624
|
+
reason: fromEdge
|
|
1625
|
+
? forExchange
|
|
1626
|
+
? "edge filtering rejected the request body (HTTP 403); this exchange's content cannot be stored as-is"
|
|
1627
|
+
: "edge filtering rejected the request (HTTP 403)"
|
|
1628
|
+
: `Meko rejected capture with HTTP 403${detail ? `: ${detail}` : ""}`,
|
|
1629
|
+
scope: fromEdge && forExchange ? "exchange" : "account",
|
|
1630
|
+
detail,
|
|
1631
|
+
};
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1100
1634
|
return null;
|
|
1101
1635
|
}
|
|
1102
1636
|
|
|
1637
|
+
/**
|
|
1638
|
+
* Grace period before a closed session whose transcript has vanished is
|
|
1639
|
+
* finalized. Default 1 day; MEKO_TRANSCRIPT_MISSING_GRACE overrides (seconds).
|
|
1640
|
+
*/
|
|
1641
|
+
function transcriptMissingGraceSeconds() {
|
|
1642
|
+
const raw = process.env.MEKO_TRANSCRIPT_MISSING_GRACE;
|
|
1643
|
+
const n = raw == null || raw === "" ? NaN : Number.parseInt(String(raw), 10);
|
|
1644
|
+
return Number.isFinite(n) && n >= 0 ? n : 86400;
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
function coerceQueued(state) {
|
|
1648
|
+
const n = Number(state && state.queued_exchanges);
|
|
1649
|
+
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
/** Consecutive not_found drains required before opening a replacement. */
|
|
1653
|
+
const MISSING_CONVERSATION_RECOVERY_POLLS = 2;
|
|
1654
|
+
|
|
1655
|
+
function missingConversationStreak(state) {
|
|
1656
|
+
const n = Number(state && state.missing_conversation_streak);
|
|
1657
|
+
return Number.isInteger(n) && n > 0 ? n : 0;
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
/**
|
|
1661
|
+
* Clear failure fields after a drain delivers all outstanding work.
|
|
1662
|
+
*
|
|
1663
|
+
* These fields describe the last attempt. Leaving them set after the queue
|
|
1664
|
+
* drains makes sessionHealthBucket() report a resolved failure as degraded.
|
|
1665
|
+
*/
|
|
1666
|
+
function clearResolvedFailure(state) {
|
|
1667
|
+
state.failure_class = null;
|
|
1668
|
+
state.last_error = null;
|
|
1669
|
+
state.next_retry_at = null;
|
|
1670
|
+
state.attempt_count = 0;
|
|
1671
|
+
state.blocked_reason = null;
|
|
1672
|
+
state.recovered_from_conversation_id = null;
|
|
1673
|
+
state.missing_conversation_streak = null;
|
|
1674
|
+
return state;
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
/**
|
|
1678
|
+
* Detect a conversation the server no longer has.
|
|
1679
|
+
*
|
|
1680
|
+
* `conversation_add_message` answers `not_found` when its conversation id is
|
|
1681
|
+
* gone: deleted server-side, expired, or created against a different Langfuse
|
|
1682
|
+
* project than the one the current datapack resolves to. classifyFailure() has
|
|
1683
|
+
* no pattern for it, so it falls through to the "transient" default and the
|
|
1684
|
+
* outbox retries the same dead id forever: three sessions reached 609, 441 and
|
|
1685
|
+
* 440 attempts holding 105 turns that could never land.
|
|
1686
|
+
*
|
|
1687
|
+
* Callers treat a match as a signal to open a fresh conversation and move the
|
|
1688
|
+
* undelivered tail into it (see recoverMissingConversation).
|
|
1689
|
+
*
|
|
1690
|
+
* The match keys on the server's detail, `Conversation <id> not found`
|
|
1691
|
+
* (src/tools.py). The tool name in the error prefix also contains
|
|
1692
|
+
* "conversation", so matching on the word alone would catch every not_found.
|
|
1693
|
+
* The server sends the same detail when a Langfuse lookup fails transiently or
|
|
1694
|
+
* the trace is too large to read (MEKO-452, MEKO-714), so a match is not proof
|
|
1695
|
+
* the conversation is gone. Recovery waits until the same detail persists
|
|
1696
|
+
* across MISSING_CONVERSATION_RECOVERY_POLLS drains, preserves the cursor, and
|
|
1697
|
+
* never copies turns that the old conversation already accepted.
|
|
1698
|
+
*/
|
|
1699
|
+
function isMissingConversationError(err) {
|
|
1700
|
+
const message = err && err.message ? String(err.message) : String(err || "");
|
|
1701
|
+
const normalized = message.toLowerCase();
|
|
1702
|
+
if (!/not[_\s-]?found/.test(normalized)) return false;
|
|
1703
|
+
return /conversation\s+\S+\s+not\s+found/.test(normalized);
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
/**
|
|
1707
|
+
* Recover from a conversation the server no longer has by opening a new one and
|
|
1708
|
+
* moving the undelivered tail to it.
|
|
1709
|
+
*
|
|
1710
|
+
* The server can also return the same not_found detail for a transient Langfuse
|
|
1711
|
+
* lookup failure. drainSession() calls this only after that detail has persisted
|
|
1712
|
+
* across two drains, and this function keeps last_line_number so turns already
|
|
1713
|
+
* accepted by the old conversation are not copied. conversation_add_message
|
|
1714
|
+
* checks ownership before storing a turn, so the failed exchange and the rest
|
|
1715
|
+
* of the undelivered tail remain safe to move.
|
|
1716
|
+
*
|
|
1717
|
+
* The new conversation is created without `session_id`. The server derives the
|
|
1718
|
+
* id from `session_id`, so passing it again would return the conversation that
|
|
1719
|
+
* just failed, and recovery would keep retrying the dead id.
|
|
1720
|
+
*
|
|
1721
|
+
* On create failure the caller keeps its normal retry path, so a network blip
|
|
1722
|
+
* here costs a retry rather than the queue.
|
|
1723
|
+
*
|
|
1724
|
+
* @returns {Promise<{ok: boolean, state?: object, convId?: string, error?: string}>}
|
|
1725
|
+
*/
|
|
1726
|
+
async function recoverMissingConversation({
|
|
1727
|
+
state,
|
|
1728
|
+
sessionId,
|
|
1729
|
+
agentId,
|
|
1730
|
+
transcriptPath,
|
|
1731
|
+
datapackId = null,
|
|
1732
|
+
}) {
|
|
1733
|
+
let newConvId = null;
|
|
1734
|
+
try {
|
|
1735
|
+
newConvId = await createConversation(
|
|
1736
|
+
sessionId || "unknown",
|
|
1737
|
+
agentId,
|
|
1738
|
+
peekTranscriptMetadata(transcriptPath),
|
|
1739
|
+
datapackId,
|
|
1740
|
+
{ freshId: true },
|
|
1741
|
+
);
|
|
1742
|
+
} catch (err) {
|
|
1743
|
+
return { ok: false, error: `conversation_create failed: ${err.message}` };
|
|
1744
|
+
}
|
|
1745
|
+
if (!newConvId) {
|
|
1746
|
+
return { ok: false, error: "conversation_create returned no ID" };
|
|
1747
|
+
}
|
|
1748
|
+
// A create that returns the failed id did not provide a recovery target.
|
|
1749
|
+
const sameId = (a, b) =>
|
|
1750
|
+
String(a || "").replace(/-/g, "").toLowerCase() ===
|
|
1751
|
+
String(b || "").replace(/-/g, "").toLowerCase();
|
|
1752
|
+
if (sameId(newConvId, state.conversation_id)) {
|
|
1753
|
+
return {
|
|
1754
|
+
ok: false,
|
|
1755
|
+
error: `conversation_create returned the missing conversation ${newConvId}`,
|
|
1756
|
+
};
|
|
1757
|
+
}
|
|
1758
|
+
const next = {
|
|
1759
|
+
...state,
|
|
1760
|
+
conversation_id: newConvId,
|
|
1761
|
+
conversation_epoch: Number(state.conversation_epoch || 0) + 1,
|
|
1762
|
+
in_flight: { seed: null, user_line: null, user_turn_id: null },
|
|
1763
|
+
delivery: "pending",
|
|
1764
|
+
failure_class: null,
|
|
1765
|
+
last_error: null,
|
|
1766
|
+
blocked_reason: null,
|
|
1767
|
+
next_retry_at: null,
|
|
1768
|
+
attempt_count: 0,
|
|
1769
|
+
missing_conversation_streak: null,
|
|
1770
|
+
};
|
|
1771
|
+
// Suppress another replacement until this conversation accepts a turn. A
|
|
1772
|
+
// fresh trace may not be visible yet when the next request reaches a
|
|
1773
|
+
// different server replica.
|
|
1774
|
+
next.recovered_from_conversation_id = state.conversation_id || null;
|
|
1775
|
+
const written = captureState.writeState(sessionId, next);
|
|
1776
|
+
if (!written.ok) {
|
|
1777
|
+
return { ok: false, error: `failed to persist recovered conversation: ${written.error}` };
|
|
1778
|
+
}
|
|
1779
|
+
return { ok: true, state: written.state || next, convId: newConvId };
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
/**
|
|
1783
|
+
* Move a session's unsent turns to a new conversation on request, without
|
|
1784
|
+
* waiting for capture to see `not_found` twice. Used by
|
|
1785
|
+
* `--capture-cleanup fix` when the recorded conversation can't be served:
|
|
1786
|
+
* its owner reads as `None`, its lookup keeps failing, or a replay loop
|
|
1787
|
+
* bloated it. Holds the drain lease for the whole move, keeps the cursor,
|
|
1788
|
+
* and creates the conversation under the session's recorded owner and
|
|
1789
|
+
* datapack.
|
|
1790
|
+
*
|
|
1791
|
+
* The caller can pass `expected_conversation_id`. Under the lease, the move
|
|
1792
|
+
* is refused (`changed: true`) when the session's conversation no longer
|
|
1793
|
+
* matches it, or when a previous move is still waiting for its new
|
|
1794
|
+
* conversation to accept a turn (`recovered_from_conversation_id`): a hook
|
|
1795
|
+
* drain may have recovered or delivered the session while the caller waited.
|
|
1796
|
+
*
|
|
1797
|
+
* @param {{ session_id: string, expected_conversation_id?: string }} options
|
|
1798
|
+
* @returns {Promise<{ok: boolean, busy?: boolean, changed?: boolean, from?: string, to?: string, last_line_number?: number, error?: string}>}
|
|
1799
|
+
*/
|
|
1800
|
+
async function moveSessionToNewConversation(options) {
|
|
1801
|
+
const sessionId = String((options && options.session_id) || "").trim();
|
|
1802
|
+
if (!sessionId) return { ok: false, error: "session_id required" };
|
|
1803
|
+
const lease = captureState.acquireLease(sessionId);
|
|
1804
|
+
if (!lease.ok) {
|
|
1805
|
+
return {
|
|
1806
|
+
ok: false,
|
|
1807
|
+
busy: lease.failure_class === "ownership",
|
|
1808
|
+
error: lease.error || "drain lease unavailable",
|
|
1809
|
+
};
|
|
1810
|
+
}
|
|
1811
|
+
try {
|
|
1812
|
+
const read = captureState.readState(sessionId);
|
|
1813
|
+
if (!read.ok) return { ok: false, error: read.error || "watermark unreadable" };
|
|
1814
|
+
let state = read.state;
|
|
1815
|
+
if (!state.agent_id) return { ok: false, error: "recorded owner missing" };
|
|
1816
|
+
const expected = options && options.expected_conversation_id;
|
|
1817
|
+
// An automatic cleanup run must not end a park the user set with `stop`
|
|
1818
|
+
// after that run scanned the session.
|
|
1819
|
+
if (
|
|
1820
|
+
options &&
|
|
1821
|
+
options.respect_stop &&
|
|
1822
|
+
state.parked &&
|
|
1823
|
+
state.parked.reason === "stop" &&
|
|
1824
|
+
state.failure_class &&
|
|
1825
|
+
state.next_retry_at === captureState.CLEANUP_PARK_UNTIL
|
|
1826
|
+
) {
|
|
1827
|
+
return { ok: false, paused: true, error: "paused by the user with stop" };
|
|
1828
|
+
}
|
|
1829
|
+
if (state.recovered_from_conversation_id) {
|
|
1830
|
+
return {
|
|
1831
|
+
ok: false,
|
|
1832
|
+
changed: true,
|
|
1833
|
+
error: "a previous move is waiting for its new conversation to accept a turn",
|
|
1834
|
+
};
|
|
1835
|
+
}
|
|
1836
|
+
if (expected && state.conversation_id !== expected) {
|
|
1837
|
+
return {
|
|
1838
|
+
ok: false,
|
|
1839
|
+
changed: true,
|
|
1840
|
+
error: `the session moved to conversation ${state.conversation_id} while waiting`,
|
|
1841
|
+
};
|
|
1842
|
+
}
|
|
1843
|
+
// Checked again here, under the lease: the session may have become live
|
|
1844
|
+
// after --capture-cleanup planned the move. SessionStart gave a live
|
|
1845
|
+
// session's agent its conversation_id, and a move can't tell it the new one.
|
|
1846
|
+
if (options && options.defer_if_live && captureState.sessionLooksLive(state)) {
|
|
1847
|
+
return { ok: false, deferred: true, error: "the session is still open" };
|
|
1848
|
+
}
|
|
1849
|
+
// MEKO-592: a session that started before the user pinned a datapack has
|
|
1850
|
+
// none in its watermark. Re-read the pin the way drainSession does, so
|
|
1851
|
+
// the new conversation is created in the datapack its turns will go to.
|
|
1852
|
+
if (!state.datapack_id) {
|
|
1853
|
+
const pin = readDatapackPin(state.agent_id);
|
|
1854
|
+
if (pin && pin.datapack_id) {
|
|
1855
|
+
state = { ...state, datapack_id: pin.datapack_id, datapack_name: pin.datapack_name || null };
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
await mcpInitialize();
|
|
1859
|
+
const moved = await recoverMissingConversation({
|
|
1860
|
+
// cleanup_moved_epoch lets --capture-cleanup count epochs since its own
|
|
1861
|
+
// last move, not the session's lifetime total. The move also ends any
|
|
1862
|
+
// park the cleanup tool set (the retry fields are reset here).
|
|
1863
|
+
state: {
|
|
1864
|
+
...state,
|
|
1865
|
+
parked: null,
|
|
1866
|
+
cleanup_moved_epoch: Number(state.conversation_epoch || 0) + 1,
|
|
1867
|
+
},
|
|
1868
|
+
sessionId,
|
|
1869
|
+
agentId: state.agent_id,
|
|
1870
|
+
transcriptPath: state.transcript_path,
|
|
1871
|
+
datapackId: state.datapack_id || null,
|
|
1872
|
+
});
|
|
1873
|
+
if (!moved.ok) return { ok: false, error: moved.error };
|
|
1874
|
+
return {
|
|
1875
|
+
ok: true,
|
|
1876
|
+
from: state.conversation_id || null,
|
|
1877
|
+
to: moved.convId,
|
|
1878
|
+
last_line_number: moved.state.last_line_number,
|
|
1879
|
+
};
|
|
1880
|
+
} catch (err) {
|
|
1881
|
+
return { ok: false, error: err.message };
|
|
1882
|
+
} finally {
|
|
1883
|
+
captureState.releaseLease(sessionId, lease.lease.token);
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1103
1887
|
/**
|
|
1104
1888
|
* Abort all pending MCP requests. Used by the preload timeout path so the
|
|
1105
1889
|
* SessionStart hook can return promptly instead of waiting for a slow server
|
|
@@ -1149,14 +1933,17 @@ async function mcpInitialize() {
|
|
|
1149
1933
|
});
|
|
1150
1934
|
}
|
|
1151
1935
|
|
|
1152
|
-
async function mcpCall(toolName, args) {
|
|
1936
|
+
async function mcpCall(toolName, args, options) {
|
|
1153
1937
|
mcpRequestId++;
|
|
1154
|
-
const result = await mcpPost(
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1938
|
+
const result = await mcpPost(
|
|
1939
|
+
{
|
|
1940
|
+
jsonrpc: "2.0",
|
|
1941
|
+
method: "tools/call",
|
|
1942
|
+
params: { name: toolName, arguments: args },
|
|
1943
|
+
id: mcpRequestId,
|
|
1944
|
+
},
|
|
1945
|
+
options,
|
|
1946
|
+
);
|
|
1160
1947
|
return result.body;
|
|
1161
1948
|
}
|
|
1162
1949
|
|
|
@@ -1218,21 +2005,29 @@ function peekTranscriptMetadata(transcriptPath) {
|
|
|
1218
2005
|
* best-effort enrichment, not a correctness requirement.
|
|
1219
2006
|
*/
|
|
1220
2007
|
async function fetchRecentMemories(agentId, datapackId = null, { limit = 10, budget = 2000 } = {}) {
|
|
2008
|
+
// memory_get_all takes no `limit`, and FastMCP rejects an unknown argument
|
|
2009
|
+
// with an isError result, so `limit` caps the page client-side instead.
|
|
1221
2010
|
const payload = {
|
|
1222
2011
|
agent_id: agentId,
|
|
1223
2012
|
conversation_id: "00000000-0000-0000-0000-000000000000",
|
|
1224
|
-
limit,
|
|
1225
2013
|
};
|
|
1226
2014
|
if (datapackId) payload.datapack_id = datapackId;
|
|
1227
2015
|
const response = await mcpCall("memory_get_all", payload);
|
|
2016
|
+
if (response && response.result && response.result.isError) {
|
|
2017
|
+
const detail = ((response.result.content || [])[0] || {}).text || "";
|
|
2018
|
+
process.stderr.write(`[meko-capture] memory preload failed: ${truncate(detail.replace(/\s+/g, " "), 300)}\n`);
|
|
2019
|
+
return [];
|
|
2020
|
+
}
|
|
1228
2021
|
const result = extractToolResult(response);
|
|
1229
2022
|
if (!result) return [];
|
|
1230
2023
|
|
|
1231
2024
|
const entries = Array.isArray(result) ? result : result.memories || result.results || [];
|
|
1232
2025
|
const summaries = [];
|
|
1233
2026
|
let used = 0;
|
|
1234
|
-
for (const entry of entries) {
|
|
1235
|
-
|
|
2027
|
+
for (const entry of entries.slice(0, limit)) {
|
|
2028
|
+
// The meko API returns the text as `data`; `memory` is the mem0 shape
|
|
2029
|
+
// servers returned before MEKO-216.
|
|
2030
|
+
const text = entry.data || entry.memory || entry.text || entry.content || "";
|
|
1236
2031
|
if (!text) continue;
|
|
1237
2032
|
const line = `- ${String(text).replace(/\s+/g, " ").trim()}`;
|
|
1238
2033
|
if (used + line.length + 1 > budget) break;
|
|
@@ -1242,18 +2037,31 @@ async function fetchRecentMemories(agentId, datapackId = null, { limit = 10, bud
|
|
|
1242
2037
|
return summaries;
|
|
1243
2038
|
}
|
|
1244
2039
|
|
|
1245
|
-
|
|
2040
|
+
/**
|
|
2041
|
+
* The server derives the conversation id from `session_id`, so the same session
|
|
2042
|
+
* always maps to the same conversation. Pass `freshId: true` to omit
|
|
2043
|
+
* `session_id` and have the server mint a new id instead. Recovery needs this:
|
|
2044
|
+
* re-creating with the session id returns the conversation that just failed.
|
|
2045
|
+
*/
|
|
2046
|
+
async function createConversation(
|
|
2047
|
+
sessionId,
|
|
2048
|
+
agentId,
|
|
2049
|
+
metadata = null,
|
|
2050
|
+
datapackId = null,
|
|
2051
|
+
{ freshId = false } = {},
|
|
2052
|
+
) {
|
|
1246
2053
|
const clientLabel = {
|
|
1247
2054
|
claude_code: "Claude Code",
|
|
1248
2055
|
cursor: "Cursor",
|
|
1249
2056
|
codex: "Codex",
|
|
1250
2057
|
kiro: "Kiro",
|
|
2058
|
+
opencode: "OpenCode",
|
|
1251
2059
|
}[resolveHookClient()] || "Coding agent";
|
|
1252
2060
|
const payload = {
|
|
1253
2061
|
agent_id: agentId,
|
|
1254
2062
|
title: `${clientLabel} session (auto-captured)`,
|
|
1255
|
-
session_id: sessionId,
|
|
1256
2063
|
};
|
|
2064
|
+
if (!freshId) payload.session_id = sessionId;
|
|
1257
2065
|
if (metadata) {
|
|
1258
2066
|
payload.metadata = JSON.stringify({
|
|
1259
2067
|
source: "hook",
|
|
@@ -1265,6 +2073,24 @@ async function createConversation(sessionId, agentId, metadata = null, datapackI
|
|
|
1265
2073
|
if (datapackId) payload.datapack_id = datapackId;
|
|
1266
2074
|
const response = await mcpCall("conversation_create", payload);
|
|
1267
2075
|
const result = extractToolResult(response);
|
|
2076
|
+
// MEKO-592: the server now returns a structured error when no datapack_id
|
|
2077
|
+
// resolves (missing pin, no server-side default). Before this guard the
|
|
2078
|
+
// server returned a success payload with a fabricated conversation id and
|
|
2079
|
+
// every subsequent add_message came back not_found — capture was silently
|
|
2080
|
+
// dead. Propagate the error like conversation_add_message already does so
|
|
2081
|
+
// callers can distinguish "server unreachable" from "action required (pin
|
|
2082
|
+
// a datapack)".
|
|
2083
|
+
if (result && result.error) {
|
|
2084
|
+
const err = new Error(`conversation_create failed: ${JSON.stringify(result.error)}`);
|
|
2085
|
+
err.code = result.error;
|
|
2086
|
+
err.detail = result.detail || "";
|
|
2087
|
+
// Distinguish a server-returned structured error from a Node system error
|
|
2088
|
+
// (e.g. ECONNREFUSED off `fetch`), which also carries `.code`. The catch
|
|
2089
|
+
// site branches on this to keep "MCP unavailable" reserved for transport
|
|
2090
|
+
// failures.
|
|
2091
|
+
err.isMcpToolError = true;
|
|
2092
|
+
throw err;
|
|
2093
|
+
}
|
|
1268
2094
|
return result && result.id ? result.id : null;
|
|
1269
2095
|
}
|
|
1270
2096
|
|
|
@@ -1362,7 +2188,7 @@ async function ensureConversationOwner({
|
|
|
1362
2188
|
return { convId, agentId: cachedOwner, source: "derived", rebucketed: false };
|
|
1363
2189
|
}
|
|
1364
2190
|
|
|
1365
|
-
async function addMessage(convId, agentId, exchange, datapackId = null) {
|
|
2191
|
+
async function addMessage(convId, agentId, exchange, datapackId = null, timeoutMs = null) {
|
|
1366
2192
|
const seed = `${convId}:${exchange.user_uuid}`;
|
|
1367
2193
|
const payload = {
|
|
1368
2194
|
conversation_id: convId,
|
|
@@ -1379,7 +2205,9 @@ async function addMessage(convId, agentId, exchange, datapackId = null) {
|
|
|
1379
2205
|
? exchange.metadata
|
|
1380
2206
|
: JSON.stringify(exchange.metadata);
|
|
1381
2207
|
}
|
|
1382
|
-
const response = await mcpCall("conversation_add_message", payload
|
|
2208
|
+
const response = await mcpCall("conversation_add_message", payload, {
|
|
2209
|
+
timeoutMs: timeoutMs || MEKO_ADD_MESSAGE_TIMEOUT,
|
|
2210
|
+
});
|
|
1383
2211
|
// FAIL CLOSED. The MCP transport resolves successfully even when the tool
|
|
1384
2212
|
// failed, and failures take several shapes: an `isError` result envelope, a
|
|
1385
2213
|
// JSON body with an `error` field, or a non-JSON / missing-content / otherwise
|
|
@@ -1400,7 +2228,9 @@ async function addMessage(convId, agentId, exchange, datapackId = null) {
|
|
|
1400
2228
|
);
|
|
1401
2229
|
}
|
|
1402
2230
|
if (result.error) {
|
|
1403
|
-
|
|
2231
|
+
// Keep the server's detail: isMissingConversationError() keys on it.
|
|
2232
|
+
const detail = typeof result.detail === "string" && result.detail ? `: ${result.detail}` : "";
|
|
2233
|
+
throw new Error(`conversation_add_message failed: ${JSON.stringify(result.error)}${detail}`);
|
|
1404
2234
|
}
|
|
1405
2235
|
// Contracts: advance only after a structurally valid accepted response that
|
|
1406
2236
|
// contains a message ID (server returns message_id; tolerate `id` aliases).
|
|
@@ -1576,7 +2406,7 @@ ${opening}
|
|
|
1576
2406
|
|
|
1577
2407
|
### What you MUST do with Meko tools
|
|
1578
2408
|
|
|
1579
|
-
- **agent_id**: use "${resolvedAgentId}" verbatim for every Meko MCP tool call in this session.
|
|
2409
|
+
- **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.
|
|
1580
2410
|
- **conversation_id**: Use "${convId}" for all MCP tool calls that accept it
|
|
1581
2411
|
|
|
1582
2412
|
### Memory capture is automatic only when hook delivery succeeds — do NOT proactively call memory_add
|
|
@@ -1590,7 +2420,7 @@ Call memory_add (with the session's agent_id + conversation_id) ONLY for the nar
|
|
|
1590
2420
|
|
|
1591
2421
|
### Recall and before destructive actions
|
|
1592
2422
|
|
|
1593
|
-
- User asks "what do you know about X?" → call
|
|
2423
|
+
- 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.
|
|
1594
2424
|
- 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.
|
|
1595
2425
|
|
|
1596
2426
|
### Use Meko, not local storage
|
|
@@ -1601,9 +2431,17 @@ Never write memories to local files (e.g., ~/.claude/projects/.../memory/). Meko
|
|
|
1601
2431
|
function buildSessionStartFallbackContext(agentId, opts) {
|
|
1602
2432
|
const resolvedAgentId = agentId || COMMON_BUCKET_AGENT_ID;
|
|
1603
2433
|
const datapackBlock = buildActiveDatapackBlock(opts && opts.datapackPin ? opts.datapackPin : null);
|
|
2434
|
+
// MEKO-592: the "server unreachable" preamble is the wrong story when the
|
|
2435
|
+
// server accepted the call and told us to pin a datapack. Callers pass
|
|
2436
|
+
// reason:"datapack_required" to swap it for setup guidance the agent can
|
|
2437
|
+
// act on.
|
|
2438
|
+
const reason = opts && opts.reason;
|
|
2439
|
+
const preamble = reason === "datapack_required"
|
|
2440
|
+
? "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_search(query=...)` (or `datapack_list` on a server without it) 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."
|
|
2441
|
+
: "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.";
|
|
1604
2442
|
return `## Meko Memory Instructions
|
|
1605
2443
|
|
|
1606
|
-
|
|
2444
|
+
${preamble}
|
|
1607
2445
|
|
|
1608
2446
|
### What you should do while capture is down
|
|
1609
2447
|
|
|
@@ -1615,7 +2453,7 @@ The SessionStart hook could not reach the Meko MCP server to create a conversati
|
|
|
1615
2453
|
Because nothing is capturing this session, explicit saves are the only path — this is the exception, not the normal mode:
|
|
1616
2454
|
- User shares personal info, preferences, or role → call memory_add immediately
|
|
1617
2455
|
- User shares org conventions or team standards → call memory_add immediately
|
|
1618
|
-
- User asks "what do you know about X?" → call
|
|
2456
|
+
- User asks "what do you know about X?" → call context_search first
|
|
1619
2457
|
|
|
1620
2458
|
### Use Meko, not local storage
|
|
1621
2459
|
|
|
@@ -1685,7 +2523,14 @@ async function handleSessionStart(hookInput) {
|
|
|
1685
2523
|
const sessionId = extractSessionId(hookInput, transcriptPath);
|
|
1686
2524
|
|
|
1687
2525
|
const source = hookInput.source || "";
|
|
1688
|
-
const
|
|
2526
|
+
const sessionCwd = pickSessionCwd(transcriptPath, hookInput);
|
|
2527
|
+
const derivedAgentId = resolveSessionAgentId(transcriptPath, hookInput, sessionCwd);
|
|
2528
|
+
const legacyAgentId = deriveLegacyAgentId({
|
|
2529
|
+
client: resolveHookClient(),
|
|
2530
|
+
cwd: sessionCwd,
|
|
2531
|
+
envOverride: process.env.MEKO_AGENT_ID,
|
|
2532
|
+
});
|
|
2533
|
+
logAgentIdDerivation(sessionCwd, derivedAgentId);
|
|
1689
2534
|
const workspaceKey = workspaceKeyFromHookInput(hookInput, transcriptPath);
|
|
1690
2535
|
|
|
1691
2536
|
// Check if watermark already exists (same session ID re-fires SessionStart, e.g. /clear, /compact)
|
|
@@ -1697,7 +2542,7 @@ async function handleSessionStart(hookInput) {
|
|
|
1697
2542
|
`[meko-capture] SessionStart: corrupt watermark for ${sessionId} (${existing.error}); blocking.\n`,
|
|
1698
2543
|
);
|
|
1699
2544
|
const context = withHealthNotice(buildSessionStartFallbackContext(derivedAgentId, {
|
|
1700
|
-
datapackPin: readDatapackPin(derivedAgentId),
|
|
2545
|
+
datapackPin: readDatapackPin(derivedAgentId, legacyAgentId),
|
|
1701
2546
|
}));
|
|
1702
2547
|
process.stdout.write(hookOutput(context));
|
|
1703
2548
|
return;
|
|
@@ -1737,6 +2582,7 @@ async function handleSessionStart(hookInput) {
|
|
|
1737
2582
|
// conversation_id) and inject context under the actual owner so nothing
|
|
1738
2583
|
// is written to the wrong namespace; a later hook retries the rebucket.
|
|
1739
2584
|
if (owned.hold) {
|
|
2585
|
+
logEffectiveAgentId(derivedAgentId, existing.agent_id, "watermark-hold");
|
|
1740
2586
|
const holdContext = buildSessionStartContext(
|
|
1741
2587
|
existing.conversation_id,
|
|
1742
2588
|
sessionId,
|
|
@@ -1750,6 +2596,7 @@ async function handleSessionStart(hookInput) {
|
|
|
1750
2596
|
return;
|
|
1751
2597
|
}
|
|
1752
2598
|
const resumedAgentId = owned.agentId;
|
|
2599
|
+
logEffectiveAgentId(derivedAgentId, resumedAgentId, "watermark-reconcile");
|
|
1753
2600
|
const resumedConvId = owned.convId;
|
|
1754
2601
|
// Persist the corrected identity + conversation (+ provenance) so the
|
|
1755
2602
|
// checkpoint / PreCompact / SessionEnd hooks read a consistent watermark
|
|
@@ -1823,6 +2670,7 @@ async function handleSessionStart(hookInput) {
|
|
|
1823
2670
|
// watermark for this resumed session (no owner we can safely write under)
|
|
1824
2671
|
// — inject context under the prior owner and let a later hook retry.
|
|
1825
2672
|
if (owned.hold) {
|
|
2673
|
+
logEffectiveAgentId(derivedAgentId, prior.agent_id, "resume-hold");
|
|
1826
2674
|
const holdContext = buildSessionStartContext(
|
|
1827
2675
|
prior.conversation_id,
|
|
1828
2676
|
sessionId,
|
|
@@ -1837,6 +2685,7 @@ async function handleSessionStart(hookInput) {
|
|
|
1837
2685
|
return;
|
|
1838
2686
|
}
|
|
1839
2687
|
const resumedAgentId = owned.agentId;
|
|
2688
|
+
logEffectiveAgentId(derivedAgentId, resumedAgentId, "resume-reconcile");
|
|
1840
2689
|
const resumedConvId = owned.convId;
|
|
1841
2690
|
// On a fresh rebucketed conversation, start the watermark at 0 so this
|
|
1842
2691
|
// session's turns replay into it. Otherwise keep currentLineCount so the
|
|
@@ -1878,7 +2727,7 @@ async function handleSessionStart(hookInput) {
|
|
|
1878
2727
|
// Intent-first: durable outbox BEFORE any network call so create failures
|
|
1879
2728
|
// leave a recoverable needs_conversation state (Capture V2).
|
|
1880
2729
|
const transcriptMetadata = peekTranscriptMetadata(transcriptPath);
|
|
1881
|
-
const datapackPin = readDatapackPin(agentId);
|
|
2730
|
+
const datapackPin = readDatapackPin(agentId, legacyAgentId);
|
|
1882
2731
|
const agentIdSource = (process.env.MEKO_AGENT_ID || "").trim() ? "explicit" : "derived";
|
|
1883
2732
|
if (sessionId) {
|
|
1884
2733
|
const intent = captureState.writeSessionIntent(sessionId, {
|
|
@@ -1908,9 +2757,38 @@ async function handleSessionStart(hookInput) {
|
|
|
1908
2757
|
datapackPin && datapackPin.datapack_id,
|
|
1909
2758
|
);
|
|
1910
2759
|
} catch (err) {
|
|
1911
|
-
|
|
1912
|
-
|
|
2760
|
+
// MEKO-592: distinguish "server unreachable" (network/init failure) from
|
|
2761
|
+
// "server accepted the call and told us to pin a datapack". The latter
|
|
2762
|
+
// is user action, not a server outage — logging it as "MCP unavailable"
|
|
2763
|
+
// sends the reporter down the wrong troubleshooting path.
|
|
2764
|
+
const isDatapackRequired = err && err.code === "datapack_id_required";
|
|
2765
|
+
if (isDatapackRequired) {
|
|
2766
|
+
process.stderr.write(
|
|
2767
|
+
`[meko-capture] SessionStart: no datapack pinned; auto-capture is idle. Pin one with datapack_search (or datapack_list) + meko-select-datapack; the next drain picks up the fresh pin.\n`,
|
|
2768
|
+
);
|
|
2769
|
+
} else if (err && err.isMcpToolError) {
|
|
2770
|
+
// Server accepted the call and returned a structured error (e.g.
|
|
2771
|
+
// invalid_metadata, create_failed). It is not "MCP unavailable"; print
|
|
2772
|
+
// the code so the reporter isn't sent down the network path. Node
|
|
2773
|
+
// system errors (ECONNREFUSED, ETIMEDOUT) also carry `.code`, so we
|
|
2774
|
+
// gate on the marker set by createConversation.
|
|
2775
|
+
process.stderr.write(
|
|
2776
|
+
`[meko-capture] SessionStart: server rejected conversation_create (${err.code}: ${err.detail || err.message}). Falling back to agent-driven setup.\n`,
|
|
2777
|
+
);
|
|
2778
|
+
} else {
|
|
2779
|
+
process.stderr.write(`[meko-capture] SessionStart: MCP unavailable (${err.message}). Falling back to agent-driven setup.\n`);
|
|
2780
|
+
}
|
|
2781
|
+
const contextOpts = { datapackPin: readDatapackPin(agentId, legacyAgentId) };
|
|
2782
|
+
if (isDatapackRequired) contextOpts.reason = "datapack_required";
|
|
2783
|
+
let context = withHealthNotice(buildSessionStartFallbackContext(agentId, contextOpts));
|
|
1913
2784
|
if (sessionId) {
|
|
2785
|
+
// MEKO-592: state was written by writeSessionIntent above with
|
|
2786
|
+
// `delivery: "needs_conversation"`, which is the Capture V2 marker
|
|
2787
|
+
// that `recover` and drain look for. Don't overwrite it here - the
|
|
2788
|
+
// first drain classifies the failure and updates the health bucket.
|
|
2789
|
+
// The prior `status: "action_required"` cache write was dead code
|
|
2790
|
+
// (nothing reads the session cache for `--capture-status`), so keep
|
|
2791
|
+
// `"error"` and leave the health signal to state on the next drain.
|
|
1914
2792
|
try {
|
|
1915
2793
|
writeSessionCache(sessionId, {
|
|
1916
2794
|
status: "error",
|
|
@@ -1929,7 +2807,9 @@ async function handleSessionStart(hookInput) {
|
|
|
1929
2807
|
|
|
1930
2808
|
if (!convId) {
|
|
1931
2809
|
process.stderr.write("[meko-capture] SessionStart: conversation_create returned no ID.\n");
|
|
1932
|
-
let context = withHealthNotice(buildSessionStartFallbackContext(agentId, {
|
|
2810
|
+
let context = withHealthNotice(buildSessionStartFallbackContext(agentId, {
|
|
2811
|
+
datapackPin: readDatapackPin(agentId, legacyAgentId),
|
|
2812
|
+
}));
|
|
1933
2813
|
if (sessionId) {
|
|
1934
2814
|
try {
|
|
1935
2815
|
writeSessionCache(sessionId, {
|
|
@@ -2006,10 +2886,26 @@ function persistExchangeCheckpoint(sessionId, state, nextLine, extras) {
|
|
|
2006
2886
|
failure_class: null,
|
|
2007
2887
|
last_error: null,
|
|
2008
2888
|
blocked_reason: null,
|
|
2889
|
+
last_failure_scope: null,
|
|
2890
|
+
recovered_from_conversation_id: null,
|
|
2891
|
+
missing_conversation_streak: null,
|
|
2009
2892
|
last_success_at: new Date().toISOString(),
|
|
2893
|
+
consecutive_exchange_drops:
|
|
2894
|
+
extras.consecutive_exchange_drops != null
|
|
2895
|
+
? extras.consecutive_exchange_drops
|
|
2896
|
+
: 0,
|
|
2010
2897
|
in_flight: { seed: null, user_line: null, user_turn_id: null },
|
|
2011
2898
|
};
|
|
2012
2899
|
if (extras.lifecycle) next.lifecycle = extras.lifecycle;
|
|
2900
|
+
if (extras.dropped_exchanges != null) {
|
|
2901
|
+
next.dropped_exchanges = extras.dropped_exchanges;
|
|
2902
|
+
}
|
|
2903
|
+
if (extras.last_drop_reason !== undefined) {
|
|
2904
|
+
next.last_drop_reason = extras.last_drop_reason;
|
|
2905
|
+
}
|
|
2906
|
+
if (extras.last_success_at !== undefined) {
|
|
2907
|
+
next.last_success_at = extras.last_success_at;
|
|
2908
|
+
}
|
|
2013
2909
|
if (extras.agent_id != null) next.agent_id = extras.agent_id;
|
|
2014
2910
|
if (extras.agent_id_source != null) next.agent_id_source = extras.agent_id_source;
|
|
2015
2911
|
if (extras.conversation_id != null) next.conversation_id = extras.conversation_id;
|
|
@@ -2032,7 +2928,30 @@ function persistExchangeCheckpoint(sessionId, state, nextLine, extras) {
|
|
|
2032
2928
|
* Build a synthetic interrupted exchange for a trailing incomplete user turn.
|
|
2033
2929
|
*/
|
|
2034
2930
|
function buildInterruptedExchange(turn, sessionId) {
|
|
2035
|
-
const input =
|
|
2931
|
+
const input = turnInput(turn) || "";
|
|
2932
|
+
// A turn held only because injected context (a skill body or a task
|
|
2933
|
+
// notification) never got its follow-up reply already has a real answer.
|
|
2934
|
+
// Keep it rather than replacing it with the interrupted marker.
|
|
2935
|
+
if (
|
|
2936
|
+
turn.assistantEntries.length > 0 &&
|
|
2937
|
+
!(turn.codexStreaming && !turn.codexComplete)
|
|
2938
|
+
) {
|
|
2939
|
+
const { output, reasoning } = renderTurnBody(turn);
|
|
2940
|
+
if (output) {
|
|
2941
|
+
return {
|
|
2942
|
+
user_uuid: turn.userMsg.uuid || "unknown",
|
|
2943
|
+
user_line: turn.userLine,
|
|
2944
|
+
next_line_number: null,
|
|
2945
|
+
input,
|
|
2946
|
+
output,
|
|
2947
|
+
reasoning,
|
|
2948
|
+
timestamp:
|
|
2949
|
+
turn.assistantEntries[turn.assistantEntries.length - 1].timestamp ||
|
|
2950
|
+
turn.userMsg.timestamp ||
|
|
2951
|
+
"",
|
|
2952
|
+
};
|
|
2953
|
+
}
|
|
2954
|
+
}
|
|
2036
2955
|
return {
|
|
2037
2956
|
user_uuid: turn.userMsg.uuid || "unknown",
|
|
2038
2957
|
user_line: turn.userLine,
|
|
@@ -2146,12 +3065,14 @@ async function drainSession(options) {
|
|
|
2146
3065
|
last_error: null,
|
|
2147
3066
|
last_success_at: null,
|
|
2148
3067
|
blocked_reason: null,
|
|
3068
|
+
consecutive_exchange_drops: 0,
|
|
2149
3069
|
};
|
|
2150
3070
|
const transcriptPath =
|
|
2151
3071
|
opts.transcript_path ||
|
|
2152
3072
|
state.transcript_path ||
|
|
2153
3073
|
null;
|
|
2154
3074
|
|
|
3075
|
+
const persistedLifecycle = state.lifecycle;
|
|
2155
3076
|
if (opts.lifecycle && opts.lifecycle !== state.lifecycle) {
|
|
2156
3077
|
state.lifecycle = opts.lifecycle;
|
|
2157
3078
|
} else if (mode === "session-end" && state.lifecycle === "active") {
|
|
@@ -2164,10 +3085,16 @@ async function drainSession(options) {
|
|
|
2164
3085
|
const retryAtMs = state.next_retry_at
|
|
2165
3086
|
? Date.parse(state.next_retry_at)
|
|
2166
3087
|
: NaN;
|
|
3088
|
+
const nowMs = Date.now();
|
|
3089
|
+
const persistentRetryDue =
|
|
3090
|
+
state.failure_class === "persistent" &&
|
|
3091
|
+
state.last_failure_scope === "exchange" &&
|
|
3092
|
+
Number.isFinite(retryAtMs) &&
|
|
3093
|
+
retryAtMs <= nowMs;
|
|
2167
3094
|
if (
|
|
2168
3095
|
state.failure_class &&
|
|
2169
3096
|
Number.isFinite(retryAtMs) &&
|
|
2170
|
-
retryAtMs >
|
|
3097
|
+
retryAtMs > nowMs &&
|
|
2171
3098
|
!(Boolean(opts.force_retry) && state.failure_class === "persistent")
|
|
2172
3099
|
) {
|
|
2173
3100
|
return makeDrainResult({
|
|
@@ -2184,6 +3111,52 @@ async function drainSession(options) {
|
|
|
2184
3111
|
}
|
|
2185
3112
|
|
|
2186
3113
|
if (!transcriptPath || !fs.existsSync(transcriptPath)) {
|
|
3114
|
+
// The transcript is the only source of turns, so a closed session whose
|
|
3115
|
+
// transcript is gone has nothing left to deliver — ever. Left alone it
|
|
3116
|
+
// sits at "pending" forever and holds the health rollup below healthy:
|
|
3117
|
+
// 26 such tombstones had accumulated, the oldest 16.7 days old, none of
|
|
3118
|
+
// them carrying a single deliverable turn.
|
|
3119
|
+
//
|
|
3120
|
+
// Only a session that is closing/closed AND has been idle past the grace
|
|
3121
|
+
// period is finalized. An ACTIVE session whose transcript has not
|
|
3122
|
+
// appeared yet must stay pending so it can still deliver, and the grace
|
|
3123
|
+
// period covers a transcript that is briefly unreadable (an unmounted
|
|
3124
|
+
// volume, a permissions blip) rather than genuinely gone.
|
|
3125
|
+
const idleMs = state.last_activity_at
|
|
3126
|
+
? Date.now() - Date.parse(state.last_activity_at)
|
|
3127
|
+
: 0;
|
|
3128
|
+
const graceMs = transcriptMissingGraceSeconds() * 1000;
|
|
3129
|
+
const finalizable =
|
|
3130
|
+
(persistedLifecycle === "closing" || persistedLifecycle === "closed") &&
|
|
3131
|
+
Number.isFinite(idleMs) &&
|
|
3132
|
+
idleMs >= graceMs &&
|
|
3133
|
+
coerceQueued(state) === 0;
|
|
3134
|
+
if (stateExisted && finalizable) {
|
|
3135
|
+
state.lifecycle = "closed";
|
|
3136
|
+
state.delivery = "complete";
|
|
3137
|
+
state.queued_exchanges = 0;
|
|
3138
|
+
clearResolvedFailure(state);
|
|
3139
|
+
captureState.writeState(sessionId, state);
|
|
3140
|
+
process.stderr.write(
|
|
3141
|
+
`[meko-capture] Finalizing ${sessionId}: transcript is gone and the session ` +
|
|
3142
|
+
`closed ${Math.round(idleMs / 86400000)}d ago with nothing queued; ` +
|
|
3143
|
+
`nothing left to deliver.\n`,
|
|
3144
|
+
);
|
|
3145
|
+
return makeDrainResult({
|
|
3146
|
+
status: "noop",
|
|
3147
|
+
session_id: sessionId,
|
|
3148
|
+
last_line_number: state.last_line_number || 0,
|
|
3149
|
+
lifecycle: state.lifecycle,
|
|
3150
|
+
delivery: state.delivery,
|
|
3151
|
+
error: "transcript missing; finalized",
|
|
3152
|
+
});
|
|
3153
|
+
}
|
|
3154
|
+
// A recovery sweep marks abandoned sessions as closing while it drains
|
|
3155
|
+
// them. Do not persist that override when the transcript is absent: the
|
|
3156
|
+
// stored active lifecycle is the evidence that this session may return.
|
|
3157
|
+
if (mode === "recover" && persistedLifecycle === "active") {
|
|
3158
|
+
state.lifecycle = persistedLifecycle;
|
|
3159
|
+
}
|
|
2187
3160
|
if (stateExisted) captureState.writeState(sessionId, state);
|
|
2188
3161
|
return makeDrainResult({
|
|
2189
3162
|
status: "noop",
|
|
@@ -2198,11 +3171,27 @@ async function drainSession(options) {
|
|
|
2198
3171
|
let convId = state.conversation_id || "";
|
|
2199
3172
|
let lastLine = state.last_line_number || 0;
|
|
2200
3173
|
const currentLines = countLines(transcriptPath);
|
|
2201
|
-
|
|
3174
|
+
let datapackPin = datapackPinFromWatermark({
|
|
2202
3175
|
datapack_id: state.datapack_id,
|
|
2203
3176
|
datapack_name: state.datapack_name,
|
|
2204
3177
|
});
|
|
2205
3178
|
|
|
3179
|
+
// MEKO-592: SessionStart freezes state.datapack_id at the value the pin
|
|
3180
|
+
// resolved to when the session opened. If the session started before the
|
|
3181
|
+
// user pinned a datapack, that value is null and every drain here would
|
|
3182
|
+
// resend it, hitting datapack_id_required against the guarded server on
|
|
3183
|
+
// every retry. Re-read the pin file when state has none so a mid-session
|
|
3184
|
+
// pin actually recovers this session's queued turns instead of forcing
|
|
3185
|
+
// the user to start a fresh session.
|
|
3186
|
+
if (!datapackPin && (state.agent_id || "").trim()) {
|
|
3187
|
+
const freshPin = readDatapackPin(state.agent_id);
|
|
3188
|
+
if (freshPin && freshPin.datapack_id) {
|
|
3189
|
+
datapackPin = freshPin;
|
|
3190
|
+
state.datapack_id = freshPin.datapack_id;
|
|
3191
|
+
state.datapack_name = freshPin.datapack_name || null;
|
|
3192
|
+
}
|
|
3193
|
+
}
|
|
3194
|
+
|
|
2206
3195
|
let effectiveAgentId = state.agent_id || "";
|
|
2207
3196
|
let effectiveSource = state.agent_id_source || "";
|
|
2208
3197
|
|
|
@@ -2270,14 +3259,19 @@ async function drainSession(options) {
|
|
|
2270
3259
|
}
|
|
2271
3260
|
}
|
|
2272
3261
|
} catch (err) {
|
|
2273
|
-
const
|
|
3262
|
+
const persistent = classifyPersistentCaptureFailure(err);
|
|
3263
|
+
const failureClass = persistent
|
|
3264
|
+
? "persistent"
|
|
3265
|
+
: captureState.classifyFailure(err);
|
|
3266
|
+
const failureText = persistentFailureText(persistent, err.message);
|
|
2274
3267
|
const pendingExchanges = extractExchanges(transcriptPath, lastLine).length;
|
|
2275
3268
|
// Only persist failure onto an existing outbox — never invent a watermark
|
|
2276
3269
|
// just because initialize failed (SessionStart owns intent creation).
|
|
2277
3270
|
if (stateExisted) {
|
|
2278
3271
|
state.delivery = failureClass === "transient" ? "retry_wait" : "blocked";
|
|
2279
3272
|
state.failure_class = failureClass;
|
|
2280
|
-
state.last_error =
|
|
3273
|
+
state.last_error = failureText;
|
|
3274
|
+
state.last_failure_scope = persistent ? persistent.scope : null;
|
|
2281
3275
|
state.attempt_count = (state.attempt_count || 0) + 1;
|
|
2282
3276
|
state.next_retry_at = captureState.nextRetryAt({
|
|
2283
3277
|
failure_class: failureClass,
|
|
@@ -2287,14 +3281,14 @@ async function drainSession(options) {
|
|
|
2287
3281
|
captureState.writeState(sessionId, state);
|
|
2288
3282
|
}
|
|
2289
3283
|
process.stderr.write(
|
|
2290
|
-
`[meko-capture] Failed to initialize MCP session: ${
|
|
3284
|
+
`[meko-capture] Failed to initialize MCP session: ${failureText}\n`,
|
|
2291
3285
|
);
|
|
2292
3286
|
return makeDrainResult({
|
|
2293
3287
|
status: failureClass === "transient" ? "retry_wait" : "blocked",
|
|
2294
3288
|
session_id: sessionId,
|
|
2295
3289
|
queued_remaining: pendingExchanges,
|
|
2296
3290
|
failure_class: failureClass,
|
|
2297
|
-
error:
|
|
3291
|
+
error: failureText,
|
|
2298
3292
|
last_line_number: lastLine,
|
|
2299
3293
|
lifecycle: state.lifecycle,
|
|
2300
3294
|
delivery: stateExisted ? state.delivery : "needs_conversation",
|
|
@@ -2391,6 +3385,7 @@ async function drainSession(options) {
|
|
|
2391
3385
|
state.lifecycle = "closed";
|
|
2392
3386
|
}
|
|
2393
3387
|
state.queued_exchanges = 0;
|
|
3388
|
+
clearResolvedFailure(state);
|
|
2394
3389
|
}
|
|
2395
3390
|
state.agent_id = effectiveAgentId;
|
|
2396
3391
|
state.agent_id_source = effectiveSource;
|
|
@@ -2414,8 +3409,32 @@ async function drainSession(options) {
|
|
|
2414
3409
|
}
|
|
2415
3410
|
|
|
2416
3411
|
const exchanges = allExchanges.slice(0, maxBatch);
|
|
3412
|
+
// PreCompact blocks the user, so it keeps the short timeout; background
|
|
3413
|
+
// drains wait for a slow server to finish storing a large turn.
|
|
3414
|
+
const addMessageTimeoutMs =
|
|
3415
|
+
mode === "pre-compact" ? MEKO_API_TIMEOUT : MEKO_ADD_MESSAGE_TIMEOUT;
|
|
3416
|
+
const leaseTtl = Math.max(
|
|
3417
|
+
captureState.leaseTtlSeconds(),
|
|
3418
|
+
Math.ceil(addMessageTimeoutMs / 1000) + 30,
|
|
3419
|
+
);
|
|
3420
|
+
// Set when renewal fails: "ownership" means another worker took the
|
|
3421
|
+
// lease; anything else means this worker couldn't write it.
|
|
3422
|
+
let leaseFailure = null;
|
|
2417
3423
|
let captured = 0;
|
|
2418
3424
|
let failed = 0;
|
|
3425
|
+
let dropped = 0;
|
|
3426
|
+
// Exchange-scoped HTML 403s may be payload-specific OR request-wide.
|
|
3427
|
+
// Allow one consecutive drop without an intervening success so a single
|
|
3428
|
+
// XSS turn cannot wedge the session; a second consecutive one holds the
|
|
3429
|
+
// queue because it may be a session-wide edge block.
|
|
3430
|
+
let consecutiveExchangeDrops = Number.isInteger(
|
|
3431
|
+
state.consecutive_exchange_drops,
|
|
3432
|
+
)
|
|
3433
|
+
? state.consecutive_exchange_drops
|
|
3434
|
+
: 0;
|
|
3435
|
+
// Consume the backoff drop on the first exchange of this drain, success
|
|
3436
|
+
// or failure, so a later HTML 403 in the same batch cannot also skip.
|
|
3437
|
+
let allowExchangeDropAfterBackoff = persistentRetryDue;
|
|
2419
3438
|
let cursorAdvanced = false;
|
|
2420
3439
|
let watermarkLine = extractFrom;
|
|
2421
3440
|
state.delivery = "draining";
|
|
@@ -2423,6 +3442,23 @@ async function drainSession(options) {
|
|
|
2423
3442
|
captureState.writeState(sessionId, state);
|
|
2424
3443
|
|
|
2425
3444
|
for (const exchange of exchanges) {
|
|
3445
|
+
// Keep the lease ahead of a slow add_message so another drain can't
|
|
3446
|
+
// reclaim it mid-batch. If renewal fails, stop without sending: either
|
|
3447
|
+
// another drain owns the session, or this worker can't write the lease.
|
|
3448
|
+
if (leaseToken) {
|
|
3449
|
+
const renewed = captureState.renewLease(sessionId, leaseToken, {
|
|
3450
|
+
ttlSeconds: leaseTtl,
|
|
3451
|
+
});
|
|
3452
|
+
if (!renewed.ok) {
|
|
3453
|
+
leaseFailure = {
|
|
3454
|
+
failure_class: renewed.failure_class === "ownership" ? "ownership" : "transient",
|
|
3455
|
+
error: renewed.error || "lease renewal failed",
|
|
3456
|
+
};
|
|
3457
|
+
break;
|
|
3458
|
+
}
|
|
3459
|
+
}
|
|
3460
|
+
const canDropAfterBackoff = allowExchangeDropAfterBackoff;
|
|
3461
|
+
allowExchangeDropAfterBackoff = false;
|
|
2426
3462
|
const seed = `${convId}:${exchange.user_uuid}`;
|
|
2427
3463
|
state.in_flight = {
|
|
2428
3464
|
seed,
|
|
@@ -2438,6 +3474,7 @@ async function drainSession(options) {
|
|
|
2438
3474
|
effectiveAgentId,
|
|
2439
3475
|
exchange,
|
|
2440
3476
|
datapackPin && datapackPin.datapack_id,
|
|
3477
|
+
addMessageTimeoutMs,
|
|
2441
3478
|
);
|
|
2442
3479
|
const boundary = boundaryAfterExchange(turnLines, exchange.user_line);
|
|
2443
3480
|
const nextLine =
|
|
@@ -2446,13 +3483,13 @@ async function drainSession(options) {
|
|
|
2446
3483
|
: exchange.next_line_number != null
|
|
2447
3484
|
? exchange.next_line_number
|
|
2448
3485
|
: safeLine;
|
|
2449
|
-
const remainingAfter = allExchanges.length - captured - 1;
|
|
3486
|
+
const remainingAfter = allExchanges.length - captured - dropped - 1;
|
|
2450
3487
|
const ck = persistExchangeCheckpoint(sessionId, state, nextLine, {
|
|
2451
3488
|
delivery:
|
|
2452
3489
|
remainingAfter > 0 || allExchanges.length > maxBatch
|
|
2453
3490
|
? "pending"
|
|
2454
3491
|
: "idle",
|
|
2455
|
-
queued_exchanges: Math.max(0,
|
|
3492
|
+
queued_exchanges: Math.max(0, remainingAfter),
|
|
2456
3493
|
agent_id: effectiveAgentId,
|
|
2457
3494
|
agent_id_source: effectiveSource,
|
|
2458
3495
|
conversation_id: convId,
|
|
@@ -2485,32 +3522,194 @@ async function drainSession(options) {
|
|
|
2485
3522
|
state = ck.state;
|
|
2486
3523
|
watermarkLine = state.last_line_number;
|
|
2487
3524
|
captured++;
|
|
3525
|
+
consecutiveExchangeDrops = 0;
|
|
2488
3526
|
cursorAdvanced = true;
|
|
2489
3527
|
} catch (err) {
|
|
3528
|
+
// A conversation the server no longer has never heals, so retrying the
|
|
3529
|
+
// same id burns attempts and strands every queued turn. Open a fresh
|
|
3530
|
+
// conversation and move the undelivered tail into it. Return immediately
|
|
3531
|
+
// rather than falling through to the post-loop cursor advance.
|
|
3532
|
+
//
|
|
3533
|
+
// A single not_found can be a transient Langfuse lookup. Wait until the
|
|
3534
|
+
// same detail persists across two drains before opening a replacement,
|
|
3535
|
+
// so one blip does not fork a live conversation.
|
|
3536
|
+
//
|
|
3537
|
+
// Recover at most once before the replacement accepts a turn. If the
|
|
3538
|
+
// replacement reports not_found first, the fresh trace may not be
|
|
3539
|
+
// visible on another replica yet. Retry it rather than opening one
|
|
3540
|
+
// conversation per attempt. A successful checkpoint clears the guard,
|
|
3541
|
+
// so a later deletion can recover again.
|
|
3542
|
+
const missingConversation = isMissingConversationError(err);
|
|
3543
|
+
if (missingConversation) {
|
|
3544
|
+
state.missing_conversation_streak = missingConversationStreak(state) + 1;
|
|
3545
|
+
} else if (state.missing_conversation_streak) {
|
|
3546
|
+
state.missing_conversation_streak = null;
|
|
3547
|
+
}
|
|
3548
|
+
const alreadyRecovered = Boolean(state.recovered_from_conversation_id);
|
|
3549
|
+
const streakPersisted =
|
|
3550
|
+
missingConversationStreak(state) >= MISSING_CONVERSATION_RECOVERY_POLLS;
|
|
3551
|
+
if (missingConversation && alreadyRecovered) {
|
|
3552
|
+
process.stderr.write(
|
|
3553
|
+
`[meko-capture] Conversation ${convId} reports not_found, but it already replaced ` +
|
|
3554
|
+
`${state.recovered_from_conversation_id}; retrying it instead of opening another.\n`,
|
|
3555
|
+
);
|
|
3556
|
+
} else if (missingConversation && !streakPersisted) {
|
|
3557
|
+
process.stderr.write(
|
|
3558
|
+
`[meko-capture] Conversation ${convId} reports not_found ` +
|
|
3559
|
+
`(${missingConversationStreak(state)}/${MISSING_CONVERSATION_RECOVERY_POLLS}); ` +
|
|
3560
|
+
`retrying it before opening a new conversation.\n`,
|
|
3561
|
+
);
|
|
3562
|
+
}
|
|
3563
|
+
if (missingConversation && !alreadyRecovered && streakPersisted) {
|
|
3564
|
+
const recovered = await recoverMissingConversation({
|
|
3565
|
+
state,
|
|
3566
|
+
sessionId,
|
|
3567
|
+
agentId: effectiveAgentId,
|
|
3568
|
+
transcriptPath,
|
|
3569
|
+
datapackId: datapackPin && datapackPin.datapack_id,
|
|
3570
|
+
});
|
|
3571
|
+
if (recovered.ok) {
|
|
3572
|
+
process.stderr.write(
|
|
3573
|
+
`[meko-capture] Conversation ${convId} is gone server-side (${err.message}); ` +
|
|
3574
|
+
`moving ${allExchanges.length - captured} queued exchange(s) into new conversation ` +
|
|
3575
|
+
`${recovered.convId}.\n`,
|
|
3576
|
+
);
|
|
3577
|
+
return makeDrainResult({
|
|
3578
|
+
status: "retry_wait",
|
|
3579
|
+
session_id: sessionId,
|
|
3580
|
+
captured,
|
|
3581
|
+
queued_remaining: allExchanges.length - captured,
|
|
3582
|
+
last_line_number: recovered.state.last_line_number || 0,
|
|
3583
|
+
lifecycle: recovered.state.lifecycle,
|
|
3584
|
+
delivery: recovered.state.delivery,
|
|
3585
|
+
});
|
|
3586
|
+
}
|
|
3587
|
+
process.stderr.write(
|
|
3588
|
+
`[meko-capture] Conversation ${convId} is gone server-side but recovery failed ` +
|
|
3589
|
+
`(${recovered.error}); retrying.\n`,
|
|
3590
|
+
);
|
|
3591
|
+
}
|
|
3592
|
+
const persistent = classifyPersistentCaptureFailure(err, {
|
|
3593
|
+
exchange: true,
|
|
3594
|
+
});
|
|
3595
|
+
|
|
3596
|
+
// An exchange-scoped persistent rejection *may* be deterministic for
|
|
3597
|
+
// this exchange only (edge filtering matched a signature inside the
|
|
3598
|
+
// transcript text). Holding forever behind a true payload rejection
|
|
3599
|
+
// wedges the rest of the session (#13). HTML 403 alone is not proof
|
|
3600
|
+
// it is payload-specific — request-wide edge blocks share that shape
|
|
3601
|
+
// — so allow at most one consecutive drop without an intervening
|
|
3602
|
+
// success; a second consecutive failure holds for retry.
|
|
3603
|
+
if (
|
|
3604
|
+
persistent &&
|
|
3605
|
+
persistent.scope === "exchange" &&
|
|
3606
|
+
(consecutiveExchangeDrops < 1 || canDropAfterBackoff)
|
|
3607
|
+
) {
|
|
3608
|
+
const boundary = boundaryAfterExchange(turnLines, exchange.user_line);
|
|
3609
|
+
const nextLine =
|
|
3610
|
+
boundary != null
|
|
3611
|
+
? boundary
|
|
3612
|
+
: exchange.next_line_number != null
|
|
3613
|
+
? exchange.next_line_number
|
|
3614
|
+
: safeLine;
|
|
3615
|
+
const remainingAfter =
|
|
3616
|
+
allExchanges.length - captured - dropped - 1;
|
|
3617
|
+
const drop = persistExchangeCheckpoint(sessionId, state, nextLine, {
|
|
3618
|
+
delivery:
|
|
3619
|
+
remainingAfter > 0 || allExchanges.length > maxBatch
|
|
3620
|
+
? "pending"
|
|
3621
|
+
: "idle",
|
|
3622
|
+
queued_exchanges: Math.max(0, remainingAfter),
|
|
3623
|
+
// Nothing was stored, so the last-success stamp must not move.
|
|
3624
|
+
last_success_at: state.last_success_at || null,
|
|
3625
|
+
dropped_exchanges: (state.dropped_exchanges || 0) + 1,
|
|
3626
|
+
last_drop_reason: persistent.reason,
|
|
3627
|
+
consecutive_exchange_drops: consecutiveExchangeDrops + 1,
|
|
3628
|
+
agent_id: effectiveAgentId,
|
|
3629
|
+
agent_id_source: effectiveSource,
|
|
3630
|
+
conversation_id: convId,
|
|
3631
|
+
datapack_id: datapackPin ? datapackPin.datapack_id : null,
|
|
3632
|
+
datapack_name: datapackPin ? datapackPin.datapack_name : null,
|
|
3633
|
+
});
|
|
3634
|
+
if (drop.ok) {
|
|
3635
|
+
state = drop.state;
|
|
3636
|
+
watermarkLine = state.last_line_number;
|
|
3637
|
+
dropped++;
|
|
3638
|
+
consecutiveExchangeDrops = state.consecutive_exchange_drops;
|
|
3639
|
+
cursorAdvanced = true;
|
|
3640
|
+
process.stderr.write(
|
|
3641
|
+
`[meko-capture] Dropped un-storable exchange (uuid=${exchange.user_uuid}): ` +
|
|
3642
|
+
`${persistent.reason}. Cursor advanced to line ${watermarkLine}; ` +
|
|
3643
|
+
`capture continues for the rest of this session.\n`,
|
|
3644
|
+
);
|
|
3645
|
+
continue;
|
|
3646
|
+
}
|
|
3647
|
+
// Cursor advance itself failed — fall through and hold, rather than
|
|
3648
|
+
// losing the exchange without a durable record that it was dropped.
|
|
3649
|
+
process.stderr.write(
|
|
3650
|
+
`[meko-capture] Could not record dropped exchange (uuid=${exchange.user_uuid}): ${drop.error}\n`,
|
|
3651
|
+
);
|
|
3652
|
+
} else if (persistent && persistent.scope === "exchange") {
|
|
3653
|
+
process.stderr.write(
|
|
3654
|
+
`[meko-capture] Holding after consecutive edge 403s (uuid=${exchange.user_uuid}): ` +
|
|
3655
|
+
`${persistent.reason}. A second consecutive HTML 403 without an ` +
|
|
3656
|
+
`intervening success may be request-wide; queue is held for retry.\n`,
|
|
3657
|
+
);
|
|
3658
|
+
}
|
|
3659
|
+
|
|
2490
3660
|
failed++;
|
|
2491
|
-
const persistent = classifyPersistentCaptureFailure(err);
|
|
2492
3661
|
const failureClass = persistent
|
|
2493
3662
|
? "persistent"
|
|
2494
3663
|
: captureState.classifyFailure(err);
|
|
2495
3664
|
state.delivery =
|
|
2496
3665
|
failureClass === "transient" ? "retry_wait" : "blocked";
|
|
2497
3666
|
state.failure_class = failureClass;
|
|
2498
|
-
|
|
3667
|
+
// Prefer the classifier reason so quota/entitlement text survives into
|
|
3668
|
+
// state and SessionStart notices instead of an opaque http_403.
|
|
3669
|
+
state.last_error = persistentFailureText(persistent, err.message);
|
|
3670
|
+
state.last_failure_scope = persistent ? persistent.scope : null;
|
|
2499
3671
|
state.attempt_count = (state.attempt_count || 0) + 1;
|
|
2500
3672
|
state.next_retry_at = captureState.nextRetryAt({
|
|
2501
3673
|
failure_class: failureClass,
|
|
2502
3674
|
attempt_count: state.attempt_count,
|
|
2503
3675
|
});
|
|
2504
|
-
state.queued_exchanges = allExchanges.length - captured;
|
|
3676
|
+
state.queued_exchanges = allExchanges.length - captured - dropped;
|
|
2505
3677
|
captureState.writeState(sessionId, state);
|
|
2506
3678
|
process.stderr.write(
|
|
2507
|
-
`[meko-capture] Failed to add message (uuid=${exchange.user_uuid}): ${
|
|
3679
|
+
`[meko-capture] Failed to add message (uuid=${exchange.user_uuid}): ${state.last_error}\n`,
|
|
2508
3680
|
);
|
|
2509
3681
|
break;
|
|
2510
3682
|
}
|
|
2511
3683
|
}
|
|
2512
3684
|
|
|
2513
|
-
|
|
3685
|
+
// Dropped exchanges are resolved, not held: they can never be stored, so
|
|
3686
|
+
// they must not count as backlog or the session reads as permanently behind.
|
|
3687
|
+
const held = allExchanges.length - captured - dropped;
|
|
3688
|
+
if (leaseFailure) {
|
|
3689
|
+
// Progress so far is already checkpointed. Nothing is recorded as a
|
|
3690
|
+
// delivery failure: the server never saw the next exchange.
|
|
3691
|
+
const lost = leaseFailure.failure_class === "ownership";
|
|
3692
|
+
process.stderr.write(
|
|
3693
|
+
lost
|
|
3694
|
+
? `[meko-capture] drain lease lost after ${captured} exchange(s); leaving the rest to its new owner.\n`
|
|
3695
|
+
: `[meko-capture] could not renew the drain lease after ${captured} exchange(s): ${leaseFailure.error}; stopping this batch.\n`,
|
|
3696
|
+
);
|
|
3697
|
+
return makeDrainResult({
|
|
3698
|
+
status: "retry_wait",
|
|
3699
|
+
session_id: sessionId,
|
|
3700
|
+
captured,
|
|
3701
|
+
dropped,
|
|
3702
|
+
queued_remaining: held,
|
|
3703
|
+
failure_class: leaseFailure.failure_class,
|
|
3704
|
+
error: lost
|
|
3705
|
+
? "drain lease lost to another worker"
|
|
3706
|
+
: `drain lease renewal failed: ${leaseFailure.error}`,
|
|
3707
|
+
cursor_advanced: cursorAdvanced,
|
|
3708
|
+
last_line_number: state.last_line_number || 0,
|
|
3709
|
+
lifecycle: state.lifecycle,
|
|
3710
|
+
delivery: state.delivery,
|
|
3711
|
+
});
|
|
3712
|
+
}
|
|
2514
3713
|
if (failed === 0 && allExchanges.length <= maxBatch && pendingFromLine === null) {
|
|
2515
3714
|
watermarkLine = safeLine;
|
|
2516
3715
|
if (watermarkLine >= state.last_line_number) {
|
|
@@ -2527,8 +3726,10 @@ async function drainSession(options) {
|
|
|
2527
3726
|
state.delivery = "idle";
|
|
2528
3727
|
state.queued_exchanges = 0;
|
|
2529
3728
|
}
|
|
3729
|
+
clearResolvedFailure(state);
|
|
2530
3730
|
captureState.writeState(sessionId, state);
|
|
2531
|
-
cursorAdvanced =
|
|
3731
|
+
cursorAdvanced =
|
|
3732
|
+
captured > 0 || dropped > 0 || cursorAdvanced || synthesizedInterrupted;
|
|
2532
3733
|
} else if (failed === 0 && allExchanges.length > maxBatch) {
|
|
2533
3734
|
state.delivery = "pending";
|
|
2534
3735
|
state.queued_exchanges = held;
|
|
@@ -2544,6 +3745,13 @@ async function drainSession(options) {
|
|
|
2544
3745
|
);
|
|
2545
3746
|
}
|
|
2546
3747
|
|
|
3748
|
+
if (dropped > 0) {
|
|
3749
|
+
process.stderr.write(
|
|
3750
|
+
`[meko-capture] ${dropped} exchange(s) could not be stored and were ` +
|
|
3751
|
+
`skipped so the rest of the session still captures.\n`,
|
|
3752
|
+
);
|
|
3753
|
+
}
|
|
3754
|
+
|
|
2547
3755
|
const finalState = captureState.readState(sessionId);
|
|
2548
3756
|
const s = finalState.ok ? finalState.state : state;
|
|
2549
3757
|
let status = "ok";
|
|
@@ -2562,6 +3770,7 @@ async function drainSession(options) {
|
|
|
2562
3770
|
status,
|
|
2563
3771
|
session_id: sessionId,
|
|
2564
3772
|
captured,
|
|
3773
|
+
dropped,
|
|
2565
3774
|
queued_remaining: held,
|
|
2566
3775
|
failure_class: failed > 0 ? s.failure_class || null : null,
|
|
2567
3776
|
error: failed > 0 ? s.last_error || null : null,
|
|
@@ -2587,10 +3796,11 @@ async function main() {
|
|
|
2587
3796
|
"checkpoint",
|
|
2588
3797
|
"drain",
|
|
2589
3798
|
"recover",
|
|
3799
|
+
"move",
|
|
2590
3800
|
];
|
|
2591
3801
|
if (!hookType || !known.includes(hookType)) {
|
|
2592
3802
|
process.stderr.write(
|
|
2593
|
-
"Usage: capture.js <session-start|before-submit-prompt|pre-compact|session-end|checkpoint|drain|recover>\n",
|
|
3803
|
+
"Usage: capture.js <session-start|before-submit-prompt|pre-compact|session-end|checkpoint|drain|recover|move>\n",
|
|
2594
3804
|
);
|
|
2595
3805
|
process.exit(1);
|
|
2596
3806
|
}
|
|
@@ -2603,7 +3813,11 @@ async function main() {
|
|
|
2603
3813
|
process.stderr.write(
|
|
2604
3814
|
`[meko-capture] Failed to parse hook input from stdin: ${err.message}\n`,
|
|
2605
3815
|
);
|
|
2606
|
-
if (hookType === "
|
|
3816
|
+
if (hookType === "move") {
|
|
3817
|
+
process.stdout.write(
|
|
3818
|
+
`${JSON.stringify({ ok: false, error: `invalid stdin JSON: ${err.message}` })}\n`,
|
|
3819
|
+
);
|
|
3820
|
+
} else if (hookType === "drain" || hookType === "recover") {
|
|
2607
3821
|
process.stdout.write(
|
|
2608
3822
|
JSON.stringify(
|
|
2609
3823
|
makeDrainResult({
|
|
@@ -2624,6 +3838,12 @@ async function main() {
|
|
|
2624
3838
|
return;
|
|
2625
3839
|
}
|
|
2626
3840
|
|
|
3841
|
+
if (hookType === "move") {
|
|
3842
|
+
const result = await moveSessionToNewConversation(hookInput);
|
|
3843
|
+
process.stdout.write(JSON.stringify(result) + "\n");
|
|
3844
|
+
return;
|
|
3845
|
+
}
|
|
3846
|
+
|
|
2627
3847
|
if (hookType === "before-submit-prompt") {
|
|
2628
3848
|
handleBeforeSubmitPrompt(hookInput);
|
|
2629
3849
|
return;
|
|
@@ -2641,7 +3861,7 @@ async function main() {
|
|
|
2641
3861
|
const drainOpts = {
|
|
2642
3862
|
session_id: sessionId,
|
|
2643
3863
|
transcript_path: transcriptPath,
|
|
2644
|
-
mode: hookType
|
|
3864
|
+
mode: hookType,
|
|
2645
3865
|
lifecycle: hookInput.lifecycle,
|
|
2646
3866
|
confirmed_dead: Boolean(hookInput.confirmed_dead),
|
|
2647
3867
|
recorded_owner_only: Boolean(hookInput.recorded_owner_only),
|
|
@@ -2688,14 +3908,19 @@ async function main() {
|
|
|
2688
3908
|
|
|
2689
3909
|
module.exports = {
|
|
2690
3910
|
deriveAgentId,
|
|
3911
|
+
deriveLegacyAgentId,
|
|
2691
3912
|
drainSession,
|
|
2692
3913
|
makeDrainResult,
|
|
2693
3914
|
boundaryAfterExchange,
|
|
2694
3915
|
realUserTurnLines,
|
|
2695
3916
|
INTERRUPTED_ASSISTANT_OUTPUT,
|
|
2696
3917
|
buildExchanges,
|
|
3918
|
+
buildInterruptedExchange,
|
|
2697
3919
|
extractExchanges,
|
|
2698
3920
|
addMessage,
|
|
3921
|
+
isMissingConversationError,
|
|
3922
|
+
recoverMissingConversation,
|
|
3923
|
+
moveSessionToNewConversation,
|
|
2699
3924
|
};
|
|
2700
3925
|
|
|
2701
3926
|
if (require.main === module) {
|