@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.
- package/README.md +505 -0
- package/assets/hooks-handlers/lib/capture.js +863 -0
- package/assets/hooks-handlers/lib/checkpoint-timer.js +72 -0
- package/assets/hooks-handlers/pre-compact.sh +3 -0
- package/assets/hooks-handlers/session-end.sh +27 -0
- package/assets/hooks-handlers/session-start.sh +24 -0
- package/assets/hooks-handlers/test_single_conversation.sh +169 -0
- package/assets/skills/meko-mcp-tools/SKILL.md +192 -0
- package/assets/skills/meko-mcp-tools/references/_sections.md +31 -0
- package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +39 -0
- package/assets/skills/meko-mcp-tools/references/tools-conversation-dedup.md +49 -0
- package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +373 -0
- package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +56 -0
- package/assets/skills/meko-mcp-tools/references/tools-db-query-patterns.md +38 -0
- package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +61 -0
- package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +128 -0
- package/assets/skills/meko-mcp-tools/references/tools-overview.md +97 -0
- package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +63 -0
- package/assets/skills/meko-mcp-tools/references/tools-scope-permissions.md +79 -0
- package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +143 -0
- package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +93 -0
- package/assets/skills/meko-mcp-tools/references/tools-write-guardrails.md +30 -0
- package/bin/create-meko-setup.mjs +499 -0
- package/lib/claude-code.mjs +14 -0
- package/lib/clients/claude-code.mjs +265 -0
- package/lib/clients/claude-desktop.mjs +256 -0
- package/lib/clients/common.mjs +107 -0
- package/lib/clients/cursor.mjs +332 -0
- package/lib/clients/index.mjs +14 -0
- package/lib/config.mjs +369 -0
- package/lib/detect.mjs +125 -0
- package/lib/installer.mjs +255 -0
- package/lib/logger.mjs +97 -0
- package/lib/mcp-transport.mjs +204 -0
- package/lib/migrate/adapters/claude-code.mjs +165 -0
- package/lib/migrate/adapters/cursor.mjs +221 -0
- package/lib/migrate/adapters/memory-md.mjs +230 -0
- package/lib/migrate/claude-parser.mjs +306 -0
- package/lib/migrate/daemon.mjs +67 -0
- package/lib/migrate/id.mjs +83 -0
- package/lib/migrate/lockfile.mjs +121 -0
- package/lib/migrate/logger.mjs +79 -0
- package/lib/migrate/mcp-client.mjs +224 -0
- package/lib/migrate/orchestrator.mjs +443 -0
- package/lib/migrate/progress.mjs +76 -0
- package/lib/migrate/state.mjs +177 -0
- package/lib/migrate.mjs +328 -0
- package/lib/prompt.mjs +212 -0
- package/lib/validate.mjs +259 -0
- package/package.json +49 -0
- 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
|
+
}
|