@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.
Files changed (67) hide show
  1. package/README.md +168 -61
  2. package/assets/hooks-handlers/lib/capture-cleanup-cli.js +2134 -0
  3. package/assets/hooks-handlers/lib/capture-notices.js +224 -14
  4. package/assets/hooks-handlers/lib/capture-state.js +206 -1
  5. package/assets/hooks-handlers/lib/capture-status-cli.js +6 -2
  6. package/assets/hooks-handlers/lib/capture.js +1364 -139
  7. package/assets/hooks-handlers/lib/checkpoint-timer.js +445 -18
  8. package/assets/hooks-handlers/lib/journal-bridge.js +218 -0
  9. package/assets/hooks-handlers/lib/kiro-handler.js +26 -4
  10. package/assets/hooks-handlers/lib/opencode-handler.js +643 -0
  11. package/assets/hooks-handlers/lib/opencode-normalizer.js +145 -0
  12. package/assets/hooks-handlers/lib/terminal-handoff.js +10 -1
  13. package/assets/opencode-plugin/meko.js +213 -0
  14. package/assets/scripts/meko-statusline.sh +102 -19
  15. package/assets/skills/meko-mcp-tools/SKILL.md +59 -16
  16. package/assets/skills/meko-mcp-tools/references/_sections.md +2 -2
  17. package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +15 -7
  18. package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +275 -19
  19. package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +7 -4
  20. package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +39 -11
  21. package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +10 -6
  22. package/assets/skills/meko-mcp-tools/references/tools-overview.md +44 -29
  23. package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +79 -14
  24. package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +47 -1
  25. package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +6 -39
  26. package/assets/skills/meko-mcp-tools-desktop/SKILL.md +57 -21
  27. package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +4 -4
  28. package/assets/skills/meko-mcp-tools-desktop/references/_sections.md +1 -1
  29. package/assets/skills/meko-mcp-tools-desktop/references/tools-agent-id-conventions.md +8 -6
  30. package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +284 -38
  31. package/assets/skills/meko-mcp-tools-desktop/references/tools-known-limitations.md +32 -10
  32. package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +16 -12
  33. package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +46 -31
  34. package/assets/skills/meko-mcp-tools-desktop/references/tools-rag-workflow.md +79 -14
  35. package/assets/skills/meko-mcp-tools-desktop/references/tools-troubleshooting.md +69 -1
  36. package/assets/skills/meko-select-datapack-desktop/SKILL.md +93 -14
  37. package/assets/skills/meko-select-datapack-desktop/references/cookbook.md +44 -12
  38. package/assets/skills/meko-select-datapack-desktop/references/role-display-future.md +14 -10
  39. package/assets/skills/meko-select-datapack-desktop/references/troubleshooting.md +17 -5
  40. package/bin/create-meko-setup.mjs +186 -30
  41. package/lib/autofix.mjs +100 -0
  42. package/lib/browser-login.mjs +495 -0
  43. package/lib/capture-cleanup.mjs +63 -0
  44. package/lib/clients/claude-code.mjs +1 -0
  45. package/lib/clients/codex.mjs +80 -35
  46. package/lib/clients/common.mjs +7 -0
  47. package/lib/clients/index.mjs +2 -0
  48. package/lib/clients/kiro.mjs +5 -5
  49. package/lib/clients/opencode.mjs +537 -0
  50. package/lib/config.mjs +68 -1
  51. package/lib/installer.mjs +18 -2
  52. package/lib/migrate/adapters/claude-code.mjs +11 -2
  53. package/lib/migrate/adapters/codex.mjs +9 -2
  54. package/lib/migrate/adapters/memory-md.mjs +27 -8
  55. package/lib/migrate/adapters/opencode.mjs +148 -0
  56. package/lib/migrate/claude-parser.mjs +153 -9
  57. package/lib/migrate/datapack.mjs +254 -16
  58. package/lib/migrate/id.mjs +195 -13
  59. package/lib/migrate/mcp-client.mjs +59 -1
  60. package/lib/migrate/opencode-normalizer.mjs +132 -0
  61. package/lib/migrate/orchestrator.mjs +294 -78
  62. package/lib/migrate/progress.mjs +239 -5
  63. package/lib/migrate/registry.mjs +72 -0
  64. package/lib/migrate.mjs +97 -9
  65. package/lib/validate.mjs +111 -22
  66. package/package.json +5 -5
  67. package/scripts/bundle-assets.mjs +4 -0
@@ -8,11 +8,19 @@
8
8
  * confirmation of the default-fallback case. This module owns the lookup.
9
9
  *
10
10
  * Wire model: the MCP server routes by `datapack_id` parameter, not URL host.
11
- * `datapack_list` returns the user's visible packs; we resolve here, then the
12
- * orchestrator threads `datapack_id` into every subsequent `tools/call`.
11
+ * We resolve here, then the orchestrator threads `datapack_id` into every
12
+ * subsequent `tools/call`.
13
+ *
14
+ * Lookups go through `datapack_search(name=...)` and `datapack_describe`, so
15
+ * they cost the same for an account with 1M datapacks as for one with two.
16
+ * A server whose tools/list has no `datapack_search` predates it; there we
17
+ * fall back to scanning `datapack_list`, which is fine for the account sizes
18
+ * such a server can serve.
13
19
  */
14
20
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
15
21
  const DEFAULT_DATAPACK_NAME = "meko_default_datapack";
22
+ const NIL_CONVERSATION_ID = "00000000-0000-0000-0000-000000000000";
23
+ const SUGGESTION_LIMIT = 10;
16
24
 
17
25
  export function isUuid(value) {
18
26
  return typeof value === "string" && UUID_RE.test(value);
@@ -50,7 +58,8 @@ export function parseDatapackList(text) {
50
58
  * Resolve the destination datapack for a migration.
51
59
  *
52
60
  * @param {object} opts
53
- * @param {{callTool: Function}} opts.client Connected McpClient.
61
+ * @param {{callTool: Function, listTools?: Function}} opts.client
62
+ * Connected McpClient.
54
63
  * @param {string|null} opts.requested Raw `--datapack` value (UUID or
55
64
  * name) or null for default.
56
65
  * @returns {Promise<{
@@ -68,20 +77,239 @@ export function parseDatapackList(text) {
68
77
  * - `server-fallback`: requested=null and no default row found in the list;
69
78
  * we let the server's own fallback chain handle it (id stays null).
70
79
  *
71
- * `defaultId` is the id of the `meko_default_datapack` row if visible — surfaced
72
- * even when `requested` is non-null so the caller can detect "user named the
73
- * default explicitly" and reuse the same on-disk state file.
80
+ * `defaultId` is the id of the `meko_default_datapack` row if visible, so the
81
+ * caller can detect "user named the default explicitly" and reuse the same
82
+ * on-disk state file. With `datapack_search`, an explicit `requested` whose
83
+ * datapack has another name gets `defaultId: null` without a lookup: its id
84
+ * can't match the default's, and skipping the lookup keeps a failure there
85
+ * from failing the request.
74
86
  *
75
87
  * Failure cases (`ok: false`):
76
- * - `datapack_list` call failed (auth, network, server error).
77
- * - response body wasn't a recognizable datapack list.
78
- * - explicit override didn't match any row.
88
+ * - the lookup call failed (auth, network, server error).
89
+ * - response body wasn't recognizable.
90
+ * - explicit override didn't match any visible datapack.
79
91
  */
80
92
  export async function resolveDestinationDatapack(opts) {
81
93
  const { client, requested } = opts;
94
+ if (await hasTool(client, "datapack_search")) {
95
+ return resolveWithSearch(client, requested);
96
+ }
97
+ return resolveWithListScan(client, requested);
98
+ }
99
+
100
+ /**
101
+ * Whether the server lists `name`. A client without `listTools` (a test
102
+ * double) or a failed tools/list reads as "no", which selects the
103
+ * `datapack_list` scan every server supports.
104
+ */
105
+ async function hasTool(client, name) {
106
+ if (typeof client.listTools !== "function") return false;
107
+ try {
108
+ const names = await client.listTools();
109
+ return Array.isArray(names) && names.includes(name);
110
+ } catch {
111
+ return false;
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Parse a `datapack_search` response body into its rows, or return
117
+ * `{error}` naming what went wrong. Rows are summary rows (no credentials),
118
+ * but the body is still never echoed.
119
+ *
120
+ * @param {{ok: boolean, text?: string, error?: object}} res
121
+ * @returns {{rows: Array} | {error: string}}
122
+ */
123
+ export function parseDatapackSearch(res) {
124
+ if (!res.ok) return { error: res.error?.message ?? "unknown" };
125
+ let body;
126
+ try {
127
+ body = JSON.parse(res.text ?? "");
128
+ } catch {
129
+ return { error: "unrecognized response shape" };
130
+ }
131
+ if (body && typeof body === "object" && typeof body.error === "string") {
132
+ const detail = typeof body.detail === "string" ? ` (${body.detail})` : "";
133
+ return { error: `${body.error}${detail}` };
134
+ }
135
+ if (!body || !Array.isArray(body.datapacks)) {
136
+ return { error: "unrecognized response shape" };
137
+ }
138
+ const rows = body.datapacks.filter(
139
+ (e) => e && typeof e === "object" && typeof e.datapack_id === "string",
140
+ );
141
+ return { rows };
142
+ }
143
+
144
+ function search(client, args) {
145
+ return client.callTool("datapack_search", {
146
+ conversation_id: NIL_CONVERSATION_ID,
147
+ ...args,
148
+ });
149
+ }
150
+
151
+ /**
152
+ * The row named exactly `name`. An owned row is requested first, with
153
+ * `grant: "owner"`, so a page of newer shared copies of the same name
154
+ * cannot hide it. If the caller owns none, the first exact match of any
155
+ * grant is used.
156
+ */
157
+ async function findByName(client, name) {
158
+ const owned = parseDatapackSearch(
159
+ await search(client, { name, grant: "owner", limit: 100 }),
160
+ );
161
+ if (owned.error) return owned;
162
+ const ownedHit = owned.rows.find(
163
+ (d) => d.datapack_name === name && d.grant === "owner",
164
+ );
165
+ if (ownedHit) return { row: ownedHit };
166
+
167
+ const found = parseDatapackSearch(await search(client, { name, limit: 100 }));
168
+ if (found.error) return found;
169
+ const exact = found.rows.filter((d) => d.datapack_name === name);
170
+ return { row: exact[0] ?? null };
171
+ }
172
+
173
+ async function resolveWithSearch(client, requested) {
174
+ if (!requested) {
175
+ const def = await findByName(client, DEFAULT_DATAPACK_NAME);
176
+ if (def.error) {
177
+ return {
178
+ ok: false,
179
+ message: `datapack_search failed: ${def.error}. Cannot resolve --datapack.`,
180
+ };
181
+ }
182
+ const defaultRow = def.row;
183
+ if (defaultRow) {
184
+ return {
185
+ ok: true,
186
+ id: defaultRow.datapack_id,
187
+ name: defaultRow.datapack_name,
188
+ source: "default",
189
+ defaultId: defaultRow.datapack_id,
190
+ };
191
+ }
192
+ return { ok: true, id: null, name: null, source: "server-fallback", defaultId: null };
193
+ }
194
+
195
+ const found = await resolveRequested(client, requested);
196
+ if (!found.ok) return found;
197
+ const def = await defaultIdFor(client, requested, found.row);
198
+ if (def.error) {
199
+ return {
200
+ ok: false,
201
+ message:
202
+ `datapack_search failed: ${def.error}. Cannot tell whether --datapack ` +
203
+ `${requested} is your default datapack.`,
204
+ };
205
+ }
206
+ return {
207
+ ok: true,
208
+ id: found.row.datapack_id,
209
+ name: found.row.datapack_name ?? null,
210
+ source: found.source,
211
+ defaultId: def.id,
212
+ };
213
+ }
214
+
215
+ /**
216
+ * The id of the caller's `meko_default_datapack`, or null, for comparing with
217
+ * an explicitly requested datapack. A row with any other name can't be the
218
+ * default, so the lookup runs only when the requested datapack carries the
219
+ * default's name or its name is unknown. That keeps a failure in a lookup the
220
+ * request doesn't need from failing the request.
221
+ *
222
+ * @returns {Promise<{id: string|null} | {error: string}>}
223
+ */
224
+ async function defaultIdFor(client, requested, row) {
225
+ const name = row.datapack_name;
226
+ if (typeof name === "string" && name !== DEFAULT_DATAPACK_NAME) {
227
+ return { id: null };
228
+ }
229
+ // findByName already resolved the default's name, owner first.
230
+ if (requested === DEFAULT_DATAPACK_NAME) return { id: row.datapack_id };
231
+ const def = await findByName(client, DEFAULT_DATAPACK_NAME);
232
+ if (def.error) return def;
233
+ return { id: def.row?.datapack_id ?? null };
234
+ }
235
+
236
+ /**
237
+ * Resolve an explicit `--datapack` to `{ok: true, row, source}`, or to the
238
+ * `{ok: false, message}` the caller returns as is.
239
+ */
240
+ async function resolveRequested(client, requested) {
241
+ if (isUuid(requested)) {
242
+ const res = await client.callTool("datapack_describe", {
243
+ datapack_id: requested,
244
+ conversation_id: NIL_CONVERSATION_ID,
245
+ });
246
+ const hit = parseDescribe(res);
247
+ if (hit.error) {
248
+ return {
249
+ ok: false,
250
+ message:
251
+ `--datapack ${requested}: no datapack with that id is visible to ` +
252
+ `this API key (${hit.error}).`,
253
+ };
254
+ }
255
+ return { ok: true, row: hit.row, source: "flag-id" };
256
+ }
257
+
258
+ const byName = await findByName(client, requested);
259
+ if (byName.error) {
260
+ return {
261
+ ok: false,
262
+ message: `datapack_search failed: ${byName.error}. Cannot resolve --datapack.`,
263
+ };
264
+ }
265
+ if (!byName.row) {
266
+ const similar = parseDatapackSearch(
267
+ await search(client, { query: requested, limit: SUGGESTION_LIMIT }),
268
+ );
269
+ if (similar.error) {
270
+ return {
271
+ ok: false,
272
+ message: `datapack_search failed: ${similar.error}. Cannot resolve --datapack.`,
273
+ };
274
+ }
275
+ const hint = similar.rows.length
276
+ ? `Similar names: ${formatNames(similar.rows)}.`
277
+ : "No datapack name contains that text.";
278
+ return {
279
+ ok: false,
280
+ message:
281
+ `--datapack ${JSON.stringify(requested)}: no datapack with that name. ` +
282
+ hint,
283
+ };
284
+ }
285
+ return { ok: true, row: byName.row, source: "flag-name" };
286
+ }
287
+
288
+ /**
289
+ * Parse a `datapack_describe` body into `{row}` or `{error}`. Only the id
290
+ * and name are read; the body is never echoed.
291
+ */
292
+ function parseDescribe(res) {
293
+ if (!res.ok) return { error: res.error?.message ?? "unknown" };
294
+ let body;
295
+ try {
296
+ body = JSON.parse(res.text ?? "");
297
+ } catch {
298
+ return { error: "unrecognized response shape" };
299
+ }
300
+ if (body && typeof body === "object" && typeof body.error === "string") {
301
+ return { error: body.error };
302
+ }
303
+ if (!body || typeof body.datapack_id !== "string") {
304
+ return { error: "unrecognized response shape" };
305
+ }
306
+ return { row: body };
307
+ }
308
+
309
+ async function resolveWithListScan(client, requested) {
82
310
  const res = await client.callTool("datapack_list", {
83
311
  scope: "read",
84
- conversation_id: "00000000-0000-0000-0000-000000000000",
312
+ conversation_id: NIL_CONVERSATION_ID,
85
313
  });
86
314
  if (!res.ok) {
87
315
  return {
@@ -101,7 +329,7 @@ export async function resolveDestinationDatapack(opts) {
101
329
  };
102
330
  }
103
331
 
104
- const defaultRow = list.find((d) => d.datapack_name === DEFAULT_DATAPACK_NAME);
332
+ const defaultRow = namedRow(list, DEFAULT_DATAPACK_NAME);
105
333
  const defaultId = defaultRow?.datapack_id ?? null;
106
334
 
107
335
  if (!requested) {
@@ -136,7 +364,7 @@ export async function resolveDestinationDatapack(opts) {
136
364
  };
137
365
  }
138
366
 
139
- const hit = list.find((d) => d.datapack_name === requested);
367
+ const hit = namedRow(list, requested);
140
368
  if (!hit) {
141
369
  return {
142
370
  ok: false,
@@ -154,14 +382,24 @@ export async function resolveDestinationDatapack(opts) {
154
382
  };
155
383
  }
156
384
 
385
+ function namedRow(list, name) {
386
+ const exact = list.filter((d) => d.datapack_name === name);
387
+ return exact.find((d) => d.grant === "owner") ?? exact[0] ?? null;
388
+ }
389
+
157
390
  /**
158
391
  * Format a one-line "Available: a, b, c" hint. Names only — never ids,
159
392
  * because the underlying response can carry credentials.
160
393
  */
161
394
  function formatAvailable(list) {
162
- const names = list
395
+ const names = formatNames(list);
396
+ if (!names) return "(no datapacks visible to this API key.)";
397
+ return `Available: ${names}.`;
398
+ }
399
+
400
+ function formatNames(list) {
401
+ return list
163
402
  .map((d) => d.datapack_name)
164
- .filter((n) => typeof n === "string" && n.length > 0);
165
- if (names.length === 0) return "(no datapacks visible to this API key.)";
166
- return `Available: ${names.join(", ")}.`;
403
+ .filter((n) => typeof n === "string" && n.length > 0)
404
+ .join(", ");
167
405
  }
@@ -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", "codex", "kiro"]);
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, Codex, Kiro) → `<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
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,13 +269,19 @@ 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",
131
- cwd,
284
+ repoRoot: cwd,
132
285
  envOverride: process.env.MEKO_AGENT_ID,
133
286
  });
134
287
  }
@@ -137,24 +290,53 @@ export function agentIdForCursor(cwd) {
137
290
  * @param {string} [cwd] Absolute project cwd of the Codex session (from the
138
291
  * rollout's `session_meta.payload.cwd`). Omit for user-scope writes
139
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.
140
301
  * @returns {string}
141
302
  */
142
- export function agentIdForCodex(cwd) {
303
+ export function agentIdForCodex(cwd, opts = {}) {
143
304
  if (!cwd) return COMMON_BUCKET_AGENT_ID;
144
305
  return deriveAgentId({
145
306
  client: "codex",
146
307
  cwd,
147
308
  envOverride: process.env.MEKO_AGENT_ID,
309
+ ...(opts.historical ? { repoRoot: repoRootForHistoricalCwd(cwd) } : {}),
148
310
  });
149
311
  }
150
312
 
151
- /** Derive the project-scoped owner used by Kiro hook capture. */
152
- export function agentIdForKiro(cwd) {
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 = {}) {
153
323
  if (!cwd) return COMMON_BUCKET_AGENT_ID;
154
324
  return deriveAgentId({
155
325
  client: "kiro",
156
326
  cwd,
157
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",
337
+ cwd,
338
+ envOverride: process.env.MEKO_AGENT_ID,
339
+ ...(opts.historical ? { repoRoot: repoRootForHistoricalCwd(cwd) } : {}),
158
340
  });
159
341
  }
160
342
 
@@ -79,6 +79,40 @@ export class McpClient {
79
79
  }
80
80
  }
81
81
 
82
+ /**
83
+ * Names of the tools the server lists over the open session. Throws when
84
+ * the request fails, so callers can decide how to degrade.
85
+ *
86
+ * @param {{timeoutMs?: number}} [opts]
87
+ * @returns {Promise<string[]>}
88
+ */
89
+ async listTools(opts = {}) {
90
+ await this._acquire();
91
+ try {
92
+ const res = await jsonRpcRequest(
93
+ this.url,
94
+ { jsonrpc: "2.0", id: this._reqId++, method: "tools/list", params: {} },
95
+ this.apiKey,
96
+ this.sessionId,
97
+ { timeoutMs: opts.timeoutMs ?? this.timeoutMs },
98
+ );
99
+ if (res.statusCode < 200 || res.statusCode >= 300) {
100
+ const e = new Error(`tools/list returned HTTP ${res.statusCode}`);
101
+ e.statusCode = res.statusCode;
102
+ throw e;
103
+ }
104
+ const parsed = JSON.parse(res.body);
105
+ if (parsed.error) {
106
+ throw new Error(`tools/list error: ${parsed.error.message ?? "unknown"}`);
107
+ }
108
+ const tools = parsed.result?.tools;
109
+ if (!Array.isArray(tools)) throw new Error("tools/list returned no tools array");
110
+ return tools.map((t) => t?.name).filter((n) => typeof n === "string");
111
+ } finally {
112
+ this._release();
113
+ }
114
+ }
115
+
82
116
  async _callWithRetry(name, args, opts) {
83
117
  const maxRetries = opts.maxRetries ?? 3;
84
118
  let lastErr = null;
@@ -93,7 +127,31 @@ export class McpClient {
93
127
  lastErr = err;
94
128
  const code = err.statusCode;
95
129
  if (code === 401 || code === 403) {
96
- // Auth is fatal; surface immediately.
130
+ // Auth errors are never worth retrying: the same credential will
131
+ // be rejected again. Whether they're fatal depends on the caller.
132
+ //
133
+ // A fire-and-forget call is one whose loss the run can absorb
134
+ // (conversation_add_message, memory_add). A token that expires
135
+ // partway through a multi-hour migration used to kill the whole
136
+ // run from inside one such call; now the call is dropped, the skip
137
+ // is recorded, and the run finishes with the gap visible.
138
+ //
139
+ // The initial auth probe is `connect()`, which does not come
140
+ // through here, so a bad key still aborts before any work starts.
141
+ if (opts.fireAndForget) {
142
+ this.log.warn?.("mcp_skipped_auth", {
143
+ tool: name,
144
+ statusCode: code,
145
+ error: err.message,
146
+ });
147
+ return {
148
+ ok: false,
149
+ skipped: true,
150
+ authSkipped: true,
151
+ statusCode: code,
152
+ error: { message: err.message },
153
+ };
154
+ }
97
155
  throw err;
98
156
  }
99
157
  if (attempt < maxRetries) {