@yugabytedb/meko-mcp 1.0.8 → 1.2.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 +242 -44
- package/assets/hooks-handlers/before-submit-prompt.sh +44 -0
- package/assets/hooks-handlers/cursor-session-start.sh +42 -0
- package/assets/hooks-handlers/lib/capture.js +457 -107
- package/assets/hooks-handlers/lib/package.json +1 -0
- package/assets/hooks-handlers/test_single_conversation.sh +1 -1
- package/assets/scripts/package-desktop-skill.sh +36 -0
- package/assets/skills/meko-mcp-tools/SKILL.md +167 -53
- package/assets/skills/meko-mcp-tools/references/_sections.md +1 -1
- package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +84 -22
- package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +89 -56
- package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +15 -7
- package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +17 -9
- package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +94 -5
- package/assets/skills/meko-mcp-tools/references/tools-overview.md +52 -37
- package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +108 -37
- package/assets/skills/meko-mcp-tools/references/tools-scope-permissions.md +6 -10
- package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +30 -18
- package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +55 -69
- package/assets/skills/meko-mcp-tools-desktop/SKILL.md +251 -0
- package/assets/skills/meko-mcp-tools-desktop/desktop/README.md +150 -0
- package/assets/skills/meko-mcp-tools-desktop/references/_sections.md +24 -0
- package/assets/skills/meko-mcp-tools-desktop/references/tools-agent-id-conventions.md +99 -0
- package/assets/skills/meko-mcp-tools-desktop/references/tools-cookbook.md +406 -0
- package/assets/skills/meko-mcp-tools-desktop/references/tools-known-limitations.md +69 -0
- package/assets/skills/meko-mcp-tools-desktop/references/tools-memory-vs-conversation.md +217 -0
- package/assets/skills/meko-mcp-tools-desktop/references/tools-overview.md +112 -0
- package/assets/skills/meko-mcp-tools-desktop/references/tools-rag-workflow.md +134 -0
- package/assets/skills/meko-mcp-tools-desktop/references/tools-scope-permissions.md +75 -0
- package/assets/skills/meko-mcp-tools-desktop/references/tools-troubleshooting.md +155 -0
- package/bin/create-meko-setup.mjs +151 -22
- package/lib/clients/claude-code.mjs +378 -68
- package/lib/clients/claude-desktop.mjs +45 -8
- package/lib/clients/codex.mjs +1209 -0
- package/lib/clients/common.mjs +223 -1
- package/lib/clients/cursor.mjs +118 -13
- package/lib/clients/index.mjs +2 -0
- package/lib/config.mjs +56 -0
- package/lib/detect.mjs +52 -44
- package/lib/installer.mjs +11 -4
- package/lib/mcp-transport.mjs +61 -2
- package/lib/migrate/adapters/memory-md.mjs +13 -6
- package/lib/migrate/datapack.mjs +167 -0
- package/lib/migrate/id.mjs +127 -9
- package/lib/migrate/orchestrator.mjs +68 -10
- package/lib/migrate.mjs +346 -3
- package/lib/prompt.mjs +22 -4
- package/lib/validate.mjs +313 -9
- package/lib/version-check.mjs +178 -0
- package/package.json +2 -1
- package/scripts/bundle-assets.mjs +8 -0
|
@@ -14,6 +14,7 @@ import * as lockfile from "./lockfile.mjs";
|
|
|
14
14
|
import { McpClient } from "./mcp-client.mjs";
|
|
15
15
|
import { createLogger, nullLogger } from "./logger.mjs";
|
|
16
16
|
import { seedFor } from "./id.mjs";
|
|
17
|
+
import { resolveDestinationDatapack } from "./datapack.mjs";
|
|
17
18
|
import * as claudeCodeAdapter from "./adapters/claude-code.mjs";
|
|
18
19
|
import * as cursorAdapter from "./adapters/cursor.mjs";
|
|
19
20
|
import * as memoryMdAdapter from "./adapters/memory-md.mjs";
|
|
@@ -34,6 +35,14 @@ import * as memoryMdAdapter from "./adapters/memory-md.mjs";
|
|
|
34
35
|
* @param {boolean} [opts.lockAlreadyHeld] Caller (e.g. detached child) says the lock is already owned by this process; skip acquire.
|
|
35
36
|
* @param {number} [opts.concurrency]
|
|
36
37
|
* @param {string} [opts.home]
|
|
38
|
+
* @param {string|null} [opts.datapack] Raw `--datapack` value (UUID or
|
|
39
|
+
* name). Resolved against the
|
|
40
|
+
* server before any writes; the
|
|
41
|
+
* resolved id is then injected
|
|
42
|
+
* into every tool call so the
|
|
43
|
+
* destination is explicit on the
|
|
44
|
+
* wire rather than relying on the
|
|
45
|
+
* server's tenant-context fallback.
|
|
37
46
|
* @returns {Promise<{ok: boolean, summary: object, counts: object}>}
|
|
38
47
|
*/
|
|
39
48
|
export async function runMigration(opts) {
|
|
@@ -160,6 +169,54 @@ export async function runMigration(opts) {
|
|
|
160
169
|
return { ok: false, summary: { error: "mcp_connect_failed", detail: err.message }, counts: prog.counts };
|
|
161
170
|
}
|
|
162
171
|
|
|
172
|
+
// Resolve --datapack against the live datapack_list so every subsequent
|
|
173
|
+
// tools/call carries an explicit datapack_id. Without this we'd be silently
|
|
174
|
+
// relying on the server's tenant-context default — fine when it's correct,
|
|
175
|
+
// invisible when it isn't.
|
|
176
|
+
let datapackId = null;
|
|
177
|
+
try {
|
|
178
|
+
const resolved = await resolveDestinationDatapack({
|
|
179
|
+
client,
|
|
180
|
+
requested: opts.datapack ?? null,
|
|
181
|
+
});
|
|
182
|
+
if (!resolved.ok) {
|
|
183
|
+
log.error("resolve_datapack_failed", { message: resolved.message });
|
|
184
|
+
releaseLock();
|
|
185
|
+
prog.status = "failed";
|
|
186
|
+
prog.finishedAt = new Date().toISOString();
|
|
187
|
+
writeProgress();
|
|
188
|
+
return {
|
|
189
|
+
ok: false,
|
|
190
|
+
summary: { error: "resolve_datapack_failed", detail: resolved.message },
|
|
191
|
+
counts: prog.counts,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
datapackId = resolved.id ?? null;
|
|
195
|
+
log.info("migration_target", {
|
|
196
|
+
requested: opts.datapack ?? null,
|
|
197
|
+
resolvedId: resolved.id ?? null,
|
|
198
|
+
resolvedName: resolved.name ?? null,
|
|
199
|
+
source: resolved.source,
|
|
200
|
+
});
|
|
201
|
+
} catch (err) {
|
|
202
|
+
log.error("resolve_datapack_threw", { error: err.message });
|
|
203
|
+
releaseLock();
|
|
204
|
+
prog.status = "failed";
|
|
205
|
+
prog.finishedAt = new Date().toISOString();
|
|
206
|
+
writeProgress();
|
|
207
|
+
return {
|
|
208
|
+
ok: false,
|
|
209
|
+
summary: { error: "resolve_datapack_threw", detail: err.message },
|
|
210
|
+
counts: prog.counts,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Injects datapack_id into every tools/call args object when set. When
|
|
215
|
+
// resolution fell into 'server-fallback' (no row matched), datapackId is
|
|
216
|
+
// null and we omit the key entirely so the server's own fallback chain
|
|
217
|
+
// still applies — the migration would otherwise fail before doing any work.
|
|
218
|
+
const withDp = (args) => (datapackId ? { ...args, datapack_id: datapackId } : args);
|
|
219
|
+
|
|
163
220
|
// -- Conversations --
|
|
164
221
|
for (const ref of [...claudeSessions, ...cursorSessions]) {
|
|
165
222
|
let existing = state.getSession(st, ref.agentId, ref.sessionUuid);
|
|
@@ -197,7 +254,7 @@ export async function runMigration(opts) {
|
|
|
197
254
|
// Ensure a conversation_id exists.
|
|
198
255
|
let conversationId = existing?.conversationId || "";
|
|
199
256
|
if (!conversationId) {
|
|
200
|
-
const createRes = await safeCall(client, "conversation_create", {
|
|
257
|
+
const createRes = await safeCall(client, "conversation_create", withDp({
|
|
201
258
|
scope: "write",
|
|
202
259
|
agent_id: ref.agentId,
|
|
203
260
|
session_id: ref.sessionUuid,
|
|
@@ -208,7 +265,7 @@ export async function runMigration(opts) {
|
|
|
208
265
|
gitBranch: ref.slug ? (parsed.gitBranch || null) : null,
|
|
209
266
|
startedAt: parsed.startedAt || null,
|
|
210
267
|
}),
|
|
211
|
-
}, log);
|
|
268
|
+
}), log);
|
|
212
269
|
|
|
213
270
|
if (!createRes.ok) {
|
|
214
271
|
log.error("conversation_create_failed", { agentId: ref.agentId, sessionUuid: ref.sessionUuid, error: createRes.error?.message });
|
|
@@ -245,7 +302,7 @@ export async function runMigration(opts) {
|
|
|
245
302
|
const seed = seedFor(ref.agentId, ref.sessionUuid, ex.user_uuid);
|
|
246
303
|
|
|
247
304
|
// (a) conversation_add_message — fire-and-forget; server batches.
|
|
248
|
-
const addMsgPromise = client.callTool("conversation_add_message", {
|
|
305
|
+
const addMsgPromise = client.callTool("conversation_add_message", withDp({
|
|
249
306
|
scope: "write",
|
|
250
307
|
conversation_id: conversationId,
|
|
251
308
|
agent_id: ref.agentId,
|
|
@@ -259,12 +316,12 @@ export async function runMigration(opts) {
|
|
|
259
316
|
userUuid: ex.user_uuid,
|
|
260
317
|
timestamp: ex.timestamp,
|
|
261
318
|
}),
|
|
262
|
-
}, { fireAndForget: true });
|
|
319
|
+
}), { fireAndForget: true });
|
|
263
320
|
|
|
264
321
|
// (b) memory_add(messages=...) — invokes Mem0 extractor server-side.
|
|
265
322
|
let memPromise = Promise.resolve(null);
|
|
266
323
|
if (!opts.noExtract && (ex.input || ex.output)) {
|
|
267
|
-
memPromise = client.callTool("memory_add", {
|
|
324
|
+
memPromise = client.callTool("memory_add", withDp({
|
|
268
325
|
scope: "write",
|
|
269
326
|
agent_id: ref.agentId,
|
|
270
327
|
conversation_id: conversationId,
|
|
@@ -276,8 +333,9 @@ export async function runMigration(opts) {
|
|
|
276
333
|
source: "migrate",
|
|
277
334
|
sessionUuid: ref.sessionUuid,
|
|
278
335
|
userUuid: ex.user_uuid,
|
|
336
|
+
cwd: ref.cwd || null,
|
|
279
337
|
}),
|
|
280
|
-
}, { fireAndForget: true });
|
|
338
|
+
}), { fireAndForget: true });
|
|
281
339
|
}
|
|
282
340
|
|
|
283
341
|
const [, memRes] = await Promise.all([addMsgPromise, memPromise]);
|
|
@@ -342,12 +400,12 @@ export async function runMigration(opts) {
|
|
|
342
400
|
if (existingUmbrella) {
|
|
343
401
|
convId = existingUmbrella;
|
|
344
402
|
} else {
|
|
345
|
-
const res = await safeCall(client, "conversation_create", {
|
|
403
|
+
const res = await safeCall(client, "conversation_create", withDp({
|
|
346
404
|
scope: "write",
|
|
347
405
|
agent_id: item.agentId,
|
|
348
406
|
title: "memory-migration",
|
|
349
407
|
metadata: JSON.stringify({ source: "migrate", purpose: "memory-umbrella" }),
|
|
350
|
-
}, log);
|
|
408
|
+
}), log);
|
|
351
409
|
if (!res.ok) {
|
|
352
410
|
log.error("memory_umbrella_failed", { agentId: item.agentId, error: res.error?.message });
|
|
353
411
|
prog.counts.warnings++;
|
|
@@ -359,13 +417,13 @@ export async function runMigration(opts) {
|
|
|
359
417
|
umbrellaPerAgent.set(item.agentId, convId);
|
|
360
418
|
}
|
|
361
419
|
|
|
362
|
-
const res = await client.callTool("memory_add", {
|
|
420
|
+
const res = await client.callTool("memory_add", withDp({
|
|
363
421
|
scope: "write",
|
|
364
422
|
agent_id: item.agentId,
|
|
365
423
|
conversation_id: convId,
|
|
366
424
|
text: item.text,
|
|
367
425
|
metadata: JSON.stringify(item.metadata),
|
|
368
|
-
}, { fireAndForget: true });
|
|
426
|
+
}), { fireAndForget: true });
|
|
369
427
|
|
|
370
428
|
if (res.ok && !isErrorText(res.text)) {
|
|
371
429
|
state.markMemory(st, item.agentId, item.hash, {
|
package/lib/migrate.mjs
CHANGED
|
@@ -17,17 +17,43 @@ import { createLogger } from "./migrate/logger.mjs";
|
|
|
17
17
|
import { generateRunId, forkDetached, migrateRoot } from "./migrate/daemon.mjs";
|
|
18
18
|
import * as lockfile from "./migrate/lockfile.mjs";
|
|
19
19
|
import * as progress from "./migrate/progress.mjs";
|
|
20
|
+
import { datapackKeyFromUrl } from "./migrate/id.mjs";
|
|
21
|
+
import { resolveDestinationDatapack } from "./migrate/datapack.mjs";
|
|
22
|
+
import { McpClient } from "./migrate/mcp-client.mjs";
|
|
20
23
|
import { selfInvocation } from "./clients/common.mjs";
|
|
24
|
+
import { askYesNo } from "./prompt.mjs";
|
|
21
25
|
|
|
22
26
|
function home() {
|
|
23
27
|
return process.env.HOME || "";
|
|
24
28
|
}
|
|
25
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Build on-disk paths for a migration run.
|
|
32
|
+
*
|
|
33
|
+
* The `statePath` is scoped by `datapackKey` so switching datapacks does not
|
|
34
|
+
* make the migrator falsely skip every session based on checkpoints from a
|
|
35
|
+
* different datapack. See issue #45 for the regression that motivated this.
|
|
36
|
+
*
|
|
37
|
+
* When `datapackKey` is omitted, `statePath` points at the legacy top-level
|
|
38
|
+
* `state.json` — only used for read paths that don't know the datapack yet
|
|
39
|
+
* (e.g. `readStatus` when run-id metadata doesn't record it). New writes
|
|
40
|
+
* should always supply a key.
|
|
41
|
+
*
|
|
42
|
+
* @param {object} [opts]
|
|
43
|
+
* @param {string} [opts.home]
|
|
44
|
+
* @param {string} [opts.datapackKey]
|
|
45
|
+
*/
|
|
26
46
|
function paths(opts = {}) {
|
|
27
47
|
const root = migrateRoot({ home: opts.home ?? home() });
|
|
48
|
+
const stateDir = path.join(root, "state");
|
|
49
|
+
const statePath = opts.datapackKey
|
|
50
|
+
? path.join(stateDir, `${opts.datapackKey}.json`)
|
|
51
|
+
: path.join(root, "state.json");
|
|
28
52
|
return {
|
|
29
53
|
root,
|
|
30
|
-
|
|
54
|
+
stateDir,
|
|
55
|
+
legacyStatePath: path.join(root, "state.json"),
|
|
56
|
+
statePath,
|
|
31
57
|
lockPath: path.join(root, "lock"),
|
|
32
58
|
runDir: (runId) => path.join(root, runId),
|
|
33
59
|
logFile: (runId) => path.join(root, runId, "stdout.log"),
|
|
@@ -35,6 +61,108 @@ function paths(opts = {}) {
|
|
|
35
61
|
};
|
|
36
62
|
}
|
|
37
63
|
|
|
64
|
+
/**
|
|
65
|
+
* One-time migration of the legacy global `state.json` into the per-datapack
|
|
66
|
+
* `state/<key>.json` layout. Called on the first run after upgrade.
|
|
67
|
+
*
|
|
68
|
+
* We keep the legacy file on disk (don't delete it) so a rollback to an
|
|
69
|
+
* older installer keeps resume state visible there. Future runs ignore
|
|
70
|
+
* `state.json` entirely once `state/` is populated.
|
|
71
|
+
*
|
|
72
|
+
* Safe to call on every run — a no-op if there's nothing to migrate.
|
|
73
|
+
*
|
|
74
|
+
* @param {object} opts
|
|
75
|
+
* @param {ReturnType<typeof paths>} opts.P Already-constructed paths bundle
|
|
76
|
+
* whose `statePath` is the per-key
|
|
77
|
+
* destination.
|
|
78
|
+
* @param {string} opts.datapackKey
|
|
79
|
+
* @param {boolean} [opts.interactive] Whether to prompt; false on CI / --yes.
|
|
80
|
+
* @param {(entry: object) => void} [opts.onWarning] Hook for the orchestrator
|
|
81
|
+
* to log a structured `warnings++`
|
|
82
|
+
* entry. Not invoked on no-op.
|
|
83
|
+
* @returns {Promise<{migrated: boolean, from?: string, to?: string, declined?: boolean}>}
|
|
84
|
+
*/
|
|
85
|
+
async function migrateLegacyStateIfNeeded(opts) {
|
|
86
|
+
const { P, datapackKey } = opts;
|
|
87
|
+
// We've already crossed this bridge iff `state/` holds any `.json` file.
|
|
88
|
+
// Plain emptiness isn't enough: a stray `.DS_Store` (macOS) or editor
|
|
89
|
+
// swap file would make `readdirSync().length === 0` return false and
|
|
90
|
+
// silently skip the one-time copy, stranding the user's pre-upgrade
|
|
91
|
+
// resume state under `state.json`.
|
|
92
|
+
let hasScopedState = false;
|
|
93
|
+
try {
|
|
94
|
+
hasScopedState = fs.readdirSync(P.stateDir, { withFileTypes: true })
|
|
95
|
+
.some((entry) => entry.isFile() && entry.name.endsWith(".json"));
|
|
96
|
+
} catch {
|
|
97
|
+
// ENOENT (dir doesn't exist yet) → trivially no scoped state.
|
|
98
|
+
}
|
|
99
|
+
if (hasScopedState) return { migrated: false };
|
|
100
|
+
if (!fs.existsSync(P.legacyStatePath)) return { migrated: false };
|
|
101
|
+
|
|
102
|
+
// Legacy file exists and state/ is empty → one-time copy.
|
|
103
|
+
if (opts.interactive) {
|
|
104
|
+
const accept = await askYesNo(
|
|
105
|
+
`Legacy global migrate state found. Treating it as belonging to the current datapack ${datapackKey}?`,
|
|
106
|
+
true,
|
|
107
|
+
);
|
|
108
|
+
if (!accept) {
|
|
109
|
+
// User declined: don't copy. Future runs will see state/ empty again
|
|
110
|
+
// and re-offer — but since state/<key>.json will likely be created by
|
|
111
|
+
// this very run, the prompt won't repeat.
|
|
112
|
+
return { migrated: false, declined: true };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
fs.mkdirSync(P.stateDir, { recursive: true });
|
|
117
|
+
fs.copyFileSync(P.legacyStatePath, P.statePath);
|
|
118
|
+
if (typeof opts.onWarning === "function") {
|
|
119
|
+
opts.onWarning({
|
|
120
|
+
event: "legacy_state_migrated",
|
|
121
|
+
from: P.legacyStatePath,
|
|
122
|
+
to: P.statePath,
|
|
123
|
+
datapackKey,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return { migrated: true, from: P.legacyStatePath, to: P.statePath };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Choose the on-disk state key for a real run.
|
|
131
|
+
*
|
|
132
|
+
* Default (no --datapack) keeps the URL-derived key — that's what existing
|
|
133
|
+
* installs already wrote into. An explicit `--datapack` that resolved to a
|
|
134
|
+
* *non-default* id is scoped by `dp-<resolved-id>` so two runs against the
|
|
135
|
+
* same URL with different packs don't share resume state and silently skip
|
|
136
|
+
* work.
|
|
137
|
+
*
|
|
138
|
+
* If the explicit value resolved to the same datapack the default flow
|
|
139
|
+
* targets (i.e. the user typed the default's UUID or the literal
|
|
140
|
+
* `meko_default_datapack`), collapse back onto the URL key so the explicit
|
|
141
|
+
* and implicit forms share resume state and don't reprocess every session
|
|
142
|
+
* from a fresh file. Without this collapse, a user who runs the default
|
|
143
|
+
* migration and later re-runs with `--datapack <default-uuid>` would
|
|
144
|
+
* silently re-extract every memory.
|
|
145
|
+
*
|
|
146
|
+
* Falls back to the URL key when the resolver couldn't pin an id
|
|
147
|
+
* (server-fallback / no API-side default row).
|
|
148
|
+
*
|
|
149
|
+
* @param {object} opts
|
|
150
|
+
* @param {string} opts.urlDatapackKey
|
|
151
|
+
* @param {string|null} opts.requested Raw --datapack value (or null).
|
|
152
|
+
* @param {string|null} opts.resolvedId Resolver output (or null).
|
|
153
|
+
* @param {string|null} [opts.defaultId] meko_default_datapack id, when known.
|
|
154
|
+
* @returns {string}
|
|
155
|
+
*/
|
|
156
|
+
export function stateKeyFor({ urlDatapackKey, requested, resolvedId, defaultId }) {
|
|
157
|
+
if (!requested || !resolvedId) return urlDatapackKey;
|
|
158
|
+
// Explicit request that resolves to the user's default datapack: stay on
|
|
159
|
+
// the URL-keyed file the implicit-default flow uses.
|
|
160
|
+
if (defaultId && resolvedId.toLowerCase() === defaultId.toLowerCase()) {
|
|
161
|
+
return urlDatapackKey;
|
|
162
|
+
}
|
|
163
|
+
return `dp-${resolvedId.toLowerCase()}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
38
166
|
function findLatestRunId(root) {
|
|
39
167
|
try {
|
|
40
168
|
const dirs = fs.readdirSync(root, { withFileTypes: true })
|
|
@@ -61,10 +189,29 @@ function findLatestRunId(root) {
|
|
|
61
189
|
* @param {boolean} [opts.noExtract]
|
|
62
190
|
* @param {boolean} [opts.force]
|
|
63
191
|
* @param {number} [opts.rate]
|
|
192
|
+
* @param {boolean} [opts.nonInteractive] Set by callers that lack a TTY
|
|
193
|
+
* (e.g. --yes, CI). Skips prompts.
|
|
194
|
+
* @param {string|null} [opts.datapack] Raw `--datapack` value (UUID or
|
|
195
|
+
* name). Resolved against the server
|
|
196
|
+
* before the lock is taken so a typo
|
|
197
|
+
* fails fast.
|
|
64
198
|
* @returns {Promise<{ok: boolean, runId?: string, message: string}>}
|
|
65
199
|
*/
|
|
66
200
|
export async function startMigration(opts) {
|
|
67
|
-
|
|
201
|
+
// Real runs scope state by destination datapack so switching datapacks
|
|
202
|
+
// doesn't falsely flag every session as already-done. Two layers:
|
|
203
|
+
// - URL-derived key: stable enough for the default flow (cloud URLs
|
|
204
|
+
// already encode the default datapack UUID in their hostname; see
|
|
205
|
+
// issue #45 for the regression that motivated URL scoping).
|
|
206
|
+
// - When --datapack is explicit and resolves to a real id, scope by
|
|
207
|
+
// that id instead. Same URL + different --datapack values land in
|
|
208
|
+
// different state files, so an alt-pack run after a default-pack
|
|
209
|
+
// run doesn't silently skip every session marked done by the first.
|
|
210
|
+
// Until preflight runs we only have the URL-derived key; that's enough
|
|
211
|
+
// for the dry-run branch and for sizing P.root / P.lockPath, which
|
|
212
|
+
// don't move when the state key changes.
|
|
213
|
+
const urlDatapackKey = datapackKeyFromUrl(opts.url);
|
|
214
|
+
let P = paths({ datapackKey: urlDatapackKey });
|
|
68
215
|
fs.mkdirSync(P.root, { recursive: true });
|
|
69
216
|
|
|
70
217
|
// Dry-run is a local-only planning pass: no HTTP, no lock, no fork.
|
|
@@ -87,12 +234,19 @@ export async function startMigration(opts) {
|
|
|
87
234
|
});
|
|
88
235
|
const s = summary.summary;
|
|
89
236
|
const planned = s.plannedCalls ?? {};
|
|
237
|
+
// Dry-run never opens an MCP session, so we can't resolve names→UUIDs
|
|
238
|
+
// here. Echo what the user asked for; the real run will resolve and
|
|
239
|
+
// print the canonical destination before doing any work.
|
|
240
|
+
const destLine = opts.datapack
|
|
241
|
+
? ` Destination datapack: ${opts.datapack} (will be resolved at run time)\n`
|
|
242
|
+
: " Destination datapack: server default (will be resolved at run time)\n";
|
|
90
243
|
return {
|
|
91
244
|
ok: summary.ok,
|
|
92
245
|
runId,
|
|
93
246
|
message:
|
|
94
247
|
(opts.url ? "" : "(no --url provided — planning only)\n") +
|
|
95
248
|
`Dry run:\n` +
|
|
249
|
+
destLine +
|
|
96
250
|
` Claude Code sessions: ${s.claudeSessions}\n` +
|
|
97
251
|
` Cursor sessions: ${s.cursorSessions}\n` +
|
|
98
252
|
` Paired user↔asst turns: ${s.pairedTurns}\n` +
|
|
@@ -114,6 +268,49 @@ export async function startMigration(opts) {
|
|
|
114
268
|
return { ok: false, message: "--api-key (or MEKO_API_KEY) is required for cloud --migrate." };
|
|
115
269
|
}
|
|
116
270
|
|
|
271
|
+
// Pre-flight: resolve the destination datapack *before* taking the lock or
|
|
272
|
+
// forking the child. A typo in --datapack should fail fast with a useful
|
|
273
|
+
// error, not after the child has spun up. We open a short-lived MCP client
|
|
274
|
+
// here purely for the datapack_list call; the child opens its own session.
|
|
275
|
+
// The resolved id isn't persisted into passthrough — the child re-resolves,
|
|
276
|
+
// which costs one extra cheap read but keeps the wire-level routing
|
|
277
|
+
// logic in a single place.
|
|
278
|
+
const preflight = await preflightDatapack({
|
|
279
|
+
url: opts.url,
|
|
280
|
+
apiKey: opts.apiKey,
|
|
281
|
+
requested: opts.datapack ?? null,
|
|
282
|
+
});
|
|
283
|
+
if (!preflight.ok) {
|
|
284
|
+
return { ok: false, message: preflight.message };
|
|
285
|
+
}
|
|
286
|
+
const datapackBanner = formatDatapackBanner(preflight, opts.datapack ?? null);
|
|
287
|
+
|
|
288
|
+
// Now that we know the resolved destination, pick the state key. An
|
|
289
|
+
// explicit --datapack that resolved to an id wins so two runs against
|
|
290
|
+
// the same URL but different packs get separate resume state files.
|
|
291
|
+
// Default flows (no --datapack) keep the URL-derived key for back-compat
|
|
292
|
+
// with checkpoints written before this PR.
|
|
293
|
+
const stateKey = stateKeyFor({
|
|
294
|
+
urlDatapackKey,
|
|
295
|
+
requested: opts.datapack ?? null,
|
|
296
|
+
resolvedId: preflight.id ?? null,
|
|
297
|
+
defaultId: preflight.defaultId ?? null,
|
|
298
|
+
});
|
|
299
|
+
P = paths({ datapackKey: stateKey });
|
|
300
|
+
|
|
301
|
+
// Back-compat for the legacy global `state.json`. This prompt has to run
|
|
302
|
+
// in the foreground wrapper because the detached child has no TTY. The
|
|
303
|
+
// non-interactive branch accepts silently, matching the behavior of
|
|
304
|
+
// `runChildMigration` (which is the path exercised by already-started
|
|
305
|
+
// runs with pre-existing state/).
|
|
306
|
+
try {
|
|
307
|
+
await migrateLegacyStateIfNeeded({
|
|
308
|
+
P,
|
|
309
|
+
datapackKey: stateKey,
|
|
310
|
+
interactive: !opts.nonInteractive && Boolean(process.stdin.isTTY),
|
|
311
|
+
});
|
|
312
|
+
} catch { /* best effort; child path will retry non-interactively */ }
|
|
313
|
+
|
|
117
314
|
// Reserve the lock slot in the foreground so a concurrent --migrate fails
|
|
118
315
|
// fast. We use a two-step dance: first, O_EXCL-create the lock with the
|
|
119
316
|
// foreground PID (just to claim the slot), then — after spawning the
|
|
@@ -140,6 +337,17 @@ export async function startMigration(opts) {
|
|
|
140
337
|
runId,
|
|
141
338
|
scope: opts.scope,
|
|
142
339
|
url: opts.url,
|
|
340
|
+
// urlDatapackKey records the key derived from the URL alone (issue
|
|
341
|
+
// #45 layout); stateKey is the actual on-disk file scope for this
|
|
342
|
+
// run, which may differ when --datapack is explicit. The child
|
|
343
|
+
// reads stateKey out of this file to land on the same path.
|
|
344
|
+
urlDatapackKey,
|
|
345
|
+
stateKey,
|
|
346
|
+
datapackKey: stateKey, // back-compat alias for older readers
|
|
347
|
+
datapackRequested: opts.datapack ?? null,
|
|
348
|
+
datapackResolvedId: preflight.id ?? null,
|
|
349
|
+
datapackResolvedName: preflight.name ?? null,
|
|
350
|
+
datapackSource: preflight.source,
|
|
143
351
|
apiKeyPresent: !!opts.apiKey,
|
|
144
352
|
projectRoot: opts.projectRoot,
|
|
145
353
|
noExtract: !!opts.noExtract,
|
|
@@ -156,6 +364,7 @@ export async function startMigration(opts) {
|
|
|
156
364
|
if (opts.noExtract) passthrough.push("--no-extract");
|
|
157
365
|
if (opts.force) passthrough.push("--force");
|
|
158
366
|
if (opts.rate) passthrough.push("--rate", String(opts.rate));
|
|
367
|
+
if (opts.datapack) passthrough.push("--datapack", String(opts.datapack));
|
|
159
368
|
|
|
160
369
|
const env = { ...process.env };
|
|
161
370
|
if (opts.apiKey) env.MEKO_API_KEY = opts.apiKey;
|
|
@@ -182,12 +391,70 @@ export async function startMigration(opts) {
|
|
|
182
391
|
ok: true,
|
|
183
392
|
runId,
|
|
184
393
|
message:
|
|
394
|
+
`${datapackBanner}\n` +
|
|
185
395
|
`Migration started. run-id=${runId}, pid=${pid}\n` +
|
|
186
396
|
`Logs: ${P.logFile(runId)}\n` +
|
|
187
397
|
`Status: ${selfInvocation()} --migrate-status`,
|
|
188
398
|
};
|
|
189
399
|
}
|
|
190
400
|
|
|
401
|
+
/**
|
|
402
|
+
* Open a short-lived MCP client and resolve --datapack before the lock is
|
|
403
|
+
* taken. Surfacing this synchronously means a typo in --datapack fails with
|
|
404
|
+
* a clear, actionable message before the user ever sees a `run-id`.
|
|
405
|
+
*
|
|
406
|
+
* @param {object} opts
|
|
407
|
+
* @param {string} opts.url
|
|
408
|
+
* @param {string|null} opts.apiKey
|
|
409
|
+
* @param {string|null} opts.requested
|
|
410
|
+
* @returns {Promise<{ok:boolean, id?:string|null, name?:string|null, source?:string, message?:string}>}
|
|
411
|
+
*/
|
|
412
|
+
async function preflightDatapack({ url, apiKey, requested }) {
|
|
413
|
+
const client = new McpClient({ url, apiKey, concurrency: 1 });
|
|
414
|
+
try {
|
|
415
|
+
await client.connect();
|
|
416
|
+
} catch (err) {
|
|
417
|
+
return {
|
|
418
|
+
ok: false,
|
|
419
|
+
message:
|
|
420
|
+
`Could not connect to MCP server to resolve --datapack: ${err.message}.`,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
const resolved = await resolveDestinationDatapack({ client, requested });
|
|
424
|
+
if (!resolved.ok) {
|
|
425
|
+
return { ok: false, message: resolved.message };
|
|
426
|
+
}
|
|
427
|
+
return {
|
|
428
|
+
ok: true,
|
|
429
|
+
id: resolved.id ?? null,
|
|
430
|
+
name: resolved.name ?? null,
|
|
431
|
+
source: resolved.source,
|
|
432
|
+
defaultId: resolved.defaultId ?? null,
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Format the one-line "Migrating to ..." banner printed before the child
|
|
438
|
+
* forks. Distinguishes the four cases so a misconfigured tenant (no default
|
|
439
|
+
* row) is visible rather than silent.
|
|
440
|
+
*/
|
|
441
|
+
function formatDatapackBanner(preflight, requested) {
|
|
442
|
+
const id = preflight.id ?? null;
|
|
443
|
+
const name = preflight.name ?? null;
|
|
444
|
+
if (preflight.source === "default") {
|
|
445
|
+
return `Migrating to default datapack: ${name} (${id})`;
|
|
446
|
+
}
|
|
447
|
+
if (preflight.source === "server-fallback") {
|
|
448
|
+
return (
|
|
449
|
+
`Migrating to default datapack (server-resolved at runtime — no ` +
|
|
450
|
+
`'meko_default_datapack' row visible to this API key).`
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
// flag-id or flag-name
|
|
454
|
+
const label = name ?? requested ?? "(unknown)";
|
|
455
|
+
return `Migrating to datapack: ${label} (${id ?? "unknown id"}) [explicit]`;
|
|
456
|
+
}
|
|
457
|
+
|
|
191
458
|
/**
|
|
192
459
|
* Invoked by the detached child via bin/create-meko-setup.mjs.
|
|
193
460
|
*
|
|
@@ -204,15 +471,53 @@ export async function startMigration(opts) {
|
|
|
204
471
|
* @param {boolean} [opts.noExtract]
|
|
205
472
|
* @param {boolean} [opts.force]
|
|
206
473
|
* @param {number} [opts.rate]
|
|
474
|
+
* @param {string|null} [opts.datapack] Forwarded `--datapack` value.
|
|
207
475
|
* @returns {Promise<number>} Exit code.
|
|
208
476
|
*/
|
|
209
477
|
export async function runChildMigration(opts) {
|
|
210
|
-
|
|
478
|
+
// The foreground wrapper picked the state key — possibly differently from
|
|
479
|
+
// the URL-only derivation when --datapack overrode the destination — and
|
|
480
|
+
// recorded it in cmd.json. Read that back so child writes land in the
|
|
481
|
+
// same state file the foreground would have used. The cmd.json runDir
|
|
482
|
+
// doesn't depend on the state key so we can locate it from the URL key.
|
|
483
|
+
const urlDatapackKey = datapackKeyFromUrl(opts.url);
|
|
484
|
+
const cmdRunDir = paths({ datapackKey: urlDatapackKey }).runDir(opts.runId);
|
|
485
|
+
let stateKey = urlDatapackKey;
|
|
486
|
+
try {
|
|
487
|
+
const cmd = JSON.parse(fs.readFileSync(path.join(cmdRunDir, "cmd.json"), "utf8"));
|
|
488
|
+
if (cmd && typeof cmd.stateKey === "string" && cmd.stateKey) {
|
|
489
|
+
stateKey = cmd.stateKey;
|
|
490
|
+
}
|
|
491
|
+
} catch { /* no cmd.json (test entry / older foreground) → URL key. */ }
|
|
492
|
+
|
|
493
|
+
const P = paths({ datapackKey: stateKey });
|
|
211
494
|
const runDir = P.runDir(opts.runId);
|
|
212
495
|
fs.mkdirSync(runDir, { recursive: true });
|
|
496
|
+
fs.mkdirSync(P.stateDir, { recursive: true });
|
|
213
497
|
|
|
214
498
|
const log = createLogger(path.join(runDir, "log.ndjson"), { runId: opts.runId });
|
|
215
499
|
|
|
500
|
+
// Back-compat: if `state/` is empty but the legacy `state.json` exists,
|
|
501
|
+
// copy it into `state/<datapackKey>.json` so this run resumes off the
|
|
502
|
+
// same checkpoints. CI / --yes path accepts silently; the detached child
|
|
503
|
+
// has no TTY anyway, so we always take the non-interactive branch here.
|
|
504
|
+
// (Interactive prompting, when introduced, happens in startMigration().)
|
|
505
|
+
try {
|
|
506
|
+
// The NDJSON `legacy_state_migrated` entry emitted via onWarning is the
|
|
507
|
+
// audit trail; we intentionally don't bump prog.counts.warnings here
|
|
508
|
+
// because the orchestrator owns that counter.
|
|
509
|
+
await migrateLegacyStateIfNeeded({
|
|
510
|
+
P,
|
|
511
|
+
datapackKey: stateKey,
|
|
512
|
+
interactive: false,
|
|
513
|
+
onWarning: (entry) => {
|
|
514
|
+
log.warn("legacy_state_migrated", entry);
|
|
515
|
+
},
|
|
516
|
+
});
|
|
517
|
+
} catch (err) {
|
|
518
|
+
log.warn("legacy_state_migrate_failed", { error: err.message });
|
|
519
|
+
}
|
|
520
|
+
|
|
216
521
|
let shuttingDown = false;
|
|
217
522
|
const onSignal = (sig) => {
|
|
218
523
|
if (shuttingDown) return;
|
|
@@ -239,6 +544,7 @@ export async function runChildMigration(opts) {
|
|
|
239
544
|
force: opts.force,
|
|
240
545
|
lockAlreadyHeld: true,
|
|
241
546
|
concurrency: opts.rate,
|
|
547
|
+
datapack: opts.datapack ?? null,
|
|
242
548
|
});
|
|
243
549
|
log.info("child_done", result.summary);
|
|
244
550
|
log.close();
|
|
@@ -269,6 +575,41 @@ export function readStatus(opts = {}) {
|
|
|
269
575
|
if (!prog) {
|
|
270
576
|
return { ok: false, runId, progress: null, message: `No progress file for run-id=${runId}.` };
|
|
271
577
|
}
|
|
578
|
+
|
|
579
|
+
// Fish the datapack key out of cmd.json if the run recorded one. Older runs
|
|
580
|
+
// (from before per-datapack scoping) don't have this field, in which case
|
|
581
|
+
// we print `(legacy)` to flag that its resume state is under the global
|
|
582
|
+
// `state.json`, not `state/<key>.json`.
|
|
583
|
+
let datapackKeyLabel = "(legacy)";
|
|
584
|
+
// The destination datapack (where the migration writes) is independent of
|
|
585
|
+
// the resume-state key (where checkpoints are stored on disk). Older runs
|
|
586
|
+
// didn't record this; show "-" for them.
|
|
587
|
+
let datapackTargetLabel = "-";
|
|
588
|
+
try {
|
|
589
|
+
const cmd = JSON.parse(
|
|
590
|
+
fs.readFileSync(path.join(P.runDir(runId), "cmd.json"), "utf8"),
|
|
591
|
+
);
|
|
592
|
+
if (cmd && typeof cmd.datapackKey === "string" && cmd.datapackKey) {
|
|
593
|
+
datapackKeyLabel = cmd.datapackKey;
|
|
594
|
+
}
|
|
595
|
+
if (cmd) {
|
|
596
|
+
const id = cmd.datapackResolvedId ?? null;
|
|
597
|
+
const name = cmd.datapackResolvedName ?? null;
|
|
598
|
+
const requested = cmd.datapackRequested ?? null;
|
|
599
|
+
const source = cmd.datapackSource ?? null;
|
|
600
|
+
if (source === "server-fallback") {
|
|
601
|
+
datapackTargetLabel = "(server default)";
|
|
602
|
+
} else if (id || name) {
|
|
603
|
+
const tag = source === "default" ? "default" : "explicit";
|
|
604
|
+
datapackTargetLabel = `${name ?? "(unknown)"} (${id ?? "?"}) [${tag}]`;
|
|
605
|
+
} else if (requested) {
|
|
606
|
+
datapackTargetLabel = `${requested} (unresolved)`;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
} catch {
|
|
610
|
+
/* cmd.json missing or unreadable — fall back to legacy label */
|
|
611
|
+
}
|
|
612
|
+
|
|
272
613
|
// Gray annotations next to each field. Keeps the output self-documenting
|
|
273
614
|
// without forcing the reader to cross-reference README docs for common
|
|
274
615
|
// fields. Respects NO_COLOR.
|
|
@@ -281,6 +622,8 @@ export function readStatus(opts = {}) {
|
|
|
281
622
|
`run-id: ${prog.runId}`,
|
|
282
623
|
`status: ${prog.status}${note("(pending → running → done | failed | stopped)")}`,
|
|
283
624
|
`scope: ${prog.scope}`,
|
|
625
|
+
`datapack: ${datapackKeyLabel}${note("(resume state lives under state/<key>.json)")}`,
|
|
626
|
+
`target pack: ${datapackTargetLabel}${note("(ingestion destination resolved at run start)")}`,
|
|
284
627
|
`started: ${prog.startedAt}`,
|
|
285
628
|
`finished: ${prog.finishedAt ?? "(in progress)"}`,
|
|
286
629
|
`current: ${prog.currentAgent ?? "-"} / ${prog.currentSession ?? "-"}${note("(<adapter>:<source-session> / <message-id>)")}`,
|
package/lib/prompt.mjs
CHANGED
|
@@ -173,6 +173,15 @@ export async function askSecret(questionText) {
|
|
|
173
173
|
rl.close();
|
|
174
174
|
rl = null;
|
|
175
175
|
}
|
|
176
|
+
// `readline.createInterface` expects stdin in paused mode — it calls
|
|
177
|
+
// `input.resume()` itself when it attaches its `data` listener. Closing
|
|
178
|
+
// the previous readline above leaves stdin in the flowing state it was
|
|
179
|
+
// using, so the muted readline we're about to create reads the user's
|
|
180
|
+
// first keypress through a partially-attached listener chain. Pausing
|
|
181
|
+
// here resets the precondition. Without this, the *next* readline (the
|
|
182
|
+
// post-askSecret singleton recreated in getRL()) never fires `line` and
|
|
183
|
+
// the install hangs with `unsettled top-level await` (issue #81).
|
|
184
|
+
process.stdin.pause();
|
|
176
185
|
|
|
177
186
|
// Print the prompt through the real stdout so the user sees it.
|
|
178
187
|
process.stdout.write(` ${questionText} `);
|
|
@@ -190,16 +199,25 @@ export async function askSecret(questionText) {
|
|
|
190
199
|
terminal: true,
|
|
191
200
|
});
|
|
192
201
|
|
|
202
|
+
// Shared teardown: close the muted readline, re-pause stdin so the next
|
|
203
|
+
// `createInterface` (typically the post-askSecret singleton from getRL())
|
|
204
|
+
// starts from the same precondition Node readline expects, and emit our
|
|
205
|
+
// own newline since the muted output swallowed the one readline would
|
|
206
|
+
// normally print on submit.
|
|
207
|
+
const finish = () => {
|
|
208
|
+
secretRL.close();
|
|
209
|
+
process.stdin.pause();
|
|
210
|
+
process.stdout.write("\n");
|
|
211
|
+
};
|
|
212
|
+
|
|
193
213
|
return new Promise((resolve, reject) => {
|
|
194
214
|
secretRL.once("SIGINT", () => {
|
|
195
|
-
|
|
196
|
-
process.stdout.write("\n");
|
|
215
|
+
finish();
|
|
197
216
|
reject(new Error("User cancelled"));
|
|
198
217
|
});
|
|
199
218
|
|
|
200
219
|
secretRL.question("", (answer) => {
|
|
201
|
-
|
|
202
|
-
process.stdout.write("\n");
|
|
220
|
+
finish();
|
|
203
221
|
resolve(answer);
|
|
204
222
|
});
|
|
205
223
|
});
|