@yugabytedb/meko-mcp 2.2.0 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +168 -61
- package/assets/hooks-handlers/lib/capture-cleanup-cli.js +2134 -0
- package/assets/hooks-handlers/lib/capture-notices.js +224 -14
- package/assets/hooks-handlers/lib/capture-state.js +206 -1
- package/assets/hooks-handlers/lib/capture-status-cli.js +6 -2
- package/assets/hooks-handlers/lib/capture.js +1364 -139
- package/assets/hooks-handlers/lib/checkpoint-timer.js +445 -18
- package/assets/hooks-handlers/lib/journal-bridge.js +218 -0
- package/assets/hooks-handlers/lib/kiro-handler.js +26 -4
- package/assets/hooks-handlers/lib/opencode-handler.js +643 -0
- package/assets/hooks-handlers/lib/opencode-normalizer.js +145 -0
- package/assets/hooks-handlers/lib/terminal-handoff.js +10 -1
- package/assets/opencode-plugin/meko.js +213 -0
- package/assets/scripts/meko-statusline.sh +102 -19
- package/assets/skills/meko-mcp-tools/SKILL.md +59 -16
- package/assets/skills/meko-mcp-tools/references/_sections.md +2 -2
- package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +15 -7
- package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +275 -19
- package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +7 -4
- package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +39 -11
- package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +10 -6
- package/assets/skills/meko-mcp-tools/references/tools-overview.md +44 -29
- package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +79 -14
- package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +47 -1
- package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +6 -39
- package/assets/skills/meko-mcp-tools-desktop/SKILL.md +57 -21
- package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +4 -4
- package/assets/skills/meko-mcp-tools-desktop/references/_sections.md +1 -1
- package/assets/skills/meko-mcp-tools-desktop/references/tools-agent-id-conventions.md +8 -6
- package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +284 -38
- package/assets/skills/meko-mcp-tools-desktop/references/tools-known-limitations.md +32 -10
- package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +16 -12
- package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +46 -31
- package/assets/skills/meko-mcp-tools-desktop/references/tools-rag-workflow.md +79 -14
- package/assets/skills/meko-mcp-tools-desktop/references/tools-troubleshooting.md +69 -1
- package/assets/skills/meko-select-datapack-desktop/SKILL.md +93 -14
- package/assets/skills/meko-select-datapack-desktop/references/cookbook.md +44 -12
- package/assets/skills/meko-select-datapack-desktop/references/role-display-future.md +14 -10
- package/assets/skills/meko-select-datapack-desktop/references/troubleshooting.md +17 -5
- package/bin/create-meko-setup.mjs +186 -30
- package/lib/autofix.mjs +100 -0
- package/lib/browser-login.mjs +495 -0
- package/lib/capture-cleanup.mjs +63 -0
- package/lib/clients/claude-code.mjs +1 -0
- package/lib/clients/codex.mjs +80 -35
- package/lib/clients/common.mjs +7 -0
- package/lib/clients/index.mjs +2 -0
- package/lib/clients/kiro.mjs +5 -5
- package/lib/clients/opencode.mjs +537 -0
- package/lib/config.mjs +68 -1
- package/lib/installer.mjs +18 -2
- package/lib/migrate/adapters/claude-code.mjs +11 -2
- package/lib/migrate/adapters/codex.mjs +9 -2
- package/lib/migrate/adapters/memory-md.mjs +27 -8
- package/lib/migrate/adapters/opencode.mjs +148 -0
- package/lib/migrate/claude-parser.mjs +153 -9
- package/lib/migrate/datapack.mjs +254 -16
- package/lib/migrate/id.mjs +195 -13
- package/lib/migrate/mcp-client.mjs +59 -1
- package/lib/migrate/opencode-normalizer.mjs +132 -0
- package/lib/migrate/orchestrator.mjs +294 -78
- package/lib/migrate/progress.mjs +239 -5
- package/lib/migrate/registry.mjs +72 -0
- package/lib/migrate.mjs +97 -9
- package/lib/validate.mjs +111 -22
- package/package.json +5 -5
- package/scripts/bundle-assets.mjs +4 -0
package/lib/migrate/datapack.mjs
CHANGED
|
@@ -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
|
-
*
|
|
12
|
-
*
|
|
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
|
|
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
|
|
72
|
-
*
|
|
73
|
-
*
|
|
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
|
-
* -
|
|
77
|
-
* - response body wasn't
|
|
78
|
-
* - explicit override didn't match any
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
166
|
-
return `Available: ${names.join(", ")}.`;
|
|
403
|
+
.filter((n) => typeof n === "string" && n.length > 0)
|
|
404
|
+
.join(", ");
|
|
167
405
|
}
|
package/lib/migrate/id.mjs
CHANGED
|
@@ -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) →
|
|
71
|
-
*
|
|
72
|
-
*
|
|
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
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
/**
|
|
152
|
-
|
|
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
|
|
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) {
|