@yugabytedb/meko-mcp 2.1.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +233 -40
  2. package/assets/hooks-handlers/codex-session-end.sh +4 -69
  3. package/assets/hooks-handlers/cursor-session-start.sh +19 -1
  4. package/assets/hooks-handlers/lib/capture-cleanup-cli.js +1473 -0
  5. package/assets/hooks-handlers/lib/capture-notices.js +438 -0
  6. package/assets/hooks-handlers/lib/capture-state.js +2173 -0
  7. package/assets/hooks-handlers/lib/capture-status-cli.js +86 -0
  8. package/assets/hooks-handlers/lib/capture.js +2782 -403
  9. package/assets/hooks-handlers/lib/checkpoint-timer.js +485 -81
  10. package/assets/hooks-handlers/lib/journal-bridge.js +218 -0
  11. package/assets/hooks-handlers/lib/kiro-handler.js +760 -0
  12. package/assets/hooks-handlers/lib/opencode-handler.js +620 -0
  13. package/assets/hooks-handlers/lib/opencode-normalizer.js +145 -0
  14. package/assets/hooks-handlers/lib/terminal-handoff.js +427 -0
  15. package/assets/hooks-handlers/session-end.sh +3 -34
  16. package/assets/opencode-plugin/meko.js +213 -0
  17. package/assets/scripts/meko-statusline.sh +123 -16
  18. package/assets/skills/meko-mcp-tools/SKILL.md +116 -126
  19. package/assets/skills/meko-mcp-tools/references/_sections.md +3 -3
  20. package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +25 -18
  21. package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +99 -24
  22. package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +1 -1
  23. package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +53 -6
  24. package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +25 -79
  25. package/assets/skills/meko-mcp-tools/references/tools-overview.md +37 -26
  26. package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +14 -8
  27. package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +66 -1
  28. package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +7 -40
  29. package/assets/skills/meko-mcp-tools-desktop/SKILL.md +56 -20
  30. package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +1 -1
  31. package/assets/skills/meko-mcp-tools-desktop/references/_sections.md +1 -1
  32. package/assets/skills/meko-mcp-tools-desktop/references/tools-agent-id-conventions.md +15 -8
  33. package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +99 -36
  34. package/assets/skills/meko-mcp-tools-desktop/references/tools-known-limitations.md +22 -5
  35. package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +18 -14
  36. package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +39 -28
  37. package/assets/skills/meko-mcp-tools-desktop/references/tools-rag-workflow.md +14 -8
  38. package/assets/skills/meko-mcp-tools-desktop/references/tools-troubleshooting.md +67 -1
  39. package/assets/skills/meko-select-datapack-desktop/SKILL.md +4 -4
  40. package/assets/skills/meko-select-datapack-desktop/references/cookbook.md +8 -8
  41. package/assets/skills/meko-select-datapack-desktop/references/role-display-future.md +7 -5
  42. package/assets/skills/meko-select-datapack-desktop/references/troubleshooting.md +4 -4
  43. package/bin/create-meko-setup.mjs +113 -12
  44. package/lib/capture-cleanup.mjs +63 -0
  45. package/lib/capture-status.mjs +67 -0
  46. package/lib/clients/aliases.mjs +5 -0
  47. package/lib/clients/claude-code.mjs +1 -0
  48. package/lib/clients/claude-desktop.mjs +120 -5
  49. package/lib/clients/codex.mjs +85 -37
  50. package/lib/clients/common.mjs +7 -0
  51. package/lib/clients/index.mjs +4 -0
  52. package/lib/clients/kiro.mjs +705 -0
  53. package/lib/clients/opencode.mjs +532 -0
  54. package/lib/config.mjs +158 -1
  55. package/lib/installer.mjs +18 -1
  56. package/lib/migrate/adapters/claude-code.mjs +11 -2
  57. package/lib/migrate/adapters/codex.mjs +497 -0
  58. package/lib/migrate/adapters/memory-md.mjs +27 -8
  59. package/lib/migrate/adapters/opencode.mjs +148 -0
  60. package/lib/migrate/claude-parser.mjs +153 -9
  61. package/lib/migrate/id.mjs +217 -10
  62. package/lib/migrate/mcp-client.mjs +25 -1
  63. package/lib/migrate/opencode-normalizer.mjs +132 -0
  64. package/lib/migrate/orchestrator.mjs +299 -56
  65. package/lib/migrate/progress.mjs +239 -5
  66. package/lib/migrate/registry.mjs +72 -0
  67. package/lib/migrate.mjs +100 -10
  68. package/lib/prompt.mjs +16 -1
  69. package/package.json +4 -4
  70. package/scripts/bundle-assets.mjs +4 -0
package/lib/installer.mjs CHANGED
@@ -67,7 +67,7 @@ export async function install(options) {
67
67
 
68
68
  info("Checking prerequisites...");
69
69
  try {
70
- await adapter.detectPrerequisites({ verbose, log: LOG, scope, projectRoot });
70
+ await adapter.detectPrerequisites({ verbose, log: LOG, scope, projectRoot, dryRun });
71
71
  } catch (err) {
72
72
  error(err.message);
73
73
  throw err;
@@ -134,6 +134,7 @@ export async function install(options) {
134
134
 
135
135
  try {
136
136
  adapter.setHookEnvironment({
137
+ name,
137
138
  url,
138
139
  apiKey,
139
140
  scope,
@@ -152,6 +153,22 @@ export async function install(options) {
152
153
  }
153
154
  currentStep++;
154
155
 
156
+ // Runs even when MCP registration was skipped so a rotated key still
157
+ // reaches the launchd env / shell rc. See yugabyte/meko-mcp-server#337.
158
+ if (typeof adapter.syncCloudAuthEnvironment === "function") {
159
+ try {
160
+ await adapter.syncCloudAuthEnvironment({
161
+ apiKey,
162
+ dryRun,
163
+ verbose,
164
+ log: LOG,
165
+ yes,
166
+ });
167
+ } catch (err) {
168
+ warn(`Cloud auth environment sync failed (non-fatal): ${err.message}`);
169
+ }
170
+ }
171
+
155
172
  if (!skipSkills) {
156
173
  try {
157
174
  const result = adapter.installSkills({
@@ -10,7 +10,7 @@
10
10
  import fs from "node:fs";
11
11
  import path from "node:path";
12
12
  import os from "node:os";
13
- import { agentIdForClaude, slugifyCwd } from "../id.mjs";
13
+ import { agentIdForClaude, deriveLegacyAgentId, slugifyCwd } from "../id.mjs";
14
14
  import { parseSessionStream, peekSessionMetadata } from "../claude-parser.mjs";
15
15
 
16
16
  function homeDir() {
@@ -46,6 +46,7 @@ export function userClaudeMdPath(opts = {}) {
46
46
  * @property {string} slug
47
47
  * @property {string} cwd Derived from the first session in the slug dir.
48
48
  * @property {string} agentId
49
+ * @property {string} legacyAgentId Pre-MEKO-590 resume-state alias.
49
50
  * @property {number} size
50
51
  * @property {number} mtimeMs
51
52
  * @property {boolean} likelyInProgress
@@ -96,12 +97,20 @@ export async function listSessions(opts) {
96
97
  let stat;
97
98
  try { stat = fs.statSync(full); } catch { continue; }
98
99
  const sessionUuid = e.name.replace(/\.jsonl$/, "");
100
+ const legacyAgentId = deriveLegacyAgentId({
101
+ client: "claude_code",
102
+ cwd: slugCwd,
103
+ envOverride: process.env.MEKO_AGENT_ID,
104
+ });
99
105
  out.push({
100
106
  sessionUuid,
101
107
  path: full,
102
108
  slug,
103
109
  cwd: slugCwd,
104
- agentId: agentIdForClaude(slugCwd),
110
+ // Historical: `slugCwd` is a cwd recorded in an old transcript (or
111
+ // reverse-slugged from the project dir name) and may no longer exist.
112
+ agentId: agentIdForClaude(slugCwd, { historical: true }),
113
+ legacyAgentId,
105
114
  size: stat.size,
106
115
  mtimeMs: stat.mtimeMs,
107
116
  likelyInProgress: stat.mtimeMs > Date.now() - 60_000,
@@ -0,0 +1,497 @@
1
+ /**
2
+ * adapters/codex.mjs — Enumerate + parse Codex CLI/Desktop session rollouts.
3
+ *
4
+ * Codex writes one JSONL "rollout" file per session under:
5
+ * <CODEX_HOME|~/.codex>/sessions/<YYYY>/<MM>/<DD>/rollout-<ts>-<uuid>.jsonl
6
+ *
7
+ * Each line is `{timestamp, type, payload}`. The shapes we consume:
8
+ * - type "session_meta": payload has `session_id` and `cwd` (the project
9
+ * root the session ran in — the source of truth for agent_id derivation).
10
+ * - type "event_msg", payload.type "user_message": the AUTHORITATIVE marker
11
+ * for a real user prompt. Codex also writes `response_item`/`message`
12
+ * role=user records for injected context (`<recommended_plugins>`,
13
+ * AGENTS.md, the Meko SessionStart block); those have NO mirrored
14
+ * user_message event, so we use the event stream to tell a real prompt
15
+ * from injected context.
16
+ * - type "response_item", payload.type "message", role "assistant": one
17
+ * slice of the assistant's reply. A single turn emits several — phase
18
+ * "commentary" updates then phase "final_answer" — so we accumulate ALL
19
+ * of them into one assistant side and only close the turn at the
20
+ * completion boundary (next user prompt or `event_msg` task_complete).
21
+ * - type "response_item", payload.type "reasoning" /
22
+ * "custom_tool_call" / "custom_tool_call_output": reasoning + tool
23
+ * activity, folded into the turn's `reasoning` (parity with the
24
+ * Claude Code / Cursor adapters).
25
+ *
26
+ * Completion-boundary discriminator (kept consistent with the live parser in
27
+ * PR #234): assistant `phase: "final_answer"` and/or the `event_msg`
28
+ * `payload.type: "task_complete"` record close a turn.
29
+ *
30
+ * Unknown line/block shapes are skipped, never fatal — Codex's on-disk
31
+ * schema drifts across CLI versions.
32
+ */
33
+
34
+ import fs from "node:fs";
35
+ import path from "node:path";
36
+ import os from "node:os";
37
+ import { agentIdForCodex, deriveLegacyAgentId } from "../id.mjs";
38
+
39
+ // Cap for the first-record read. Codex embeds `base_instructions` in the
40
+ // session_meta line, which routinely exceeds 16 KiB; read generously so the
41
+ // full first record parses. 4 MiB is far above any real header while still
42
+ // bounding a pathological file.
43
+ const HEADER_READ_CAP = 4 * 1024 * 1024;
44
+
45
+ function homeDir() {
46
+ return process.env.HOME || os.homedir();
47
+ }
48
+
49
+ /**
50
+ * The `.codex` home. Honors `CODEX_HOME` (production sets it to relocate all
51
+ * Codex state); otherwise `<home>/.codex`. The injectable `opts.home` override
52
+ * is preserved for tests and only applies to the default location.
53
+ *
54
+ * @param {object} [opts]
55
+ * @param {string} [opts.home]
56
+ * @returns {string}
57
+ */
58
+ export function codexHome(opts = {}) {
59
+ const envHome = (process.env.CODEX_HOME || "").trim();
60
+ if (envHome) return envHome;
61
+ return path.join(opts.home ?? homeDir(), ".codex");
62
+ }
63
+
64
+ /**
65
+ * The sessions root Codex writes rollout files under.
66
+ *
67
+ * @param {object} [opts]
68
+ * @param {string} [opts.home]
69
+ * @returns {string}
70
+ */
71
+ export function sessionsRoot(opts = {}) {
72
+ return path.join(codexHome(opts), "sessions");
73
+ }
74
+
75
+ /**
76
+ * @typedef {Object} CodexSessionRef
77
+ * @property {string} sessionUuid Rollout filename basename (matches
78
+ * capture.js::extractSessionId, so migrator + live capture dedup to the
79
+ * same conversation container).
80
+ * @property {string} path Absolute path to the rollout .jsonl.
81
+ * @property {string} cwd Project root from session_meta.
82
+ * @property {string} agentId `codex:<repo-basename>`.
83
+ * @property {string} legacyAgentId Pre-MEKO-590 resume-state alias.
84
+ * @property {"codex"} source
85
+ * @property {number} mtimeMs
86
+ * @property {boolean} likelyInProgress
87
+ */
88
+
89
+ /**
90
+ * Recursively collect rollout-*.jsonl paths under the date-partitioned tree.
91
+ *
92
+ * @param {string} dir
93
+ * @returns {string[]}
94
+ */
95
+ function collectRolloutFiles(dir) {
96
+ let entries;
97
+ try {
98
+ entries = fs.readdirSync(dir, { withFileTypes: true });
99
+ } catch {
100
+ return [];
101
+ }
102
+ const out = [];
103
+ for (const e of entries) {
104
+ const full = path.join(dir, e.name);
105
+ if (e.isDirectory()) {
106
+ out.push(...collectRolloutFiles(full));
107
+ } else if (e.isFile() && e.name.startsWith("rollout-") && e.name.endsWith(".jsonl")) {
108
+ out.push(full);
109
+ }
110
+ }
111
+ return out;
112
+ }
113
+
114
+ /**
115
+ * Read the COMPLETE first JSONL record (through the first newline) and, if it
116
+ * is the `session_meta` header, harvest `session_id` + `cwd` + `timestamp`.
117
+ * Reads in chunks up to HEADER_READ_CAP so a large `base_instructions` block
118
+ * (>16 KiB, common in current rollouts) still parses — a bounded prefix would
119
+ * truncate the line, fail to parse, and silently drop the session.
120
+ *
121
+ * @param {string} filePath
122
+ * @returns {{ sessionId: string|null, cwd: string|null, startedAt: string|null }}
123
+ */
124
+ export function peekSessionMeta(filePath) {
125
+ const empty = { sessionId: null, cwd: null, startedAt: null };
126
+ let fd;
127
+ try {
128
+ fd = fs.openSync(filePath, "r");
129
+ const chunkSize = 65536;
130
+ const chunk = Buffer.alloc(chunkSize);
131
+ let acc = "";
132
+ let offset = 0;
133
+ let newlineIdx = -1;
134
+ while (offset < HEADER_READ_CAP) {
135
+ const bytesRead = fs.readSync(fd, chunk, 0, chunkSize, offset);
136
+ if (bytesRead === 0) break;
137
+ acc += chunk.toString("utf-8", 0, bytesRead);
138
+ offset += bytesRead;
139
+ newlineIdx = acc.indexOf("\n");
140
+ if (newlineIdx !== -1) break;
141
+ }
142
+ const firstLine = (newlineIdx === -1 ? acc : acc.slice(0, newlineIdx)).trim();
143
+ if (!firstLine) return empty;
144
+ const obj = JSON.parse(firstLine);
145
+ if (obj && obj.type === "session_meta" && obj.payload && typeof obj.payload === "object") {
146
+ const p = obj.payload;
147
+ const str = (v) => (typeof v === "string" ? v : null);
148
+ return {
149
+ sessionId: str(p.session_id) || str(p.id),
150
+ cwd: str(p.cwd),
151
+ startedAt: str(p.timestamp) || str(obj.timestamp),
152
+ };
153
+ }
154
+ return empty;
155
+ } catch {
156
+ return empty;
157
+ } finally {
158
+ if (fd !== undefined) {
159
+ try { fs.closeSync(fd); } catch { /* FD already gone */ }
160
+ }
161
+ }
162
+ }
163
+
164
+ /**
165
+ * The session identifier used for conversation dedup. Standardized on the
166
+ * rollout FILENAME BASENAME so it matches capture.js::extractSessionId
167
+ * (`path.basename(transcriptPath, ".jsonl")`) — live hook capture and the
168
+ * migrator then create/reconnect the SAME conversation container instead of
169
+ * two divergent ones for the same session.
170
+ *
171
+ * @param {string} rolloutPath
172
+ * @returns {string}
173
+ */
174
+ export function sessionUuidForRollout(rolloutPath) {
175
+ return path.basename(rolloutPath, ".jsonl");
176
+ }
177
+
178
+ /**
179
+ * @param {object} opts
180
+ * @param {"user"|"project"} opts.scope
181
+ * @param {string} [opts.projectCwd]
182
+ * @param {string} [opts.home]
183
+ * @returns {CodexSessionRef[]}
184
+ */
185
+ export function listSessions(opts) {
186
+ const root = sessionsRoot(opts);
187
+ if (!fs.existsSync(root)) return [];
188
+
189
+ const wantedCwd = opts.scope === "project" ? opts.projectCwd : null;
190
+
191
+ const out = [];
192
+ for (const full of collectRolloutFiles(root)) {
193
+ const meta = peekSessionMeta(full);
194
+ if (!meta.cwd) continue;
195
+ if (wantedCwd && meta.cwd !== wantedCwd) continue;
196
+ let stat;
197
+ try {
198
+ stat = fs.statSync(full);
199
+ } catch {
200
+ continue;
201
+ }
202
+ out.push({
203
+ sessionUuid: sessionUuidForRollout(full),
204
+ path: full,
205
+ cwd: meta.cwd,
206
+ // Historical: cwd recorded in the rollout, may be gone from disk.
207
+ agentId: agentIdForCodex(meta.cwd, { historical: true }),
208
+ legacyAgentId: deriveLegacyAgentId({
209
+ client: "codex",
210
+ cwd: meta.cwd,
211
+ envOverride: process.env.MEKO_AGENT_ID,
212
+ }),
213
+ source: "codex",
214
+ mtimeMs: stat.mtimeMs,
215
+ likelyInProgress: stat.mtimeMs > Date.now() - 60_000,
216
+ });
217
+ }
218
+ return out;
219
+ }
220
+
221
+ /**
222
+ * Parse a rollout file: pair real user prompts with the full assistant reply.
223
+ *
224
+ * @param {string} sessionPath
225
+ * @returns {{
226
+ * sessionUuid: string,
227
+ * firstUserText: string,
228
+ * cwd: string|null,
229
+ * startedAt: string|null,
230
+ * exchanges: Array<{user_uuid: string, input: string, output: string, reasoning: string, timestamp: string}>,
231
+ * partial: boolean,
232
+ * }}
233
+ */
234
+ export function parseSession(sessionPath) {
235
+ const { records, realPrompts, hasUserMessageEvents } = collectRecords(sessionPath);
236
+
237
+ let cwd = null;
238
+ let startedAt = null;
239
+ let firstUserText = "";
240
+
241
+ // Turn assembly. A real user prompt opens a turn; every assistant slice and
242
+ // reasoning/tool record until the next real prompt (or a task_complete)
243
+ // belongs to it. `turnIdx` — the positional id fallback for id-less Codex
244
+ // user records — increments ONLY when a turn is emitted, so a turn that is
245
+ // held on one run gets the SAME id when it is finally emitted on a later run
246
+ // (all preceding emitted turns are identical), keeping seed dedup stable.
247
+ const exchanges = [];
248
+ let turnIdx = 0;
249
+ // current: { input, uuid, timestamp, lastTimestamp, outputs, reasoning,
250
+ // hasAssistant, sawPhase, sawFinalAnswer, boundaryClosed }
251
+ let current = null;
252
+
253
+ // A turn is safe to emit once it has reached its completion boundary — the
254
+ // SAME boundary the live parser in PR #234 uses:
255
+ // - a `task_complete` event closed it (boundaryClosed), OR
256
+ // - an assistant slice with phase `final_answer` arrived, OR
257
+ // - it is the legacy/simple shape: assistant content but no phase info at
258
+ // all (older rollouts don't tag phases) — complete as soon as any
259
+ // assistant message exists, like non-Codex turns.
260
+ // A turn still streaming commentary with no boundary, or with no assistant
261
+ // reply yet, is NOT complete and must be held (see the EOF flush below) so
262
+ // the whole turn is captured once on a later run rather than sent early and
263
+ // then permanently skipped by lastUuid resume when its final answer arrives.
264
+ const isComplete = (t) =>
265
+ t.boundaryClosed || t.sawFinalAnswer || (t.hasAssistant && !t.sawPhase);
266
+
267
+ // Emit `current` as an exchange. `allowIncomplete` is true only when a new
268
+ // real prompt supersedes it (the turn is definitively over — no more content
269
+ // will ever be appended to it) or a task_complete closed it; at EOF we pass
270
+ // false so a still-in-progress trailing turn is HELD, not emitted.
271
+ const flush = (allowIncomplete) => {
272
+ if (!current) return;
273
+ const t = current;
274
+ current = null;
275
+ if (!allowIncomplete && !isComplete(t)) return; // hold: do not emit or advance turnIdx
276
+ const output = t.outputs.join("\n\n");
277
+ const reasoning = t.reasoning.join("\n---\n");
278
+ if (t.input || output) {
279
+ exchanges.push({
280
+ user_uuid: t.uuid || `codex-${turnIdx}`,
281
+ input: t.input,
282
+ output,
283
+ reasoning,
284
+ timestamp: t.lastTimestamp || t.timestamp || "",
285
+ });
286
+ turnIdx++;
287
+ }
288
+ };
289
+
290
+ for (const rec of records) {
291
+ if (rec.kind === "meta") {
292
+ if (!cwd) cwd = rec.cwd;
293
+ if (!startedAt) startedAt = rec.timestamp;
294
+ continue;
295
+ }
296
+ if (!startedAt && rec.timestamp) startedAt = rec.timestamp;
297
+
298
+ if (rec.kind === "user") {
299
+ // Only real prompts open a turn. When user_message events exist they are
300
+ // authoritative: a role=user response_item whose text isn't among them is
301
+ // injected context (plugins/AGENTS.md/Meko block) and is dropped.
302
+ if (hasUserMessageEvents && !realPrompts.has(rec.text)) continue;
303
+ // A new real prompt means the previous turn is definitively over — emit
304
+ // it even if it never saw an explicit boundary (it won't get more).
305
+ flush(true);
306
+ if (!firstUserText && rec.text) firstUserText = rec.text.slice(0, 240);
307
+ current = {
308
+ input: rec.text,
309
+ uuid: rec.uuid,
310
+ timestamp: rec.timestamp,
311
+ lastTimestamp: rec.timestamp,
312
+ outputs: [],
313
+ reasoning: [],
314
+ hasAssistant: false,
315
+ sawPhase: false,
316
+ sawFinalAnswer: false,
317
+ boundaryClosed: false,
318
+ };
319
+ } else if (rec.kind === "assistant") {
320
+ if (!current) continue; // assistant with no preceding real prompt — skip
321
+ current.hasAssistant = true;
322
+ if (rec.phase) current.sawPhase = true;
323
+ if (rec.phase === "final_answer") current.sawFinalAnswer = true;
324
+ if (rec.text) current.outputs.push(rec.text);
325
+ if (rec.timestamp) current.lastTimestamp = rec.timestamp;
326
+ } else if (rec.kind === "reasoning" || rec.kind === "tool_call" || rec.kind === "tool_result") {
327
+ if (!current) continue;
328
+ if (rec.text) current.reasoning.push(rec.text);
329
+ if (rec.timestamp) current.lastTimestamp = rec.timestamp;
330
+ } else if (rec.kind === "task_complete") {
331
+ // Completion boundary: the current turn is finished — emit it.
332
+ if (current) {
333
+ current.boundaryClosed = true;
334
+ if (rec.timestamp) current.lastTimestamp = rec.timestamp;
335
+ }
336
+ flush(true);
337
+ }
338
+ }
339
+ // EOF: hold a still-incomplete trailing turn (no boundary / no assistant yet)
340
+ // so it is captured whole on a later run once its completion is appended.
341
+ flush(false);
342
+
343
+ return {
344
+ sessionUuid: sessionUuidForRollout(sessionPath),
345
+ firstUserText,
346
+ cwd,
347
+ startedAt,
348
+ exchanges,
349
+ // Codex keeps a session's rollout open across turns and writes a
350
+ // `task_complete` after EACH one, so there is no reliable end-of-session
351
+ // marker: an interactive session can sit idle for minutes (well past any
352
+ // mtime window) and then append another prompt. Finalizing from a quiet
353
+ // period or a trailing task_complete would mark the session done and make
354
+ // the orchestrator skip it forever, dropping every later turn. So Codex
355
+ // rollouts stay rescan-capable (`partial: true`) unconditionally; the
356
+ // orchestrator re-reads them each run, lastUuid resume skips already-sent
357
+ // turns, and seed dedup (<conv>:<user_uuid>) makes the re-scan a no-op when
358
+ // nothing new was appended. Combined with the incomplete-trailing-turn hold
359
+ // above, this guarantees any appended turn is eventually migrated exactly
360
+ // once.
361
+ partial: true,
362
+ };
363
+ }
364
+
365
+ /**
366
+ * Read + classify every JSONL record we care about, and collect the set of
367
+ * authoritative real-prompt texts from `event_msg`/`user_message`.
368
+ */
369
+ function collectRecords(filePath) {
370
+ let raw;
371
+ try {
372
+ raw = fs.readFileSync(filePath, "utf8");
373
+ } catch {
374
+ return { records: [], realPrompts: new Set(), hasUserMessageEvents: false };
375
+ }
376
+ const records = [];
377
+ const realPrompts = new Set();
378
+ let hasUserMessageEvents = false;
379
+
380
+ for (const line of raw.split("\n")) {
381
+ const trimmed = line.trim();
382
+ if (!trimmed) continue;
383
+ let obj;
384
+ try {
385
+ obj = JSON.parse(trimmed);
386
+ } catch {
387
+ continue;
388
+ }
389
+ const payload = obj.payload;
390
+ if (!payload || typeof payload !== "object") continue;
391
+ const ts = obj.timestamp || null;
392
+
393
+ if (obj.type === "session_meta") {
394
+ records.push({
395
+ kind: "meta",
396
+ cwd: typeof payload.cwd === "string" ? payload.cwd : null,
397
+ timestamp: ts || (typeof payload.timestamp === "string" ? payload.timestamp : null),
398
+ });
399
+ continue;
400
+ }
401
+
402
+ if (obj.type === "event_msg") {
403
+ if (payload.type === "user_message" && typeof payload.message === "string") {
404
+ hasUserMessageEvents = true;
405
+ realPrompts.add(payload.message);
406
+ } else if (payload.type === "task_complete") {
407
+ records.push({ kind: "task_complete", timestamp: ts });
408
+ }
409
+ continue;
410
+ }
411
+
412
+ if (obj.type !== "response_item") continue;
413
+
414
+ if (payload.type === "message") {
415
+ const role = payload.role;
416
+ if (role === "user") {
417
+ records.push({
418
+ kind: "user", uuid: payload.id || null, timestamp: ts,
419
+ text: extractTextBlocks(payload.content),
420
+ });
421
+ } else if (role === "assistant") {
422
+ records.push({
423
+ kind: "assistant",
424
+ timestamp: ts,
425
+ phase: payload.phase || null,
426
+ text: extractTextBlocks(payload.content),
427
+ });
428
+ }
429
+ // developer / system messages are Codex-internal — dropped.
430
+ continue;
431
+ }
432
+
433
+ if (payload.type === "reasoning") {
434
+ const text = extractReasoningSummary(payload.summary);
435
+ if (text) records.push({ kind: "reasoning", timestamp: ts, text: `THINKING:\n${text}` });
436
+ continue;
437
+ }
438
+
439
+ if (payload.type === "custom_tool_call") {
440
+ const name = typeof payload.name === "string" ? payload.name : "unknown";
441
+ const input = typeof payload.input === "string"
442
+ ? payload.input.slice(0, 500)
443
+ : (payload.input ? JSON.stringify(payload.input).slice(0, 500) : "");
444
+ records.push({ kind: "tool_call", timestamp: ts, text: `TOOL CALL: ${name}(${input})` });
445
+ continue;
446
+ }
447
+
448
+ if (payload.type === "custom_tool_call_output") {
449
+ const output = typeof payload.output === "string"
450
+ ? payload.output
451
+ : (payload.output != null ? JSON.stringify(payload.output) : "");
452
+ if (output) {
453
+ records.push({ kind: "tool_result", timestamp: ts, text: `TOOL RESULT:\n${output.slice(0, 3000)}` });
454
+ }
455
+ continue;
456
+ }
457
+ }
458
+
459
+ return { records, realPrompts, hasUserMessageEvents };
460
+ }
461
+
462
+ /**
463
+ * Codex message content blocks are `{type:"input_text"|"output_text"|"text",
464
+ * text}`. Concatenate the text; ignore non-text blocks.
465
+ */
466
+ function extractTextBlocks(content) {
467
+ if (typeof content === "string") return content;
468
+ if (!Array.isArray(content)) return "";
469
+ const parts = [];
470
+ for (const b of content) {
471
+ if (!b) continue;
472
+ if (typeof b === "string") { parts.push(b); continue; }
473
+ if (
474
+ (b.type === "input_text" || b.type === "output_text" || b.type === "text")
475
+ && typeof b.text === "string"
476
+ ) {
477
+ parts.push(b.text);
478
+ }
479
+ }
480
+ return parts.join("\n");
481
+ }
482
+
483
+ /**
484
+ * Codex reasoning items carry a `summary` array of `{type:"summary_text",
485
+ * text}` blocks. Concatenate their text.
486
+ */
487
+ function extractReasoningSummary(summary) {
488
+ if (typeof summary === "string") return summary;
489
+ if (!Array.isArray(summary)) return "";
490
+ const parts = [];
491
+ for (const b of summary) {
492
+ if (!b) continue;
493
+ if (typeof b === "string") { parts.push(b); continue; }
494
+ if (typeof b.text === "string") parts.push(b.text);
495
+ }
496
+ return parts.join("\n");
497
+ }
@@ -14,6 +14,7 @@ import os from "node:os";
14
14
  import {
15
15
  CLAUDE_CODE_USER_AGENT_ID,
16
16
  agentIdForClaude,
17
+ deriveLegacyAgentId,
17
18
  memoryHash,
18
19
  slugifyCwd,
19
20
  } from "../id.mjs";
@@ -26,6 +27,7 @@ function homeDir() {
26
27
  /**
27
28
  * @typedef {Object} MemoryItem
28
29
  * @property {string} agentId
30
+ * @property {string} [legacyAgentId] Pre-MEKO-590 state alias.
29
31
  * @property {string} text
30
32
  * @property {string} hash
31
33
  * @property {{source: string, scope: "user"|"project", sourcePath: string, heading?: string, name?: string, description?: string, type?: string}} metadata
@@ -62,22 +64,35 @@ export async function enumerateMemoryItems(opts) {
62
64
  if (!stat.isDirectory()) continue;
63
65
  const cwd = await cwdForSlugDir(dir, slug);
64
66
  if (!cwd) continue;
65
- const agentId = agentIdForClaude(cwd);
67
+ // Historical: recorded/reverse-slugged cwd, may be gone from disk.
68
+ const agentId = agentIdForClaude(cwd, { historical: true });
69
+ const legacyAgentId = deriveLegacyAgentId({
70
+ client: "claude_code",
71
+ cwd,
72
+ envOverride: process.env.MEKO_AGENT_ID,
73
+ });
66
74
  const projMd = path.join(cwd, ".claude", "CLAUDE.md");
67
- items.push(...readClaudeMd(projMd, agentId, "project", cwd));
75
+ items.push(...readClaudeMd(projMd, agentId, "project", cwd, legacyAgentId));
68
76
  const memDir = path.join(dir, "memory");
69
- items.push(...readMemoryDir(memDir, agentId, "project", cwd));
77
+ items.push(...readMemoryDir(memDir, agentId, "project", cwd, legacyAgentId));
70
78
  }
71
79
  }
72
80
  } else {
73
81
  const cwd = opts.projectCwd;
74
82
  if (!cwd) return [];
75
- const agentId = agentIdForClaude(cwd);
83
+ // Keep project CLAUDE.md and indexed memories in the same bucket as the
84
+ // historical session adapter, including when the cwd is no longer a repo.
85
+ const agentId = agentIdForClaude(cwd, { historical: true });
86
+ const legacyAgentId = deriveLegacyAgentId({
87
+ client: "claude_code",
88
+ cwd,
89
+ envOverride: process.env.MEKO_AGENT_ID,
90
+ });
76
91
  const projMd = path.join(cwd, ".claude", "CLAUDE.md");
77
- items.push(...readClaudeMd(projMd, agentId, "project", cwd));
92
+ items.push(...readClaudeMd(projMd, agentId, "project", cwd, legacyAgentId));
78
93
  const slug = slugifyCwd(cwd);
79
94
  const memDir = path.join(home, ".claude", "projects", slug, "memory");
80
- items.push(...readMemoryDir(memDir, agentId, "project", cwd));
95
+ items.push(...readMemoryDir(memDir, agentId, "project", cwd, legacyAgentId));
81
96
  }
82
97
 
83
98
  return items;
@@ -94,9 +109,10 @@ export async function enumerateMemoryItems(opts) {
94
109
  * so downstream consumers can correlate even after the agent_id is
95
110
  * sanitized down to a basename. Null for user-scope items (which use the
96
111
  * `meko_agent` common bucket).
112
+ * @param {string|null} [legacyAgentId] Pre-MEKO-590 compatibility alias.
97
113
  * @returns {MemoryItem[]}
98
114
  */
99
- export function readClaudeMd(filePath, agentId, scope, cwd = null) {
115
+ export function readClaudeMd(filePath, agentId, scope, cwd = null, legacyAgentId = null) {
100
116
  let raw;
101
117
  try {
102
118
  raw = fs.readFileSync(filePath, "utf8");
@@ -112,6 +128,7 @@ export function readClaudeMd(filePath, agentId, scope, cwd = null) {
112
128
  const name = heading || path.basename(filePath);
113
129
  return {
114
130
  agentId,
131
+ ...(legacyAgentId && legacyAgentId !== agentId ? { legacyAgentId } : {}),
115
132
  text,
116
133
  hash: memoryHash(text, name),
117
134
  name,
@@ -166,9 +183,10 @@ export function splitTopLevelSections(md) {
166
183
  * @param {string} agentId
167
184
  * @param {"user"|"project"} scope
168
185
  * @param {string|null} [cwd] - Absolute project path; attached to metadata.
186
+ * @param {string|null} [legacyAgentId] Pre-MEKO-590 compatibility alias.
169
187
  * @returns {MemoryItem[]}
170
188
  */
171
- export function readMemoryDir(memDir, agentId, scope, cwd = null) {
189
+ export function readMemoryDir(memDir, agentId, scope, cwd = null, legacyAgentId = null) {
172
190
  let entries;
173
191
  try {
174
192
  entries = fs.readdirSync(memDir);
@@ -192,6 +210,7 @@ export function readMemoryDir(memDir, agentId, scope, cwd = null) {
192
210
  const name = frontmatter.name || entry.replace(/\.md$/, "");
193
211
  out.push({
194
212
  agentId,
213
+ ...(legacyAgentId && legacyAgentId !== agentId ? { legacyAgentId } : {}),
195
214
  text,
196
215
  hash: memoryHash(text, name),
197
216
  name,