@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
@@ -0,0 +1,148 @@
1
+ import { execFileSync, spawnSync } from "node:child_process";
2
+ import path from "node:path";
3
+ import { agentIdForOpenCode } from "../id.mjs";
4
+ import { normalizeOpenCodeSession } from "../opencode-normalizer.mjs";
5
+ function defaultRunExport(sessionId, opts = {}) {
6
+ const runner = opts.runExport ?? defaultRunExportCommand;
7
+ return runner(sessionId, opts);
8
+ }
9
+ function defaultRunExportCommand(sessionId, opts = {}) {
10
+ const bin = opts.opencodeBin ?? probeOpenCode()?.path ?? "opencode";
11
+ const result = spawnSync(bin, ["export", sessionId], {
12
+ encoding: "utf8",
13
+ maxBuffer: 64 * 1024 * 1024,
14
+ timeout: 10_000,
15
+ });
16
+ if (result.error) throw result.error;
17
+ if (result.status !== 0) {
18
+ throw new Error(
19
+ `opencode export ${sessionId} failed (${result.status}): ${(result.stderr || "").trim()}`,
20
+ );
21
+ }
22
+ return parseJsonStdout(result.stdout);
23
+ }
24
+ function defaultRunList(opts = {}) {
25
+ const runner = opts.runList ?? defaultRunListCommand;
26
+ return runner(opts);
27
+ }
28
+ function defaultRunListCommand(opts = {}) {
29
+ // OpenCode 1.18.x exposes roots only here; child sessions are not
30
+ // enumerable through the supported CLI.
31
+ const bin = opts.opencodeBin ?? probeOpenCode()?.path ?? "opencode";
32
+ const result = spawnSync(bin, ["session", "list", "--format", "json"], {
33
+ encoding: "utf8",
34
+ maxBuffer: 16 * 1024 * 1024,
35
+ timeout: 10_000,
36
+ });
37
+ if (result.error) throw result.error;
38
+ if (result.status !== 0) {
39
+ throw new Error(
40
+ `opencode session list failed (${result.status}): ${(result.stderr || "").trim()}`,
41
+ );
42
+ }
43
+ return parseJsonStdout(result.stdout);
44
+ }
45
+ function parseJsonStdout(stdout) {
46
+ const trimmed = String(stdout || "").trim();
47
+ if (!trimmed) return [];
48
+ try {
49
+ return JSON.parse(trimmed);
50
+ } catch (err) {
51
+ throw new Error(`invalid OpenCode JSON output: ${err.message}`);
52
+ }
53
+ }
54
+ export function probeOpenCode(opts = {}) {
55
+ const probe = opts.probe ?? opts.probeOpenCode ?? defaultProbeOpenCode;
56
+ return probe();
57
+ }
58
+ export function isSupportedOpenCodeVersion(version) {
59
+ const match = String(version || "").match(/(\d+)\.(\d+)\.(\d+)/);
60
+ return Boolean(match && Number(match[1]) === 1 && Number(match[2]) === 18);
61
+ }
62
+ function defaultProbeOpenCode() {
63
+ try {
64
+ const which = process.platform === "win32" ? "where" : "which";
65
+ const pathResult = execFileSync(which, ["opencode"], { encoding: "utf8", timeout: 10_000 }).trim().split(/\r?\n/)[0];
66
+ const version = execFileSync(pathResult, ["--version"], { encoding: "utf8", timeout: 10_000 }).trim();
67
+ return { found: true, path: pathResult, version };
68
+ } catch {
69
+ return { found: false, path: "", version: "" };
70
+ }
71
+ }
72
+ function sessionScopeMatches(sessionDir, opts) {
73
+ if (opts.scope !== "project") return true;
74
+ return path.resolve(sessionDir) === path.resolve(opts.projectCwd || process.cwd());
75
+ }
76
+ function firstString(...values) {
77
+ for (const value of values) {
78
+ if (typeof value === "string" && value.trim()) return value.trim();
79
+ }
80
+ return "";
81
+ }
82
+ export async function listSessions(opts = {}) {
83
+ const installation = probeOpenCode(opts);
84
+ if (!installation?.found) return [];
85
+ if (!isSupportedOpenCodeVersion(installation.version)) {
86
+ return [];
87
+ }
88
+ const listed = await Promise.resolve(defaultRunList(opts));
89
+ const rows = Array.isArray(listed) ? listed : (listed?.sessions || listed?.data);
90
+ if (!Array.isArray(rows)) {
91
+ throw new Error("invalid OpenCode session list shape: expected an array");
92
+ }
93
+ const out = [];
94
+ for (const row of rows) {
95
+ if (!row || typeof row !== "object") continue;
96
+ const sessionId = firstString(row.id, row.sessionID, row.sessionId);
97
+ if (!sessionId) continue;
98
+ const cwd = firstString(row.directory, row.cwd, row.projectPath);
99
+ if (!cwd || !sessionScopeMatches(cwd, opts)) continue;
100
+ const mtimeMs = timestampMs(row.updated, row.updatedAt, row.time?.updated);
101
+ out.push({
102
+ sessionUuid: sessionId,
103
+ path: sessionId,
104
+ cwd,
105
+ agentId: agentIdForOpenCode(cwd, { historical: true }),
106
+ mtimeMs,
107
+ likelyInProgress: mtimeMs > Date.now() - 60_000,
108
+ parentSessionId: firstString(row.parentID, row.parentId) || null,
109
+ });
110
+ }
111
+ return out;
112
+ }
113
+ function timestampMs(...values) {
114
+ for (const value of values) {
115
+ if (typeof value === "number" && Number.isFinite(value)) return value;
116
+ if (typeof value === "string" && value.trim()) {
117
+ const parsed = Date.parse(value);
118
+ if (Number.isFinite(parsed)) return parsed;
119
+ }
120
+ }
121
+ return 0;
122
+ }
123
+ export async function parseSession(ref, opts = {}) {
124
+ let exportShape = ref.exportData;
125
+ if (!exportShape) {
126
+ exportShape = await Promise.resolve(defaultRunExport(ref.sessionUuid || ref.path, opts));
127
+ }
128
+ const normalized = normalizeOpenCodeSession(exportShape, {
129
+ terminalError: Boolean(ref.terminalError),
130
+ interrupted: Boolean(ref.interrupted),
131
+ });
132
+ return {
133
+ sessionUuid: normalized.sessionId,
134
+ firstUserText: normalized.firstUserText,
135
+ startedAt: normalized.startedAt,
136
+ exchanges: normalized.exchanges.map((ex) => ({
137
+ user_uuid: ex.userId,
138
+ input: ex.input,
139
+ output: ex.output,
140
+ reasoning: "",
141
+ timestamp: ex.timestamp,
142
+ interrupted: ex.interrupted,
143
+ })),
144
+ partial: Boolean(normalized.partial),
145
+ parentSessionId: normalized.parentSessionId,
146
+ cwd: normalized.cwd,
147
+ };
148
+ }
@@ -5,8 +5,12 @@
5
5
  * - no CLI / side effects; only exports
6
6
  * - streaming via readline so sessions larger than available RAM
7
7
  * don't need to be loaded whole
8
- * - filters isSidechain / isMeta / attachment-type entries up front
9
- * (the hook-side version doesn't need to because it runs live)
8
+ * - filters isSidechain / attachment-type entries up front, and drops
9
+ * `isMeta` entries other than skill context and subagent hand-backs
10
+ * - folds skill bodies, task notifications and subagent hand-backs into
11
+ * the turn they belong to, with the same rules as capture.js
12
+ * (MEKO-710). The whole session is parsed at once, so there is no parse
13
+ * window; a session still being written holds its trailing turn.
10
14
  *
11
15
  * Kept deliberately independent of `skills/hooks-handlers/lib/capture.js`:
12
16
  * installer packages ship without that sibling directory, and mixing
@@ -15,6 +19,7 @@
15
19
  */
16
20
 
17
21
  import fs from "node:fs";
22
+ import path from "node:path";
18
23
  import readline from "node:readline";
19
24
 
20
25
  export const MAX_THINKING_LEN = Number(process.env.MEKO_MAX_THINKING_LEN ?? 2000);
@@ -24,6 +29,8 @@ export const MAX_TOOL_RESULT_LEN = Number(process.env.MEKO_MAX_TOOL_RESULT_LEN ?
24
29
  const SKIP_INPUT_PATTERN =
25
30
  /^<(system-reminder|command-|local-command|available-deferred)/;
26
31
 
32
+ const SKILL_BODY_PATTERN = /^Base directory for this skill: *(\S+)/;
33
+
27
34
  export function truncate(str, max) {
28
35
  if (!str || str.length <= max) return str;
29
36
  return str.slice(0, max) + "... [truncated]";
@@ -104,6 +111,84 @@ export function extractToolResultContent(msg) {
104
111
  return "TOOL RESULT:\n" + parts.join("\n---\n");
105
112
  }
106
113
 
114
+ /**
115
+ * Render a Claude Code skill or prompt command entry as the command the user
116
+ * typed (`/commit fix typo`). Only the `<command-message>`-led shape loads a
117
+ * skill; built-in commands lead with `<command-name>` and return "".
118
+ * Mirrors skillCommandInput in capture.js.
119
+ */
120
+ export function skillCommandInput(text) {
121
+ if (typeof text !== "string" || !/^<command-message>/.test(text)) return "";
122
+ const name = /<command-name>([\s\S]*?)<\/command-name>/.exec(text);
123
+ if (!name) return "";
124
+ const args = /<command-args>([\s\S]*?)<\/command-args>/.exec(text);
125
+ return [name[1].trim(), args ? args[1].trim() : ""].filter(Boolean).join(" ");
126
+ }
127
+
128
+ /** Is this a background-task or subagent notification? Mirrors capture.js. */
129
+ export function isTaskNotification(obj) {
130
+ if (obj?.type !== "user" || isToolResultMessage(obj)) return false;
131
+ if (obj.origin && obj.origin.kind === "task-notification") return true;
132
+ return /^\s*<task-notification>/.test(extractText(obj.message?.content));
133
+ }
134
+
135
+ /**
136
+ * Is this a subagent's hand-back report? Claude Code writes it as an
137
+ * `isMeta` entry tagged `origin: { kind: "peer", handback: true }`. Other
138
+ * peer messages are requests from another session. Mirrors capture.js.
139
+ */
140
+ export function isSubagentHandback(obj) {
141
+ if (obj?.type !== "user" || isToolResultMessage(obj)) return false;
142
+ return obj.origin?.kind === "peer" && obj.origin.handback === true;
143
+ }
144
+
145
+ function handbackInput(obj) {
146
+ const name = obj.origin?.name || obj.origin?.from || "";
147
+ return name ? `Agent message: ${name}` : "Agent message";
148
+ }
149
+
150
+ /**
151
+ * Is this an `isMeta` entry Claude Code injected while loading a skill? The
152
+ * model-invoked shape carries `sourceToolUseID`; a typed `/skill` body starts
153
+ * with "Base directory for this skill:". Mirrors capture.js.
154
+ */
155
+ export function isSkillContext(obj) {
156
+ if (obj?.type !== "user" || obj.isMeta !== true) return false;
157
+ if (isToolResultMessage(obj)) return false;
158
+ if (obj.sourceToolUseID) return true;
159
+ return SKILL_BODY_PATTERN.test(extractText(obj.message?.content));
160
+ }
161
+
162
+ function taskNotificationInput(text) {
163
+ const summary = /<summary>([\s\S]*?)<\/summary>/.exec(text || "");
164
+ return `Task notification: ${summary ? summary[1].trim() : "background task update"}`;
165
+ }
166
+
167
+ function skillContextInput(text) {
168
+ const dir = SKILL_BODY_PATTERN.exec(text || "");
169
+ const name = dir ? path.basename(dir[1].replace(/\/+$/, "")) : "";
170
+ return name ? `Skill loaded: ${name}` : "Skill context loaded";
171
+ }
172
+
173
+ /**
174
+ * The rendered input for a turn. A typed skill command that got its body is
175
+ * unwrapped to `/name args`; a turn opened by injected context gets a one-line
176
+ * summary so that context is never sent as user input.
177
+ */
178
+ function turnInput(turn) {
179
+ const text = extractText(turn.userMsg.message?.content);
180
+ if (isTaskNotification(turn.userMsg)) return taskNotificationInput(text);
181
+ if (isSubagentHandback(turn.userMsg)) return handbackInput(turn.userMsg);
182
+ if (isSkillContext(turn.userMsg)) return skillContextInput(text);
183
+ return turn.injectedEntries.some((e) => e.isMeta === true && !isSubagentHandback(e))
184
+ ? skillCommandInput(text) || text
185
+ : text;
186
+ }
187
+
188
+ function isCapturableInput(input) {
189
+ return !!input && !SKIP_INPUT_PATTERN.test(input);
190
+ }
191
+
107
192
  /**
108
193
  * Should this JSON entry be dropped before we start pairing turns?
109
194
  * Mirrors the plan: skip sidechains, meta turns, and the non-message types.
@@ -177,6 +262,10 @@ export async function parseSessionStream(sessionPath) {
177
262
  // Turn-assembly state.
178
263
  const exchanges = [];
179
264
  let current = null;
265
+ // The last entry kept for turn assembly, and whether consecutive injected
266
+ // isMeta entries are still arriving after a skill command or Skill call.
267
+ let prev = null;
268
+ let insideInjectedRun = false;
180
269
 
181
270
  for await (const raw of rl) {
182
271
  const line = raw.trim();
@@ -191,28 +280,63 @@ export async function parseSessionStream(sessionPath) {
191
280
  if (!cwd && typeof obj.cwd === "string") cwd = obj.cwd;
192
281
  if (!gitBranch && typeof obj.gitBranch === "string") gitBranch = obj.gitBranch;
193
282
  if (!startedAt && typeof obj.timestamp === "string") startedAt = obj.timestamp;
194
- if (!firstUserText && obj.type === "user" && !isSkippable(obj) && !isToolResultMessage(obj)) {
283
+ if (!firstUserText && obj.type === "user" && !isSkippable(obj) && !isToolResultMessage(obj) && !isTaskNotification(obj)) {
195
284
  const txt = extractText(obj.message?.content);
196
285
  if (txt && !SKIP_INPUT_PATTERN.test(txt)) firstUserText = txt.slice(0, 240);
197
286
  }
198
287
 
199
- if (isSkippable(obj)) continue;
288
+ // Skill context and hand-backs are isMeta but belong to their turn, so
289
+ // test them before isSkippable drops every other isMeta entry. A
290
+ // hand-back is never skill context, even if it carries sourceToolUseID.
291
+ const handback = isSubagentHandback(obj) && obj.isSidechain !== true;
292
+ const skillContext =
293
+ !handback && isSkillContext(obj) && obj.isSidechain !== true;
294
+ if (!skillContext && !handback && isSkippable(obj)) continue;
295
+
296
+ const isUserText = obj.type === "user" && !isToolResultMessage(obj);
297
+ let foldIntoCurrent = false;
298
+ if (isUserText && current) {
299
+ if (skillContext) {
300
+ const prevIsSkillCommand =
301
+ prev?.type === "user" &&
302
+ skillCommandInput(extractText(prev.message?.content)) !== "";
303
+ foldIntoCurrent =
304
+ !!obj.sourceToolUseID || insideInjectedRun || prevIsSkillCommand;
305
+ } else if (isTaskNotification(obj) || handback) {
306
+ // Folding into a turn that is never captured (a `/compact` or
307
+ // `<local-command-…>` turn) would drop the reply with it.
308
+ foldIntoCurrent = isCapturableInput(turnInput(current));
309
+ }
310
+ }
311
+ insideInjectedRun = skillContext && (!!obj.sourceToolUseID || foldIntoCurrent);
200
312
 
201
- if (obj.type === "user" && !isToolResultMessage(obj)) {
313
+ if (isUserText && !foldIntoCurrent) {
202
314
  if (current) {
203
315
  const built = buildExchange(current);
204
316
  if (built) exchanges.push(built);
205
317
  }
206
- current = { userMsg: obj, assistantEntries: [], toolResultEntries: [] };
318
+ current = {
319
+ userMsg: obj,
320
+ assistantEntries: [],
321
+ toolResultEntries: [],
322
+ injectedEntries: [],
323
+ };
207
324
  } else if (current) {
208
325
  if (obj.type === "assistant") {
209
326
  current.assistantEntries.push(obj);
210
327
  } else if (obj.type === "user" && isToolResultMessage(obj)) {
211
328
  current.toolResultEntries.push(obj);
329
+ } else if (isUserText) {
330
+ current.injectedEntries.push(obj);
212
331
  }
213
332
  }
333
+ prev = obj;
214
334
  }
215
- if (current) {
335
+ // A session still being written can get more entries for its trailing
336
+ // turn, such as a task notification and its reply. Hold that turn, like
337
+ // the Codex adapter does: if it were sent now, resume would skip it on the
338
+ // next run and the new entries would never be migrated.
339
+ if (current && !partial) {
216
340
  const built = buildExchange(current);
217
341
  if (built) exchanges.push(built);
218
342
  }
@@ -236,8 +360,8 @@ function baseName(p) {
236
360
  }
237
361
 
238
362
  function buildExchange(ex) {
239
- const input = extractText(ex.userMsg.message?.content);
240
- if (!input || SKIP_INPUT_PATTERN.test(input)) return null;
363
+ const input = turnInput(ex);
364
+ if (!isCapturableInput(input)) return null;
241
365
  if (ex.assistantEntries.length === 0) return null;
242
366
 
243
367
  const outputParts = [];
@@ -257,9 +381,29 @@ function buildExchange(ex) {
257
381
  const trText = extractToolResultContent(tr);
258
382
  if (trText) reasoningParts.push(trText);
259
383
  }
384
+ const anchoredOnInjection =
385
+ isTaskNotification(ex.userMsg) || isSubagentHandback(ex.userMsg) || isSkillContext(ex.userMsg);
386
+ const injected = anchoredOnInjection
387
+ ? [ex.userMsg, ...ex.injectedEntries]
388
+ : ex.injectedEntries;
389
+ for (const inj of injected) {
390
+ const text = extractText(inj.message?.content);
391
+ if (text) {
392
+ const label = isTaskNotification(inj)
393
+ ? "TASK NOTIFICATION:\n"
394
+ : isSubagentHandback(inj)
395
+ ? "AGENT MESSAGE:\n"
396
+ : "INJECTED CONTEXT:\n";
397
+ reasoningParts.push(label + truncate(text, MAX_TOOL_RESULT_LEN));
398
+ }
399
+ }
260
400
 
261
401
  return {
262
402
  user_uuid: ex.userMsg.uuid || "unknown",
403
+ // Uuids of the injected entries folded into this turn. Older migrator
404
+ // versions sent a task notification as its own exchange, so a resume
405
+ // cursor saved by them can name one of these.
406
+ folded_uuids: ex.injectedEntries.map((e) => e.uuid).filter(Boolean),
263
407
  input,
264
408
  output: outputParts.join("\n\n"),
265
409
  reasoning: reasoningParts.join("\n---\n"),
@@ -3,6 +3,8 @@
3
3
  */
4
4
 
5
5
  import { createHash } from "node:crypto";
6
+ import fs from "node:fs";
7
+ import os from "node:os";
6
8
  import path from "node:path";
7
9
 
8
10
  /**
@@ -44,7 +46,7 @@ export const COMMON_BUCKET_AGENT_ID = "meko_agent";
44
46
 
45
47
  const AGENT_ID_MAX_LEN = 64;
46
48
  const KNOWN_LOOSE_CLIENTS = new Set(["claude-desktop", "claude_desktop"]);
47
- const KNOWN_CODING_CLIENTS = new Set(["claude_code", "cursor"]);
49
+ const KNOWN_CODING_CLIENTS = new Set(["claude_code", "cursor", "codex", "kiro", "opencode"]);
48
50
 
49
51
  /**
50
52
  * Sanitize a project segment: lowercase, collapse non-`[a-z0-9-]` to `-`,
@@ -61,21 +63,125 @@ function sanitizeProjectSegment(segment) {
61
63
  return collapsed.slice(0, AGENT_ID_MAX_LEN);
62
64
  }
63
65
 
66
+ /**
67
+ * The user's home directory, read fresh so tests (and shells that re-point
68
+ * `$HOME`) see the current value rather than a module-load snapshot.
69
+ *
70
+ * @returns {string|null}
71
+ */
72
+ function homeBoundary() {
73
+ const fromEnv = (process.env.HOME || "").trim();
74
+ if (fromEnv) return path.resolve(fromEnv);
75
+ try {
76
+ const h = os.homedir();
77
+ return h ? path.resolve(h) : null;
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Walk parents of `cwd` looking for a `.git` entry (directory for a normal
85
+ * checkout, file for a submodule or linked worktree). Returns the absolute
86
+ * repo-root path, or `null` if none is found.
87
+ *
88
+ * The walk stops at `$HOME`: a directory under home that is not itself in a
89
+ * repo must NOT inherit home's repo-ness. Users who keep dotfiles under git
90
+ * would otherwise see every scratch directory collapse into
91
+ * `claude_code:<username>` — the same cross-project merge MEKO-590 exists to
92
+ * prevent, one level up. `$HOME` itself is still a valid repo root, so a
93
+ * session run directly in a git-tracked home resolves normally.
94
+ *
95
+ * Sync + best-effort: any I/O error at a level just skips that level. The
96
+ * caller treats `null` as "outside a repo" and falls back to the bare client
97
+ * name, per the MEKO-590 contract.
98
+ *
99
+ * @param {string} [cwd]
100
+ * @returns {string|null}
101
+ */
102
+ export function resolveRepoRoot(cwd) {
103
+ if (!cwd || typeof cwd !== "string") return null;
104
+ let dir;
105
+ try {
106
+ dir = path.resolve(cwd);
107
+ } catch {
108
+ return null;
109
+ }
110
+ const start = dir;
111
+ const home = homeBoundary();
112
+ let prev = null;
113
+ while (dir && dir !== prev) {
114
+ // A directory below a git-tracked home must not inherit home's `.git`.
115
+ if (home && dir === home && dir !== start) return null;
116
+ try {
117
+ fs.statSync(path.join(dir, ".git"));
118
+ return dir;
119
+ } catch {
120
+ // no .git here; keep walking
121
+ }
122
+ // Check home itself for `.git`, then stop. This lets a git-tracked home
123
+ // resolve as a repo root without allowing a non-git home to inherit a
124
+ // `.git` from /Users, /home, or /.
125
+ if (home && dir === home) return null;
126
+ prev = dir;
127
+ dir = path.dirname(dir);
128
+ }
129
+ return null;
130
+ }
131
+
132
+ /**
133
+ * Repo root for a cwd that is HISTORICAL rather than live — a path recorded
134
+ * in an old transcript, or reverse-derived from a `~/.claude/projects/<slug>`
135
+ * directory name. Such a path may no longer exist (repo deleted, moved, or
136
+ * renamed) while its transcripts survive, so a filesystem walk is not a
137
+ * usable oracle.
138
+ *
139
+ * Walks first, so a repo still on disk gets the correct root. When the walk
140
+ * finds nothing, treats the recorded path's own basename as the project
141
+ * segment instead of reporting "outside a repo". That keeps each historical
142
+ * project in its own bucket: collapsing them all into a bare `claude_code`
143
+ * would re-create, inside the migrator, exactly the cross-repo merge
144
+ * MEKO-590 fixes in the hook.
145
+ *
146
+ * @param {string} [cwd]
147
+ * @returns {string|null}
148
+ */
149
+ export function repoRootForHistoricalCwd(cwd) {
150
+ if (!cwd || typeof cwd !== "string") return null;
151
+ const live = resolveRepoRoot(cwd);
152
+ if (live) return live;
153
+ try {
154
+ return path.resolve(cwd);
155
+ } catch {
156
+ return null;
157
+ }
158
+ }
159
+
64
160
  /**
65
161
  * Derive an `agent_id` for a Meko write.
66
162
  *
67
- * Contract:
163
+ * Contract (MEKO-590):
68
164
  * - `envOverride` (typically `MEKO_AGENT_ID`) wins if non-empty.
69
165
  * - Loose clients (Claude Desktop) → bare `claude_desktop`; no schema.
70
- * - Coding clients (Claude Code, Cursor) → `<client>:<repo-basename>`.
71
- * Outside a repo (basename is empty/`.`/`/`) → bare client name to
72
- * keep coding-agent traffic out of the common bucket.
166
+ * - Coding clients (Claude Code, Cursor, Codex, Kiro, OpenCode) →
167
+ * `<client>:<repo-basename>`, resolved by walking parents of `cwd`
168
+ * for a `.git` entry. The basename is lowercased and non-`[a-z0-9-]`
169
+ * runs collapse to `-`. Outside a repo (no `.git` in any ancestor,
170
+ * or basename sanitizes to empty) → bare client name, so sibling
171
+ * directories named `docs` / `webapp` / `src` across unrelated repos
172
+ * cannot silently collapse into one agent_id.
73
173
  * - Anything else → `meko_agent` (the common cross-project bucket).
74
174
  *
175
+ * The `repoRoot` opt lets callers bypass the filesystem walk when they
176
+ * already know the repo root (or, for Cursor's flat project-name model,
177
+ * want to treat a supplied name as the basename directly). Passing
178
+ * `repoRoot: null` explicitly is an assertion of "outside a repo".
179
+ *
75
180
  * @param {object} opts
76
- * @param {string} [opts.client] e.g. "claude_code", "cursor", "claude-desktop"
181
+ * @param {string} [opts.client] e.g. "claude_code", "cursor", "codex", "kiro", "claude-desktop"
77
182
  * @param {string} [opts.cwd] Absolute working directory, for coding clients
78
183
  * @param {string} [opts.envOverride] Raw value of `MEKO_AGENT_ID`, if set
184
+ * @param {string|null} [opts.repoRoot] Pre-resolved repo root; bypasses the FS walk when set
79
185
  * @returns {string}
80
186
  */
81
187
  export function deriveAgentId(opts = {}) {
@@ -86,13 +192,45 @@ export function deriveAgentId(opts = {}) {
86
192
  if (KNOWN_LOOSE_CLIENTS.has(client)) return "claude_desktop";
87
193
  if (!KNOWN_CODING_CLIENTS.has(client)) return COMMON_BUCKET_AGENT_ID;
88
194
 
89
- const rawBase = opts.cwd ? path.basename(opts.cwd) : "";
90
- if (!rawBase || rawBase === "." || rawBase === "/") return client;
91
- const project = sanitizeProjectSegment(rawBase);
195
+ const repoRoot = Object.prototype.hasOwnProperty.call(opts, "repoRoot")
196
+ ? opts.repoRoot
197
+ : resolveRepoRoot(opts.cwd);
198
+ if (!repoRoot) return client;
199
+ const project = sanitizeProjectSegment(path.basename(repoRoot));
92
200
  if (!project) return client;
93
201
  return `${client}:${project}`;
94
202
  }
95
203
 
204
+ /**
205
+ * Derive the coding-agent id used before MEKO-590: from the cwd's leaf
206
+ * basename without a repo-root walk. Persisted migrator state and datapack
207
+ * pin filenames may still use this value, so callers use it as a compatibility
208
+ * alias while new sessions use `deriveAgentId`.
209
+ *
210
+ * @param {object} opts
211
+ * @param {string} [opts.client]
212
+ * @param {string} [opts.cwd]
213
+ * @param {string} [opts.envOverride]
214
+ * @returns {string}
215
+ */
216
+ export function deriveLegacyAgentId(opts = {}) {
217
+ const override = (opts.envOverride ?? "").trim();
218
+ if (override) return override;
219
+
220
+ const client = (opts.client ?? "").trim();
221
+ if (KNOWN_LOOSE_CLIENTS.has(client)) return "claude_desktop";
222
+ if (!KNOWN_CODING_CLIENTS.has(client)) return COMMON_BUCKET_AGENT_ID;
223
+
224
+ let rawBase = "";
225
+ try {
226
+ rawBase = opts.cwd ? path.basename(opts.cwd) : "";
227
+ } catch {
228
+ return client;
229
+ }
230
+ const project = sanitizeProjectSegment(rawBase);
231
+ return project ? `${client}:${project}` : client;
232
+ }
233
+
96
234
  /**
97
235
  * Back-compat alias. New code should call `deriveAgentId(...)`.
98
236
  */
@@ -101,14 +239,23 @@ export const AGENT_ID = COMMON_BUCKET_AGENT_ID;
101
239
  /**
102
240
  * @param {string} [cwd] Absolute project cwd. Required for project-scope
103
241
  * writes; omit for user-scope writes (returns the common bucket).
242
+ *
243
+ * Pass `{ historical: true }` when `cwd` came from a recorded transcript or
244
+ * a reverse-slugged project directory rather than the live process. The repo
245
+ * root is then resolved via `repoRootForHistoricalCwd`, which falls back to
246
+ * the recorded basename when the path no longer exists on disk, so projects
247
+ * whose repos were deleted or moved keep distinct buckets.
248
+ * @param {object} [opts]
249
+ * @param {boolean} [opts.historical] Treat `cwd` as a recorded path.
104
250
  * @returns {string}
105
251
  */
106
- export function agentIdForClaude(cwd) {
252
+ export function agentIdForClaude(cwd, opts = {}) {
107
253
  if (!cwd) return COMMON_BUCKET_AGENT_ID;
108
254
  return deriveAgentId({
109
255
  client: "claude_code",
110
256
  cwd,
111
257
  envOverride: process.env.MEKO_AGENT_ID,
258
+ ...(opts.historical ? { repoRoot: repoRootForHistoricalCwd(cwd) } : {}),
112
259
  });
113
260
  }
114
261
 
@@ -122,14 +269,74 @@ export const CLAUDE_CODE_USER_AGENT_ID = COMMON_BUCKET_AGENT_ID;
122
269
  /**
123
270
  * @param {string} [cwd] Absolute project cwd (or basename — Cursor's projects
124
271
  * live under a flat `~/.cursor/projects/<name>/`). Omit for user-scope.
272
+ *
273
+ * Cursor's persisted transcripts store a flat project name rather than a
274
+ * full absolute cwd, so we treat the supplied value as the repo basename
275
+ * directly (via `repoRoot`) instead of walking the filesystem for a `.git`
276
+ * that isn't there.
277
+ *
125
278
  * @returns {string}
126
279
  */
127
280
  export function agentIdForCursor(cwd) {
128
281
  if (!cwd) return COMMON_BUCKET_AGENT_ID;
129
282
  return deriveAgentId({
130
283
  client: "cursor",
284
+ repoRoot: cwd,
285
+ envOverride: process.env.MEKO_AGENT_ID,
286
+ });
287
+ }
288
+
289
+ /**
290
+ * @param {string} [cwd] Absolute project cwd of the Codex session (from the
291
+ * rollout's `session_meta.payload.cwd`). Omit for user-scope writes
292
+ * (returns the common bucket).
293
+ *
294
+ * Pass `{ historical: true }` when `cwd` came from a recorded transcript or
295
+ * a reverse-slugged project directory rather than the live process. The repo
296
+ * root is then resolved via `repoRootForHistoricalCwd`, which falls back to
297
+ * the recorded basename when the path no longer exists on disk, so projects
298
+ * whose repos were deleted or moved keep distinct buckets.
299
+ * @param {object} [opts]
300
+ * @param {boolean} [opts.historical] Treat `cwd` as a recorded path.
301
+ * @returns {string}
302
+ */
303
+ export function agentIdForCodex(cwd, opts = {}) {
304
+ if (!cwd) return COMMON_BUCKET_AGENT_ID;
305
+ return deriveAgentId({
306
+ client: "codex",
307
+ cwd,
308
+ envOverride: process.env.MEKO_AGENT_ID,
309
+ ...(opts.historical ? { repoRoot: repoRootForHistoricalCwd(cwd) } : {}),
310
+ });
311
+ }
312
+
313
+ /**
314
+ * Derive the project-scoped owner used by Kiro hook capture.
315
+ *
316
+ * Pass `{ historical: true }` when `cwd` came from a recorded transcript or
317
+ * a reverse-slugged project directory rather than the live process. The repo
318
+ * root is then resolved via `repoRootForHistoricalCwd`, which falls back to
319
+ * the recorded basename when the path no longer exists on disk, so projects
320
+ * whose repos were deleted or moved keep distinct buckets.
321
+ */
322
+ export function agentIdForKiro(cwd, opts = {}) {
323
+ if (!cwd) return COMMON_BUCKET_AGENT_ID;
324
+ return deriveAgentId({
325
+ client: "kiro",
326
+ cwd,
327
+ envOverride: process.env.MEKO_AGENT_ID,
328
+ ...(opts.historical ? { repoRoot: repoRootForHistoricalCwd(cwd) } : {}),
329
+ });
330
+ }
331
+
332
+ /** Derive the project-scoped owner used by OpenCode capture and migration. */
333
+ export function agentIdForOpenCode(cwd, opts = {}) {
334
+ if (!cwd) return COMMON_BUCKET_AGENT_ID;
335
+ return deriveAgentId({
336
+ client: "opencode",
131
337
  cwd,
132
338
  envOverride: process.env.MEKO_AGENT_ID,
339
+ ...(opts.historical ? { repoRoot: repoRootForHistoricalCwd(cwd) } : {}),
133
340
  });
134
341
  }
135
342