@yugabytedb/meko-mcp 1.0.3

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 (51) hide show
  1. package/README.md +505 -0
  2. package/assets/hooks-handlers/lib/capture.js +863 -0
  3. package/assets/hooks-handlers/lib/checkpoint-timer.js +72 -0
  4. package/assets/hooks-handlers/pre-compact.sh +3 -0
  5. package/assets/hooks-handlers/session-end.sh +27 -0
  6. package/assets/hooks-handlers/session-start.sh +24 -0
  7. package/assets/hooks-handlers/test_single_conversation.sh +169 -0
  8. package/assets/skills/meko-mcp-tools/SKILL.md +192 -0
  9. package/assets/skills/meko-mcp-tools/references/_sections.md +31 -0
  10. package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +39 -0
  11. package/assets/skills/meko-mcp-tools/references/tools-conversation-dedup.md +49 -0
  12. package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +373 -0
  13. package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +56 -0
  14. package/assets/skills/meko-mcp-tools/references/tools-db-query-patterns.md +38 -0
  15. package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +61 -0
  16. package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +128 -0
  17. package/assets/skills/meko-mcp-tools/references/tools-overview.md +97 -0
  18. package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +63 -0
  19. package/assets/skills/meko-mcp-tools/references/tools-scope-permissions.md +79 -0
  20. package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +143 -0
  21. package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +93 -0
  22. package/assets/skills/meko-mcp-tools/references/tools-write-guardrails.md +30 -0
  23. package/bin/create-meko-setup.mjs +499 -0
  24. package/lib/claude-code.mjs +14 -0
  25. package/lib/clients/claude-code.mjs +265 -0
  26. package/lib/clients/claude-desktop.mjs +256 -0
  27. package/lib/clients/common.mjs +107 -0
  28. package/lib/clients/cursor.mjs +332 -0
  29. package/lib/clients/index.mjs +14 -0
  30. package/lib/config.mjs +369 -0
  31. package/lib/detect.mjs +125 -0
  32. package/lib/installer.mjs +255 -0
  33. package/lib/logger.mjs +97 -0
  34. package/lib/mcp-transport.mjs +204 -0
  35. package/lib/migrate/adapters/claude-code.mjs +165 -0
  36. package/lib/migrate/adapters/cursor.mjs +221 -0
  37. package/lib/migrate/adapters/memory-md.mjs +230 -0
  38. package/lib/migrate/claude-parser.mjs +306 -0
  39. package/lib/migrate/daemon.mjs +67 -0
  40. package/lib/migrate/id.mjs +83 -0
  41. package/lib/migrate/lockfile.mjs +121 -0
  42. package/lib/migrate/logger.mjs +79 -0
  43. package/lib/migrate/mcp-client.mjs +224 -0
  44. package/lib/migrate/orchestrator.mjs +443 -0
  45. package/lib/migrate/progress.mjs +76 -0
  46. package/lib/migrate/state.mjs +177 -0
  47. package/lib/migrate.mjs +328 -0
  48. package/lib/prompt.mjs +212 -0
  49. package/lib/validate.mjs +259 -0
  50. package/package.json +49 -0
  51. package/scripts/bundle-assets.mjs +49 -0
@@ -0,0 +1,224 @@
1
+ /**
2
+ * mcp-client.mjs — Thin wrapper over mcp-transport.mjs for the migrator.
3
+ *
4
+ * Adds:
5
+ * - session management (initialize once, reuse sessionId)
6
+ * - a concurrency semaphore on outgoing tool calls
7
+ * - exponential-backoff retries on transient errors
8
+ * - a small helper that parses the JSON text returned by Meko tools
9
+ */
10
+
11
+ import {
12
+ jsonRpcRequest,
13
+ classifyError,
14
+ mcpInitialize,
15
+ DEFAULT_REQUEST_TIMEOUT_MS,
16
+ } from "../mcp-transport.mjs";
17
+
18
+ /**
19
+ * @typedef {Object} CallOptions
20
+ * @property {number} [timeoutMs]
21
+ * @property {number} [maxRetries] Default 3. 0 disables retry.
22
+ * @property {boolean} [fireAndForget] If true, failures log+return null.
23
+ */
24
+
25
+ export class McpClient {
26
+ /**
27
+ * @param {object} opts
28
+ * @param {string} opts.url
29
+ * @param {string|null} opts.apiKey
30
+ * @param {number} [opts.concurrency] Default 4.
31
+ * @param {number} [opts.timeoutMs]
32
+ * @param {{info?: Function, warn?: Function, error?: Function}} [opts.log]
33
+ */
34
+ constructor(opts) {
35
+ this.url = opts.url;
36
+ this.apiKey = opts.apiKey ?? null;
37
+ // Defense in depth: NaN or anything non-positive would leave the
38
+ // semaphore permanently blocked (queued waiters never fire because
39
+ // `inflight < NaN` is always false). Fall back to the default.
40
+ const concurrency = Number(opts.concurrency);
41
+ this.concurrency = Number.isFinite(concurrency) && concurrency >= 1
42
+ ? Math.floor(concurrency)
43
+ : 4;
44
+ this.timeoutMs = opts.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
45
+ this.log = opts.log ?? {};
46
+ this.sessionId = null;
47
+ this._reqId = 100;
48
+ this._inflight = 0;
49
+ this._waiters = [];
50
+ }
51
+
52
+ async connect() {
53
+ const res = await mcpInitialize(this.url, this.apiKey, {
54
+ clientName: "meko-migrate",
55
+ timeoutMs: this.timeoutMs,
56
+ });
57
+ if (!res.ok) {
58
+ const err = new Error(res.message || "initialize failed");
59
+ err.statusCode = res.statusCode;
60
+ throw err;
61
+ }
62
+ this.sessionId = res.sessionId;
63
+ }
64
+
65
+ /**
66
+ * Call a tool over the open session.
67
+ *
68
+ * @param {string} name
69
+ * @param {object} args
70
+ * @param {CallOptions} [opts]
71
+ * @returns {Promise<{ok: boolean, result?: object, text?: string, error?: object, statusCode?: number}>}
72
+ */
73
+ async callTool(name, args, opts = {}) {
74
+ await this._acquire();
75
+ try {
76
+ return await this._callWithRetry(name, args, opts);
77
+ } finally {
78
+ this._release();
79
+ }
80
+ }
81
+
82
+ async _callWithRetry(name, args, opts) {
83
+ const maxRetries = opts.maxRetries ?? 3;
84
+ let lastErr = null;
85
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
86
+ try {
87
+ const out = await this._callOnce(name, args, opts);
88
+ // HTTP-level failures inside a structured response go through
89
+ // _callOnce too; it throws on non-2xx. A tool-level error surfaces
90
+ // as `{ok:false, error:{...}}` and we return it without retry.
91
+ return out;
92
+ } catch (err) {
93
+ lastErr = err;
94
+ const code = err.statusCode;
95
+ if (code === 401 || code === 403) {
96
+ // Auth is fatal; surface immediately.
97
+ throw err;
98
+ }
99
+ if (attempt < maxRetries) {
100
+ const delay = 1000 * 2 ** attempt;
101
+ this.log.warn?.("mcp_retry", {
102
+ tool: name,
103
+ attempt: attempt + 1,
104
+ delay_ms: delay,
105
+ error: err.message,
106
+ });
107
+ await sleep(delay);
108
+ continue;
109
+ }
110
+ }
111
+ }
112
+ if (opts.fireAndForget) {
113
+ this.log.warn?.("mcp_fire_and_forget_failed", {
114
+ tool: name,
115
+ error: lastErr?.message,
116
+ });
117
+ return { ok: false, error: { message: lastErr?.message ?? "unknown" } };
118
+ }
119
+ throw lastErr;
120
+ }
121
+
122
+ async _callOnce(name, args, opts) {
123
+ const payload = {
124
+ jsonrpc: "2.0",
125
+ id: this._reqId++,
126
+ method: "tools/call",
127
+ params: { name, arguments: args },
128
+ };
129
+
130
+ let res;
131
+ try {
132
+ res = await jsonRpcRequest(this.url, payload, this.apiKey, this.sessionId, {
133
+ timeoutMs: opts.timeoutMs ?? this.timeoutMs,
134
+ });
135
+ } catch (err) {
136
+ const e = new Error(classifyError(err, this.timeoutMs));
137
+ e.cause = err;
138
+ throw e;
139
+ }
140
+
141
+ if (res.statusCode === 401 || res.statusCode === 403) {
142
+ const e = new Error(
143
+ `Auth rejected by server (HTTP ${res.statusCode}). Check API_KEY.`,
144
+ );
145
+ e.statusCode = res.statusCode;
146
+ throw e;
147
+ }
148
+
149
+ if (res.statusCode < 200 || res.statusCode >= 300) {
150
+ const e = new Error(
151
+ `tools/call ${name} returned HTTP ${res.statusCode}: ${res.body.slice(0, 200)}`,
152
+ );
153
+ e.statusCode = res.statusCode;
154
+ throw e;
155
+ }
156
+
157
+ let parsed;
158
+ try {
159
+ parsed = JSON.parse(res.body);
160
+ } catch {
161
+ const e = new Error(
162
+ `tools/call ${name} returned non-JSON: ${res.body.slice(0, 200)}`,
163
+ );
164
+ e.statusCode = res.statusCode;
165
+ throw e;
166
+ }
167
+
168
+ if (parsed.error) {
169
+ return {
170
+ ok: false,
171
+ error: parsed.error,
172
+ statusCode: res.statusCode,
173
+ };
174
+ }
175
+
176
+ const text = extractFirstText(parsed.result);
177
+ return {
178
+ ok: true,
179
+ result: parsed.result,
180
+ text,
181
+ statusCode: res.statusCode,
182
+ };
183
+ }
184
+
185
+ // --- Semaphore -----------------------------------------------------------
186
+
187
+ _acquire() {
188
+ if (this._inflight < this.concurrency) {
189
+ this._inflight++;
190
+ return Promise.resolve();
191
+ }
192
+ return new Promise((resolve) => {
193
+ this._waiters.push(() => {
194
+ this._inflight++;
195
+ resolve();
196
+ });
197
+ });
198
+ }
199
+
200
+ _release() {
201
+ this._inflight--;
202
+ const next = this._waiters.shift();
203
+ if (next) next();
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Meko tool results come back as `{content: [{type:"text", text: "..."}], ...}`.
209
+ * Pull the first text block out (or null).
210
+ */
211
+ function extractFirstText(result) {
212
+ const content = result?.content;
213
+ if (!Array.isArray(content)) return null;
214
+ for (const block of content) {
215
+ if (block && block.type === "text" && typeof block.text === "string") {
216
+ return block.text;
217
+ }
218
+ }
219
+ return null;
220
+ }
221
+
222
+ function sleep(ms) {
223
+ return new Promise((r) => setTimeout(r, ms));
224
+ }
@@ -0,0 +1,443 @@
1
+ /**
2
+ * orchestrator.mjs — Drives a migration run end-to-end.
3
+ *
4
+ * Reads state, acquires a lock, enumerates sources, walks the MCP tools,
5
+ * flushes progress/state, and returns a summary. All I/O goes through
6
+ * the lower-level modules (`state`, `progress`, `lockfile`, `mcp-client`).
7
+ */
8
+
9
+ import path from "node:path";
10
+ import fs from "node:fs";
11
+ import * as state from "./state.mjs";
12
+ import * as progress from "./progress.mjs";
13
+ import * as lockfile from "./lockfile.mjs";
14
+ import { McpClient } from "./mcp-client.mjs";
15
+ import { createLogger, nullLogger } from "./logger.mjs";
16
+ import { seedFor } from "./id.mjs";
17
+ import * as claudeCodeAdapter from "./adapters/claude-code.mjs";
18
+ import * as cursorAdapter from "./adapters/cursor.mjs";
19
+ import * as memoryMdAdapter from "./adapters/memory-md.mjs";
20
+
21
+ /**
22
+ * @param {object} opts
23
+ * @param {string} opts.url
24
+ * @param {string|null} opts.apiKey
25
+ * @param {string} opts.runId
26
+ * @param {"user"|"project"} opts.scope
27
+ * @param {string} opts.projectCwd
28
+ * @param {string} opts.runDir
29
+ * @param {string} opts.statePath
30
+ * @param {string} opts.lockPath
31
+ * @param {boolean} [opts.dryRun]
32
+ * @param {boolean} [opts.noExtract]
33
+ * @param {boolean} [opts.force] Bypass state-level skip-if-done.
34
+ * @param {boolean} [opts.lockAlreadyHeld] Caller (e.g. detached child) says the lock is already owned by this process; skip acquire.
35
+ * @param {number} [opts.concurrency]
36
+ * @param {string} [opts.home]
37
+ * @returns {Promise<{ok: boolean, summary: object, counts: object}>}
38
+ */
39
+ export async function runMigration(opts) {
40
+ fs.mkdirSync(opts.runDir, { recursive: true });
41
+
42
+ const log = opts.dryRun
43
+ ? nullLogger()
44
+ : createLogger(path.join(opts.runDir, "log.ndjson"), { runId: opts.runId });
45
+
46
+ const prog = progress.initial(opts.runId, opts.scope);
47
+ const progressPath = path.join(opts.runDir, "progress.json");
48
+ const writeProgress = () => {
49
+ try { progress.write(progressPath, prog); } catch { /* best effort */ }
50
+ };
51
+
52
+ let st = state.load(opts.statePath);
53
+ let opsSinceFlush = 0;
54
+ const FLUSH_EVERY = 25;
55
+ const flushState = () => {
56
+ try { state.save(opts.statePath, st); } catch { /* best effort */ }
57
+ opsSinceFlush = 0;
58
+ };
59
+
60
+ // Lock acquisition. On the production path the detached-child runner has
61
+ // already marked the lock with its own PID, so we MUST NOT try to acquire
62
+ // again — that would self-deadlock. Tests that call runMigration()
63
+ // directly default to acquiring here.
64
+ let lockAcquiredHere = false;
65
+ if (!opts.dryRun && !opts.lockAlreadyHeld) {
66
+ const acq = lockfile.acquire(opts.lockPath, { runId: opts.runId }, { force: opts.force });
67
+ if (!acq.acquired) {
68
+ log.error("lock_busy", { holder: acq.holder });
69
+ return { ok: false, summary: { error: "lock_busy", holder: acq.holder }, counts: prog.counts };
70
+ }
71
+ lockAcquiredHere = true;
72
+ }
73
+
74
+ // Plan the work.
75
+ const claudeSessions = await claudeCodeAdapter.listSessions({
76
+ scope: opts.scope,
77
+ projectCwd: opts.projectCwd,
78
+ home: opts.home,
79
+ });
80
+ const cursorSessions = cursorAdapter.listSessions({
81
+ scope: opts.scope,
82
+ projectCwd: opts.projectCwd,
83
+ home: opts.home,
84
+ });
85
+ const memoryItems = await memoryMdAdapter.enumerateMemoryItems({
86
+ scope: opts.scope,
87
+ projectCwd: opts.projectCwd,
88
+ home: opts.home,
89
+ });
90
+
91
+ prog.counts.sessionsPlanned = claudeSessions.length + cursorSessions.length;
92
+ writeProgress();
93
+
94
+ log.info("plan", {
95
+ scope: opts.scope,
96
+ claudeSessions: claudeSessions.length,
97
+ cursorSessions: cursorSessions.length,
98
+ memoryItems: memoryItems.length,
99
+ dryRun: !!opts.dryRun,
100
+ noExtract: !!opts.noExtract,
101
+ });
102
+
103
+ if (opts.dryRun) {
104
+ // Peek-parse every session so we can report planned turn counts and
105
+ // the total expected MCP call volume — helps users estimate
106
+ // LLM-extraction cost before firing a real run.
107
+ let pairedTurns = 0;
108
+ for (const ref of claudeSessions) {
109
+ const parsed = await claudeCodeAdapter.parseSession(ref.path);
110
+ pairedTurns += parsed.exchanges.length;
111
+ }
112
+ for (const ref of cursorSessions) {
113
+ const parsed = cursorAdapter.parseSession(ref.path);
114
+ pairedTurns += parsed.exchanges.length;
115
+ }
116
+ const plannedConversationCreates = claudeSessions.length + cursorSessions.length
117
+ + (memoryItems.length > 0 ? countDistinctMemoryAgents(memoryItems) : 0);
118
+ const plannedConversationAddMessage = pairedTurns;
119
+ const plannedMemoryAddExtract = opts.noExtract ? 0 : pairedTurns;
120
+ const plannedMemoryAddText = memoryItems.length;
121
+
122
+ const summary = {
123
+ scope: opts.scope,
124
+ claudeSessions: claudeSessions.length,
125
+ cursorSessions: cursorSessions.length,
126
+ memoryItems: memoryItems.length,
127
+ pairedTurns,
128
+ plannedCalls: {
129
+ conversation_create: plannedConversationCreates,
130
+ conversation_add_message: plannedConversationAddMessage,
131
+ memory_add_messages: plannedMemoryAddExtract,
132
+ memory_add_text: plannedMemoryAddText,
133
+ total:
134
+ plannedConversationCreates +
135
+ plannedConversationAddMessage +
136
+ plannedMemoryAddExtract +
137
+ plannedMemoryAddText,
138
+ },
139
+ };
140
+ return { ok: true, summary, counts: prog.counts };
141
+ }
142
+
143
+ // Open MCP client.
144
+ const client = new McpClient({
145
+ url: opts.url,
146
+ apiKey: opts.apiKey,
147
+ concurrency: opts.concurrency ?? 4,
148
+ log,
149
+ });
150
+
151
+ try {
152
+ await client.connect();
153
+ log.info("mcp_connected", { sessionId: client.sessionId });
154
+ } catch (err) {
155
+ log.error("mcp_connect_failed", { error: err.message, code: err.statusCode });
156
+ releaseLock();
157
+ prog.status = "failed";
158
+ prog.finishedAt = new Date().toISOString();
159
+ writeProgress();
160
+ return { ok: false, summary: { error: "mcp_connect_failed", detail: err.message }, counts: prog.counts };
161
+ }
162
+
163
+ // -- Conversations --
164
+ for (const ref of [...claudeSessions, ...cursorSessions]) {
165
+ let existing = state.getSession(st, ref.agentId, ref.sessionUuid);
166
+ if (existing?.status === "done" && !opts.force) {
167
+ log.info("session_skip_done", { agentId: ref.agentId, sessionUuid: ref.sessionUuid });
168
+ prog.counts.sessionsDone++;
169
+ writeProgress();
170
+ continue;
171
+ }
172
+ // --force re-ingests: wipe resume state so we don't skip messages.
173
+ if (existing && opts.force) {
174
+ state.markSession(st, ref.agentId, ref.sessionUuid, {
175
+ status: "pending",
176
+ messagesSent: 0,
177
+ memoryPairsSent: 0,
178
+ lastUuid: null,
179
+ completedAt: null,
180
+ lastError: null,
181
+ });
182
+ existing = state.getSession(st, ref.agentId, ref.sessionUuid);
183
+ }
184
+
185
+ prog.currentAgent = ref.agentId;
186
+ prog.currentSession = ref.sessionUuid;
187
+ writeProgress();
188
+
189
+ const parsed = (ref.path && ref.path.endsWith(".jsonl"))
190
+ ? await claudeCodeAdapter.parseSession(ref.path)
191
+ : cursorAdapter.parseSession(ref.path);
192
+
193
+ if (parsed.partial) {
194
+ log.warn("session_partial", { path: ref.path });
195
+ }
196
+
197
+ // Ensure a conversation_id exists.
198
+ let conversationId = existing?.conversationId || "";
199
+ if (!conversationId) {
200
+ const createRes = await safeCall(client, "conversation_create", {
201
+ scope: "write",
202
+ agent_id: ref.agentId,
203
+ session_id: ref.sessionUuid,
204
+ title: (parsed.firstUserText || ref.sessionUuid).slice(0, 120),
205
+ metadata: JSON.stringify({
206
+ source: ref.path.endsWith(".jsonl") ? "claude-code" : "cursor",
207
+ cwd: ref.cwd || null,
208
+ gitBranch: ref.slug ? (parsed.gitBranch || null) : null,
209
+ startedAt: parsed.startedAt || null,
210
+ }),
211
+ }, log);
212
+
213
+ if (!createRes.ok) {
214
+ log.error("conversation_create_failed", { agentId: ref.agentId, sessionUuid: ref.sessionUuid, error: createRes.error?.message });
215
+ state.markSession(st, ref.agentId, ref.sessionUuid, {
216
+ status: "failed",
217
+ lastError: createRes.error?.message ?? "conversation_create failed",
218
+ });
219
+ prog.counts.warnings++;
220
+ if (++opsSinceFlush >= FLUSH_EVERY) flushState();
221
+ writeProgress();
222
+ continue;
223
+ }
224
+
225
+ conversationId = extractId(createRes.text) || "";
226
+ state.markSession(st, ref.agentId, ref.sessionUuid, {
227
+ conversationId,
228
+ status: "partial",
229
+ });
230
+ if (++opsSinceFlush >= FLUSH_EVERY) flushState();
231
+ }
232
+
233
+ // Replay turns.
234
+ let messagesSent = existing?.messagesSent ?? 0;
235
+ let memoryPairsSent = existing?.memoryPairsSent ?? 0;
236
+ let lastUuid = existing?.lastUuid ?? null;
237
+
238
+ // Resume from lastUuid if present.
239
+ let skipping = !!lastUuid;
240
+ for (const ex of parsed.exchanges) {
241
+ if (skipping) {
242
+ if (ex.user_uuid === lastUuid) skipping = false;
243
+ continue;
244
+ }
245
+ const seed = seedFor(ref.agentId, ref.sessionUuid, ex.user_uuid);
246
+
247
+ // (a) conversation_add_message — fire-and-forget; server batches.
248
+ const addMsgPromise = client.callTool("conversation_add_message", {
249
+ scope: "write",
250
+ conversation_id: conversationId,
251
+ agent_id: ref.agentId,
252
+ input: ex.input,
253
+ output: ex.output,
254
+ reasoning: ex.reasoning || undefined,
255
+ seed,
256
+ metadata: JSON.stringify({
257
+ source: "migrate",
258
+ sessionUuid: ref.sessionUuid,
259
+ userUuid: ex.user_uuid,
260
+ timestamp: ex.timestamp,
261
+ }),
262
+ }, { fireAndForget: true });
263
+
264
+ // (b) memory_add(messages=...) — invokes Mem0 extractor server-side.
265
+ let memPromise = Promise.resolve(null);
266
+ if (!opts.noExtract && (ex.input || ex.output)) {
267
+ memPromise = client.callTool("memory_add", {
268
+ scope: "write",
269
+ agent_id: ref.agentId,
270
+ conversation_id: conversationId,
271
+ messages: [
272
+ { role: "user", content: ex.input || "" },
273
+ { role: "assistant", content: ex.output || "" },
274
+ ],
275
+ metadata: JSON.stringify({
276
+ source: "migrate",
277
+ sessionUuid: ref.sessionUuid,
278
+ userUuid: ex.user_uuid,
279
+ }),
280
+ }, { fireAndForget: true });
281
+ }
282
+
283
+ const [, memRes] = await Promise.all([addMsgPromise, memPromise]);
284
+ messagesSent++;
285
+ if (!opts.noExtract) {
286
+ if (memRes && memRes.ok) {
287
+ memoryPairsSent++;
288
+ prog.counts.memoryPairsSent++;
289
+ } else if (memRes) {
290
+ prog.counts.memoryErrors++;
291
+ }
292
+ }
293
+ lastUuid = ex.user_uuid;
294
+ prog.counts.messagesSent++;
295
+
296
+ state.markSession(st, ref.agentId, ref.sessionUuid, {
297
+ conversationId,
298
+ status: "partial",
299
+ messagesSent,
300
+ memoryPairsSent,
301
+ lastUuid,
302
+ });
303
+ if (++opsSinceFlush >= FLUSH_EVERY) flushState();
304
+ writeProgress();
305
+ }
306
+
307
+ state.markSession(st, ref.agentId, ref.sessionUuid, {
308
+ conversationId,
309
+ status: parsed.partial ? "partial" : "done",
310
+ messagesSent,
311
+ memoryPairsSent,
312
+ lastUuid,
313
+ completedAt: new Date().toISOString(),
314
+ lastError: null,
315
+ });
316
+ flushState();
317
+ if (!parsed.partial) prog.counts.sessionsDone++;
318
+ writeProgress();
319
+ log.info("session_done", {
320
+ agentId: ref.agentId,
321
+ sessionUuid: ref.sessionUuid,
322
+ messagesSent,
323
+ memoryPairsSent,
324
+ partial: parsed.partial,
325
+ });
326
+ }
327
+
328
+ // -- Memory MD files --
329
+ const umbrellaPerAgent = new Map();
330
+ for (const item of memoryItems) {
331
+ if (state.isMemoryDone(st, item.agentId, item.hash) && !opts.force) continue;
332
+
333
+ let convId = umbrellaPerAgent.get(item.agentId);
334
+ if (!convId) {
335
+ const existingUmbrella = state.ensureAgent(st, item.agentId).memoryConversationId;
336
+ if (existingUmbrella) {
337
+ convId = existingUmbrella;
338
+ } else {
339
+ const res = await safeCall(client, "conversation_create", {
340
+ scope: "write",
341
+ agent_id: item.agentId,
342
+ title: "memory-migration",
343
+ metadata: JSON.stringify({ source: "migrate", purpose: "memory-umbrella" }),
344
+ }, log);
345
+ if (!res.ok) {
346
+ log.error("memory_umbrella_failed", { agentId: item.agentId, error: res.error?.message });
347
+ prog.counts.warnings++;
348
+ continue;
349
+ }
350
+ convId = extractId(res.text) || "";
351
+ state.ensureAgent(st, item.agentId).memoryConversationId = convId;
352
+ }
353
+ umbrellaPerAgent.set(item.agentId, convId);
354
+ }
355
+
356
+ const res = await client.callTool("memory_add", {
357
+ scope: "write",
358
+ agent_id: item.agentId,
359
+ conversation_id: convId,
360
+ text: item.text,
361
+ metadata: JSON.stringify(item.metadata),
362
+ }, { fireAndForget: true });
363
+
364
+ if (res.ok) {
365
+ state.markMemory(st, item.agentId, item.hash, {
366
+ memoryId: extractId(res.text) || "",
367
+ name: item.name,
368
+ addedAt: new Date().toISOString(),
369
+ });
370
+ prog.counts.memoriesFromMD++;
371
+ } else {
372
+ prog.counts.memoryErrors++;
373
+ log.warn("memory_md_failed", { agentId: item.agentId, name: item.name, error: res.error?.message });
374
+ }
375
+ if (++opsSinceFlush >= FLUSH_EVERY) flushState();
376
+ writeProgress();
377
+ }
378
+
379
+ flushState();
380
+ prog.status = "done";
381
+ prog.finishedAt = new Date().toISOString();
382
+ prog.currentAgent = null;
383
+ prog.currentSession = null;
384
+ writeProgress();
385
+
386
+ st.runs.push({
387
+ runId: opts.runId,
388
+ startedAt: prog.startedAt,
389
+ finishedAt: prog.finishedAt,
390
+ scope: opts.scope,
391
+ status: "done",
392
+ counts: prog.counts,
393
+ });
394
+ flushState();
395
+
396
+ log.info("done", { summary: prog.counts });
397
+ log.close();
398
+ releaseLock();
399
+
400
+ return { ok: true, summary: prog.counts, counts: prog.counts };
401
+
402
+ function releaseLock() {
403
+ if (lockAcquiredHere) {
404
+ try { lockfile.release(opts.lockPath); } catch { /* ignore */ }
405
+ }
406
+ }
407
+ }
408
+
409
+ /**
410
+ * How many umbrella memory-migration conversations the real run would open.
411
+ */
412
+ function countDistinctMemoryAgents(items) {
413
+ const set = new Set();
414
+ for (const item of items) set.add(item.agentId);
415
+ return set.size;
416
+ }
417
+
418
+ /**
419
+ * Call a tool with basic error-surfacing. Does not retry beyond the
420
+ * client's own retry logic. Returns `{ok: boolean, text?: string, error?: any}`.
421
+ */
422
+ async function safeCall(client, name, args, log) {
423
+ try {
424
+ return await client.callTool(name, args);
425
+ } catch (err) {
426
+ log.error("mcp_call_threw", { tool: name, error: err.message });
427
+ return { ok: false, error: { message: err.message } };
428
+ }
429
+ }
430
+
431
+ /**
432
+ * Meko tools return JSON text in their content blocks. Parse that and
433
+ * return an `id` field if present.
434
+ */
435
+ function extractId(text) {
436
+ if (!text) return null;
437
+ try {
438
+ const obj = JSON.parse(text);
439
+ return obj.id || obj.conversation_id || obj.memory_id || null;
440
+ } catch {
441
+ return null;
442
+ }
443
+ }