@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,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* claude-parser.mjs — Pure-ESM, streaming Claude Code transcript parser.
|
|
3
|
+
*
|
|
4
|
+
* Ported from skills/hooks-handlers/lib/capture.js, but:
|
|
5
|
+
* - no CLI / side effects; only exports
|
|
6
|
+
* - streaming via readline so sessions larger than available RAM
|
|
7
|
+
* don't need to be loaded whole
|
|
8
|
+
* - filters isSidechain / isMeta / attachment-type entries up front
|
|
9
|
+
* (the hook-side version doesn't need to because it runs live)
|
|
10
|
+
*
|
|
11
|
+
* Kept deliberately independent of `skills/hooks-handlers/lib/capture.js`:
|
|
12
|
+
* installer packages ship without that sibling directory, and mixing
|
|
13
|
+
* CommonJS side-effect-heavy hook code into an ESM installer costs more
|
|
14
|
+
* than the ~150 LOC of parser logic duplicated here.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import readline from "node:readline";
|
|
19
|
+
|
|
20
|
+
export const MAX_THINKING_LEN = Number(process.env.MEKO_MAX_THINKING_LEN ?? 2000);
|
|
21
|
+
export const MAX_TOOL_INPUT_LEN = Number(process.env.MEKO_MAX_TOOL_INPUT_LEN ?? 500);
|
|
22
|
+
export const MAX_TOOL_RESULT_LEN = Number(process.env.MEKO_MAX_TOOL_RESULT_LEN ?? 3000);
|
|
23
|
+
|
|
24
|
+
const SKIP_INPUT_PATTERN =
|
|
25
|
+
/^<(system-reminder|command-|local-command|available-deferred)/;
|
|
26
|
+
|
|
27
|
+
export function truncate(str, max) {
|
|
28
|
+
if (!str || str.length <= max) return str;
|
|
29
|
+
return str.slice(0, max) + "... [truncated]";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function extractText(content) {
|
|
33
|
+
if (typeof content === "string") return content;
|
|
34
|
+
if (!Array.isArray(content)) return "";
|
|
35
|
+
const parts = [];
|
|
36
|
+
for (const b of content) {
|
|
37
|
+
if (typeof b === "string") { parts.push(b); continue; }
|
|
38
|
+
if (b && b.type === "text" && typeof b.text === "string") parts.push(b.text);
|
|
39
|
+
}
|
|
40
|
+
return parts.join("\n");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function extractThinking(content) {
|
|
44
|
+
if (!Array.isArray(content)) return "";
|
|
45
|
+
const parts = [];
|
|
46
|
+
for (const b of content) {
|
|
47
|
+
if (b && b.type === "thinking" && typeof b.thinking === "string") {
|
|
48
|
+
parts.push(truncate(b.thinking, MAX_THINKING_LEN));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return parts.join("\n");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function extractToolCalls(content) {
|
|
55
|
+
if (!Array.isArray(content)) return "";
|
|
56
|
+
const parts = [];
|
|
57
|
+
for (const b of content) {
|
|
58
|
+
if (!b || b.type !== "tool_use") continue;
|
|
59
|
+
const name = b.name || "unknown";
|
|
60
|
+
let inputStr = "";
|
|
61
|
+
if (b.input && typeof b.input === "object") {
|
|
62
|
+
try {
|
|
63
|
+
inputStr = truncate(JSON.stringify(b.input), MAX_TOOL_INPUT_LEN);
|
|
64
|
+
} catch {
|
|
65
|
+
inputStr = "(unserializable)";
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
parts.push(`TOOL CALL: ${name}(${inputStr})`);
|
|
69
|
+
}
|
|
70
|
+
return parts.join("\n");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function isToolResultMessage(msg) {
|
|
74
|
+
if (msg?.toolUseResult != null) return true;
|
|
75
|
+
const content = msg?.message?.content;
|
|
76
|
+
if (Array.isArray(content)) {
|
|
77
|
+
return content.some((b) => b && b.type === "tool_result");
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function extractToolResultContent(msg) {
|
|
83
|
+
const parts = [];
|
|
84
|
+
const content = msg?.message?.content;
|
|
85
|
+
if (Array.isArray(content)) {
|
|
86
|
+
for (const b of content) {
|
|
87
|
+
if (!b || b.type !== "tool_result") continue;
|
|
88
|
+
const inner = b.content;
|
|
89
|
+
if (typeof inner === "string") {
|
|
90
|
+
parts.push(truncate(inner, MAX_TOOL_RESULT_LEN));
|
|
91
|
+
} else if (Array.isArray(inner)) {
|
|
92
|
+
for (const ib of inner) {
|
|
93
|
+
if (ib && ib.type === "text" && typeof ib.text === "string") {
|
|
94
|
+
parts.push(truncate(ib.text, MAX_TOOL_RESULT_LEN));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (!parts.length && typeof msg?.toolUseResult === "string") {
|
|
101
|
+
parts.push(truncate(msg.toolUseResult, MAX_TOOL_RESULT_LEN));
|
|
102
|
+
}
|
|
103
|
+
if (parts.length === 0) return "";
|
|
104
|
+
return "TOOL RESULT:\n" + parts.join("\n---\n");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Should this JSON entry be dropped before we start pairing turns?
|
|
109
|
+
* Mirrors the plan: skip sidechains, meta turns, and the non-message types.
|
|
110
|
+
*
|
|
111
|
+
* @param {any} obj
|
|
112
|
+
* @returns {boolean}
|
|
113
|
+
*/
|
|
114
|
+
export function isSkippable(obj) {
|
|
115
|
+
if (!obj || typeof obj !== "object") return true;
|
|
116
|
+
if (obj.isSidechain === true) return true;
|
|
117
|
+
if (obj.isMeta === true) return true;
|
|
118
|
+
const t = obj.type;
|
|
119
|
+
if (t === "attachment" || t === "file-history-snapshot" || t === "custom-title") {
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
if (t !== "user" && t !== "assistant") return true;
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Stream a JSONL session and emit paired user↔assistant exchanges.
|
|
128
|
+
*
|
|
129
|
+
* Uses `readline.createInterface` with a file read stream — the file
|
|
130
|
+
* is never fully materialized in memory, even at 100+ MB.
|
|
131
|
+
*
|
|
132
|
+
* Truncated-trailing-line handling: readline drops a trailing partial
|
|
133
|
+
* line silently when the input stream ends without a newline. That is
|
|
134
|
+
* exactly the behaviour we want for "in-progress" session files: the
|
|
135
|
+
* partial JSON gets dropped rather than failing the whole run.
|
|
136
|
+
*
|
|
137
|
+
* @param {string} sessionPath
|
|
138
|
+
* @returns {Promise<{
|
|
139
|
+
* sessionUuid: string,
|
|
140
|
+
* firstUserText: string,
|
|
141
|
+
* cwd: string,
|
|
142
|
+
* gitBranch: string|null,
|
|
143
|
+
* startedAt: string|null,
|
|
144
|
+
* messageCount: number,
|
|
145
|
+
* exchanges: Array<{user_uuid: string, input: string, output: string, reasoning: string, timestamp: string}>,
|
|
146
|
+
* partial: boolean,
|
|
147
|
+
* }>}
|
|
148
|
+
*/
|
|
149
|
+
export async function parseSessionStream(sessionPath) {
|
|
150
|
+
let stat;
|
|
151
|
+
try { stat = fs.statSync(sessionPath); } catch { stat = null; }
|
|
152
|
+
let partial = false;
|
|
153
|
+
if (stat) {
|
|
154
|
+
try {
|
|
155
|
+
const fd = fs.openSync(sessionPath, "r");
|
|
156
|
+
const buf = Buffer.alloc(1);
|
|
157
|
+
if (stat.size > 0) fs.readSync(fd, buf, 0, 1, stat.size - 1);
|
|
158
|
+
fs.closeSync(fd);
|
|
159
|
+
const recent = stat.mtimeMs > Date.now() - 60_000;
|
|
160
|
+
const endsNL = stat.size === 0 || buf[0] === 0x0a;
|
|
161
|
+
partial = recent && !endsNL;
|
|
162
|
+
} catch { /* ignore */ }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const rl = readline.createInterface({
|
|
166
|
+
input: fs.createReadStream(sessionPath, { encoding: "utf8" }),
|
|
167
|
+
crlfDelay: Infinity,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// Metadata we collect in a single streaming pass.
|
|
171
|
+
let firstUserText = "";
|
|
172
|
+
let cwd = "";
|
|
173
|
+
let gitBranch = null;
|
|
174
|
+
let startedAt = null;
|
|
175
|
+
let messageCount = 0;
|
|
176
|
+
|
|
177
|
+
// Turn-assembly state.
|
|
178
|
+
const exchanges = [];
|
|
179
|
+
let current = null;
|
|
180
|
+
|
|
181
|
+
for await (const raw of rl) {
|
|
182
|
+
const line = raw.trim();
|
|
183
|
+
if (!line) continue;
|
|
184
|
+
let obj;
|
|
185
|
+
try {
|
|
186
|
+
obj = JSON.parse(line);
|
|
187
|
+
} catch {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
messageCount++;
|
|
191
|
+
if (!cwd && typeof obj.cwd === "string") cwd = obj.cwd;
|
|
192
|
+
if (!gitBranch && typeof obj.gitBranch === "string") gitBranch = obj.gitBranch;
|
|
193
|
+
if (!startedAt && typeof obj.timestamp === "string") startedAt = obj.timestamp;
|
|
194
|
+
if (!firstUserText && obj.type === "user" && !isSkippable(obj) && !isToolResultMessage(obj)) {
|
|
195
|
+
const txt = extractText(obj.message?.content);
|
|
196
|
+
if (txt && !SKIP_INPUT_PATTERN.test(txt)) firstUserText = txt.slice(0, 240);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (isSkippable(obj)) continue;
|
|
200
|
+
|
|
201
|
+
if (obj.type === "user" && !isToolResultMessage(obj)) {
|
|
202
|
+
if (current) {
|
|
203
|
+
const built = buildExchange(current);
|
|
204
|
+
if (built) exchanges.push(built);
|
|
205
|
+
}
|
|
206
|
+
current = { userMsg: obj, assistantEntries: [], toolResultEntries: [] };
|
|
207
|
+
} else if (current) {
|
|
208
|
+
if (obj.type === "assistant") {
|
|
209
|
+
current.assistantEntries.push(obj);
|
|
210
|
+
} else if (obj.type === "user" && isToolResultMessage(obj)) {
|
|
211
|
+
current.toolResultEntries.push(obj);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (current) {
|
|
216
|
+
const built = buildExchange(current);
|
|
217
|
+
if (built) exchanges.push(built);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
sessionUuid: baseName(sessionPath),
|
|
222
|
+
firstUserText,
|
|
223
|
+
cwd,
|
|
224
|
+
gitBranch,
|
|
225
|
+
startedAt,
|
|
226
|
+
messageCount,
|
|
227
|
+
exchanges,
|
|
228
|
+
partial,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function baseName(p) {
|
|
233
|
+
const i = p.lastIndexOf("/");
|
|
234
|
+
const file = i >= 0 ? p.slice(i + 1) : p;
|
|
235
|
+
return file.replace(/\.jsonl$/, "");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function buildExchange(ex) {
|
|
239
|
+
const input = extractText(ex.userMsg.message?.content);
|
|
240
|
+
if (!input || SKIP_INPUT_PATTERN.test(input)) return null;
|
|
241
|
+
if (ex.assistantEntries.length === 0) return null;
|
|
242
|
+
|
|
243
|
+
const outputParts = [];
|
|
244
|
+
const reasoningParts = [];
|
|
245
|
+
|
|
246
|
+
for (const a of ex.assistantEntries) {
|
|
247
|
+
const content = a.message?.content;
|
|
248
|
+
if (!Array.isArray(content)) continue;
|
|
249
|
+
const t = extractText(content);
|
|
250
|
+
if (t) outputParts.push(t);
|
|
251
|
+
const thinking = extractThinking(content);
|
|
252
|
+
if (thinking) reasoningParts.push("THINKING:\n" + thinking);
|
|
253
|
+
const tc = extractToolCalls(content);
|
|
254
|
+
if (tc) reasoningParts.push(tc);
|
|
255
|
+
}
|
|
256
|
+
for (const tr of ex.toolResultEntries) {
|
|
257
|
+
const trText = extractToolResultContent(tr);
|
|
258
|
+
if (trText) reasoningParts.push(trText);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
user_uuid: ex.userMsg.uuid || "unknown",
|
|
263
|
+
input,
|
|
264
|
+
output: outputParts.join("\n\n"),
|
|
265
|
+
reasoning: reasoningParts.join("\n---\n"),
|
|
266
|
+
timestamp:
|
|
267
|
+
ex.assistantEntries[ex.assistantEntries.length - 1].timestamp ||
|
|
268
|
+
ex.userMsg.timestamp ||
|
|
269
|
+
"",
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Peek just enough of a JSONL session to harvest its metadata
|
|
275
|
+
* (cwd, gitBranch, startedAt, sessionId). Cheap — stops at the first
|
|
276
|
+
* parseable line.
|
|
277
|
+
*
|
|
278
|
+
* @param {string} sessionPath
|
|
279
|
+
* @returns {Promise<{cwd: string, gitBranch: string|null, startedAt: string|null, sessionId: string|null}>}
|
|
280
|
+
*/
|
|
281
|
+
export async function peekSessionMetadata(sessionPath) {
|
|
282
|
+
const rl = readline.createInterface({
|
|
283
|
+
input: fs.createReadStream(sessionPath, { encoding: "utf8" }),
|
|
284
|
+
crlfDelay: Infinity,
|
|
285
|
+
});
|
|
286
|
+
try {
|
|
287
|
+
for await (const raw of rl) {
|
|
288
|
+
const line = raw.trim();
|
|
289
|
+
if (!line) continue;
|
|
290
|
+
try {
|
|
291
|
+
const obj = JSON.parse(line);
|
|
292
|
+
return {
|
|
293
|
+
cwd: typeof obj.cwd === "string" ? obj.cwd : "",
|
|
294
|
+
gitBranch: typeof obj.gitBranch === "string" ? obj.gitBranch : null,
|
|
295
|
+
startedAt: typeof obj.timestamp === "string" ? obj.timestamp : null,
|
|
296
|
+
sessionId: typeof obj.sessionId === "string" ? obj.sessionId : null,
|
|
297
|
+
};
|
|
298
|
+
} catch {
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
} finally {
|
|
303
|
+
rl.close();
|
|
304
|
+
}
|
|
305
|
+
return { cwd: "", gitBranch: null, startedAt: null, sessionId: null };
|
|
306
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* daemon.mjs — Detached-child spawn helper and run-id utilities.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import crypto from "node:crypto";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `YYYYMMDD-HHMMSS-<6-hex>`.
|
|
12
|
+
*/
|
|
13
|
+
export function generateRunId() {
|
|
14
|
+
const d = new Date();
|
|
15
|
+
const pad = (n, w = 2) => String(n).padStart(w, "0");
|
|
16
|
+
const stamp =
|
|
17
|
+
d.getUTCFullYear() +
|
|
18
|
+
pad(d.getUTCMonth() + 1) +
|
|
19
|
+
pad(d.getUTCDate()) +
|
|
20
|
+
"-" +
|
|
21
|
+
pad(d.getUTCHours()) +
|
|
22
|
+
pad(d.getUTCMinutes()) +
|
|
23
|
+
pad(d.getUTCSeconds());
|
|
24
|
+
const rand = crypto.randomBytes(3).toString("hex");
|
|
25
|
+
return `${stamp}-${rand}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Root directory for all migrate runs.
|
|
30
|
+
*
|
|
31
|
+
* @param {object} [opts]
|
|
32
|
+
* @param {string} [opts.home]
|
|
33
|
+
*/
|
|
34
|
+
export function migrateRoot(opts = {}) {
|
|
35
|
+
const home = opts.home ?? process.env.HOME;
|
|
36
|
+
return path.join(home, ".meko", "migrate");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Spawn a detached child running the installer binary with `--__migrate-child`.
|
|
41
|
+
*
|
|
42
|
+
* @param {object} opts
|
|
43
|
+
* @param {string} opts.scriptPath Absolute path to create-meko-setup.mjs.
|
|
44
|
+
* @param {string} opts.runId
|
|
45
|
+
* @param {string[]} opts.args Pass-through CLI args (e.g., ["--scope","user"]).
|
|
46
|
+
* @param {string} opts.logFilePath File to redirect child stdout+stderr to.
|
|
47
|
+
* @param {NodeJS.ProcessEnv} [opts.env]
|
|
48
|
+
* @param {string} [opts.cwd]
|
|
49
|
+
* @returns {number} The detached child's PID.
|
|
50
|
+
*/
|
|
51
|
+
export function forkDetached(opts) {
|
|
52
|
+
fs.mkdirSync(path.dirname(opts.logFilePath), { recursive: true });
|
|
53
|
+
const logFd = fs.openSync(opts.logFilePath, "a");
|
|
54
|
+
|
|
55
|
+
const child = spawn(
|
|
56
|
+
process.execPath,
|
|
57
|
+
[opts.scriptPath, "--__migrate-child", "--run-id", opts.runId, ...opts.args],
|
|
58
|
+
{
|
|
59
|
+
detached: true,
|
|
60
|
+
stdio: ["ignore", logFd, logFd],
|
|
61
|
+
env: opts.env ?? process.env,
|
|
62
|
+
cwd: opts.cwd ?? process.cwd(),
|
|
63
|
+
},
|
|
64
|
+
);
|
|
65
|
+
child.unref();
|
|
66
|
+
return child.pid;
|
|
67
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* id.mjs — Agent-id derivation and seed computation for the migrator.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Slugify an absolute path the way Claude Code stores project sessions:
|
|
9
|
+
* `/Users/foo/bar` → `-Users-foo-bar`.
|
|
10
|
+
*
|
|
11
|
+
* Matches the on-disk folder names under `~/.claude/projects/`.
|
|
12
|
+
*
|
|
13
|
+
* @param {string} cwd - Absolute path.
|
|
14
|
+
* @returns {string}
|
|
15
|
+
*/
|
|
16
|
+
export function slugifyCwd(cwd) {
|
|
17
|
+
if (!cwd) return "";
|
|
18
|
+
return cwd.replace(/[^A-Za-z0-9]/g, "-");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Reverse-derive an absolute path from a Claude-style slug. Used when
|
|
23
|
+
* scanning `~/.claude/projects/` and matching a session's `cwd` field
|
|
24
|
+
* against a slugged directory name.
|
|
25
|
+
*
|
|
26
|
+
* Note: the slugify is lossy (multiple different paths can collide);
|
|
27
|
+
* prefer matching on the session's embedded `cwd` field when available.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} slug
|
|
30
|
+
* @returns {string}
|
|
31
|
+
*/
|
|
32
|
+
export function unslugifyCwd(slug) {
|
|
33
|
+
return slug.replace(/-/g, "/");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {string} cwd - Absolute project path.
|
|
38
|
+
* @returns {string} Agent id, e.g. `claude-code:-Users-foo-bar`.
|
|
39
|
+
*/
|
|
40
|
+
export function agentIdForClaude(cwd) {
|
|
41
|
+
return `claude-code:${slugifyCwd(cwd)}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* User-scope agent id for artifacts that aren't tied to a specific project
|
|
46
|
+
* (e.g., `~/.claude/CLAUDE.md`).
|
|
47
|
+
*/
|
|
48
|
+
export const CLAUDE_CODE_USER_AGENT_ID = "claude-code:user";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {string} projectName - Cursor's human-readable project folder name.
|
|
52
|
+
* @returns {string}
|
|
53
|
+
*/
|
|
54
|
+
export function agentIdForCursor(projectName) {
|
|
55
|
+
return `cursor:${projectName}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Deterministic seed for `conversation_add_message` dedup.
|
|
60
|
+
* sha256(agentId + ":" + sessionUuid + ":" + messageUuid), truncated.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} agentId
|
|
63
|
+
* @param {string} sessionUuid
|
|
64
|
+
* @param {string} messageUuid
|
|
65
|
+
* @returns {string} 32-char hex
|
|
66
|
+
*/
|
|
67
|
+
export function seedFor(agentId, sessionUuid, messageUuid) {
|
|
68
|
+
return createHash("sha256")
|
|
69
|
+
.update(`${agentId}:${sessionUuid}:${messageUuid}`)
|
|
70
|
+
.digest("hex")
|
|
71
|
+
.slice(0, 32);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Dedup hash for a memory-MD entry.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} text
|
|
78
|
+
* @param {string} [name]
|
|
79
|
+
* @returns {string} 64-char hex
|
|
80
|
+
*/
|
|
81
|
+
export function memoryHash(text, name = "") {
|
|
82
|
+
return createHash("sha256").update(`${name}\0${text}`).digest("hex");
|
|
83
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lockfile.mjs — PID-based lockfile for migration runs.
|
|
3
|
+
*
|
|
4
|
+
* Lock contents: {pid, runId, startedAt}. A lock is considered stale
|
|
5
|
+
* if the PID it names no longer has a live process.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {Object} LockInfo
|
|
13
|
+
* @property {number} pid
|
|
14
|
+
* @property {string} runId
|
|
15
|
+
* @property {string} startedAt ISO timestamp.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Acquire a lock at `lockPath`. Returns `{acquired: true}` on success,
|
|
20
|
+
* or `{acquired: false, holder: LockInfo, stale: boolean}` if another
|
|
21
|
+
* process already holds it.
|
|
22
|
+
*
|
|
23
|
+
* @param {string} lockPath
|
|
24
|
+
* @param {{pid?: number, runId: string}} info
|
|
25
|
+
* @param {{force?: boolean}} [opts]
|
|
26
|
+
* @returns {{acquired: true} | {acquired: false, holder: LockInfo, stale: boolean}}
|
|
27
|
+
*/
|
|
28
|
+
export function acquire(lockPath, info, opts = {}) {
|
|
29
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
30
|
+
|
|
31
|
+
const existing = read(lockPath);
|
|
32
|
+
if (existing) {
|
|
33
|
+
const stale = isStale(existing);
|
|
34
|
+
if (!stale && !opts.force) {
|
|
35
|
+
return { acquired: false, holder: existing, stale: false };
|
|
36
|
+
}
|
|
37
|
+
// Stale or force — clobber.
|
|
38
|
+
try {
|
|
39
|
+
fs.unlinkSync(lockPath);
|
|
40
|
+
} catch {
|
|
41
|
+
/* ignore */
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const payload = {
|
|
46
|
+
pid: info.pid ?? process.pid,
|
|
47
|
+
runId: info.runId,
|
|
48
|
+
startedAt: new Date().toISOString(),
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// O_WRONLY | O_CREAT | O_EXCL — fail if another process got here first.
|
|
52
|
+
try {
|
|
53
|
+
const fd = fs.openSync(lockPath, "wx");
|
|
54
|
+
fs.writeSync(fd, JSON.stringify(payload));
|
|
55
|
+
fs.closeSync(fd);
|
|
56
|
+
return { acquired: true };
|
|
57
|
+
} catch (err) {
|
|
58
|
+
if (err.code === "EEXIST") {
|
|
59
|
+
const holder = read(lockPath);
|
|
60
|
+
return {
|
|
61
|
+
acquired: false,
|
|
62
|
+
holder: holder ?? { pid: -1, runId: "?", startedAt: "?" },
|
|
63
|
+
stale: false,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
throw err;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Release a lock held by this process. Safe to call multiple times.
|
|
72
|
+
*
|
|
73
|
+
* @param {string} lockPath
|
|
74
|
+
*/
|
|
75
|
+
export function release(lockPath) {
|
|
76
|
+
try {
|
|
77
|
+
const holder = read(lockPath);
|
|
78
|
+
if (holder && holder.pid !== process.pid) {
|
|
79
|
+
// Not ours — don't clobber.
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
fs.unlinkSync(lockPath);
|
|
83
|
+
} catch {
|
|
84
|
+
/* best effort */
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Read a lockfile, returning null if missing or unreadable.
|
|
90
|
+
*
|
|
91
|
+
* @param {string} lockPath
|
|
92
|
+
* @returns {LockInfo | null}
|
|
93
|
+
*/
|
|
94
|
+
export function read(lockPath) {
|
|
95
|
+
try {
|
|
96
|
+
const raw = fs.readFileSync(lockPath, "utf8");
|
|
97
|
+
return JSON.parse(raw);
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Is the process named in `info` no longer alive?
|
|
105
|
+
*
|
|
106
|
+
* Uses `process.kill(pid, 0)` which throws ESRCH for dead PIDs and
|
|
107
|
+
* succeeds (or throws EPERM) for live ones.
|
|
108
|
+
*
|
|
109
|
+
* @param {LockInfo} info
|
|
110
|
+
* @returns {boolean}
|
|
111
|
+
*/
|
|
112
|
+
export function isStale(info) {
|
|
113
|
+
if (!info || typeof info.pid !== "number" || info.pid <= 0) return true;
|
|
114
|
+
try {
|
|
115
|
+
process.kill(info.pid, 0);
|
|
116
|
+
return false; // live
|
|
117
|
+
} catch (err) {
|
|
118
|
+
if (err.code === "EPERM") return false; // live, owned by another user
|
|
119
|
+
return true; // ESRCH or similar
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* logger.mjs — NDJSON event logger for migration runs.
|
|
3
|
+
*
|
|
4
|
+
* One JSON object per line. No dependency on the node logger used by
|
|
5
|
+
* the installer (this one writes to a file fd on disk, not stderr).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {Object} Logger
|
|
12
|
+
* @property {(event: string, fields?: object) => void} info
|
|
13
|
+
* @property {(event: string, fields?: object) => void} warn
|
|
14
|
+
* @property {(event: string, fields?: object) => void} error
|
|
15
|
+
* @property {() => void} close
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Create a logger that appends NDJSON to `logPath`. Also forwards to
|
|
20
|
+
* `process.stderr` when `tee` is true (useful in foreground dry-runs).
|
|
21
|
+
*
|
|
22
|
+
* @param {string} logPath - Absolute file path.
|
|
23
|
+
* @param {object} [opts]
|
|
24
|
+
* @param {string} [opts.runId]
|
|
25
|
+
* @param {boolean} [opts.tee]
|
|
26
|
+
* @returns {Logger}
|
|
27
|
+
*/
|
|
28
|
+
export function createLogger(logPath, opts = {}) {
|
|
29
|
+
const fd = fs.openSync(logPath, "a");
|
|
30
|
+
const runId = opts.runId;
|
|
31
|
+
const tee = !!opts.tee;
|
|
32
|
+
|
|
33
|
+
function emit(level, event, fields) {
|
|
34
|
+
const line =
|
|
35
|
+
JSON.stringify({
|
|
36
|
+
ts: new Date().toISOString(),
|
|
37
|
+
level,
|
|
38
|
+
event,
|
|
39
|
+
runId,
|
|
40
|
+
...(fields || {}),
|
|
41
|
+
}) + "\n";
|
|
42
|
+
try {
|
|
43
|
+
fs.writeSync(fd, line);
|
|
44
|
+
} catch {
|
|
45
|
+
/* best effort */
|
|
46
|
+
}
|
|
47
|
+
if (tee) {
|
|
48
|
+
process.stderr.write(line);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
info: (event, fields) => emit("info", event, fields),
|
|
54
|
+
warn: (event, fields) => emit("warn", event, fields),
|
|
55
|
+
error: (event, fields) => emit("error", event, fields),
|
|
56
|
+
close() {
|
|
57
|
+
try {
|
|
58
|
+
fs.closeSync(fd);
|
|
59
|
+
} catch {
|
|
60
|
+
/* best effort */
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Create a no-op logger. Useful in tests or `--dry-run` before the
|
|
68
|
+
* run directory exists.
|
|
69
|
+
*
|
|
70
|
+
* @returns {Logger}
|
|
71
|
+
*/
|
|
72
|
+
export function nullLogger() {
|
|
73
|
+
return {
|
|
74
|
+
info: () => {},
|
|
75
|
+
warn: () => {},
|
|
76
|
+
error: () => {},
|
|
77
|
+
close: () => {},
|
|
78
|
+
};
|
|
79
|
+
}
|