evrex-mcp 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/import.js ADDED
@@ -0,0 +1,1667 @@
1
+ #!/usr/bin/env node
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __esm = (fn, res) => function __init() {
5
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
6
+ };
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+
12
+ // src/client.ts
13
+ var client_exports = {};
14
+ __export(client_exports, {
15
+ evrexApi: () => evrexApi
16
+ });
17
+ function headers() {
18
+ const base = { "Content-Type": "application/json" };
19
+ if (EVREX_TOKEN) base.Authorization = `Bearer ${EVREX_TOKEN}`;
20
+ return base;
21
+ }
22
+ function describeFailure(method, path, status, statusText) {
23
+ if (status === 401 || status === 403) {
24
+ return EVREX_TOKEN ? `${method} ${path} -> ${status}: the EVREX_TOKEN this server was started with was rejected. It may be revoked or expired.` : `${method} ${path} -> ${status}: this evrex backend requires a credential, but no EVREX_TOKEN is set for this MCP server.`;
25
+ }
26
+ return `${method} ${path} -> ${status} ${statusText}`;
27
+ }
28
+ async function request(method, path, { body, absentIsAnswer } = {}) {
29
+ const res = await fetch(`${API_BASE_URL}${path}`, {
30
+ method,
31
+ headers: headers(),
32
+ ...body === void 0 ? {} : { body: JSON.stringify(body) }
33
+ });
34
+ if (res.status === 404 && absentIsAnswer) return null;
35
+ if (!res.ok) throw new Error(describeFailure(method, path, res.status, res.statusText));
36
+ return await res.json();
37
+ }
38
+ var DEFAULT_API_BASE_URL, API_BASE_URL, EVREX_TOKEN, get, getOrNull, post, evrexApi;
39
+ var init_client = __esm({
40
+ "src/client.ts"() {
41
+ "use strict";
42
+ DEFAULT_API_BASE_URL = "https://api.evrex.ai";
43
+ API_BASE_URL = (process.env.EVREX_API_BASE_URL ?? process.env.EVREX_API_URL ?? DEFAULT_API_BASE_URL).replace(/\/+$/, "");
44
+ EVREX_TOKEN = process.env.EVREX_TOKEN ?? process.env.EVREX_API_TOKEN ?? null;
45
+ get = (path) => request("GET", path);
46
+ getOrNull = (path) => request("GET", path, { absentIsAnswer: true });
47
+ post = (path, body) => request("POST", path, { body });
48
+ evrexApi = {
49
+ baseUrl: API_BASE_URL,
50
+ repos: () => get("/repos"),
51
+ commits: (repoPath) => get(`/commits?repoPath=${encodeURIComponent(repoPath)}`),
52
+ // Abbreviated shas resolve server-side, so a value pasted from `git log`
53
+ // works here (apps/backend/src/reads/reads.service.ts#resolveSha).
54
+ commit: (sha) => getOrNull(`/commits/${encodeURIComponent(sha)}`),
55
+ sessions: (repoPath) => get(`/sessions?repoPath=${encodeURIComponent(repoPath)}`),
56
+ session: (id) => getOrNull(`/sessions/${encodeURIComponent(id)}`),
57
+ ask: (repoPath, text, filePaths) => post("/ask", { repoPath, text, filePaths }),
58
+ // Evidence-only retrieval (BM25 + embedding), no LLM synthesis call — see
59
+ // apps/backend/src/query/query.service.ts#search. Used by evrex_search,
60
+ // which wants ranked hits fast, not a synthesized paragraph.
61
+ search: (repoPath, text, filePaths) => post("/search", { repoPath, text, filePaths })
62
+ };
63
+ }
64
+ });
65
+
66
+ // src/credential-store.ts
67
+ var credential_store_exports = {};
68
+ __export(credential_store_exports, {
69
+ CredentialStore: () => CredentialStore,
70
+ NoKeychainError: () => NoKeychainError,
71
+ systemRunner: () => systemRunner
72
+ });
73
+ import { spawn } from "node:child_process";
74
+ var SERVICE, ACCOUNT, systemRunner, NoKeychainError, CredentialStore, WINDOWS_PATH, WINDOWS_STORE, WINDOWS_RETRIEVE, WINDOWS_REMOVE;
75
+ var init_credential_store = __esm({
76
+ "src/credential-store.ts"() {
77
+ "use strict";
78
+ SERVICE = "evrex-capture";
79
+ ACCOUNT = "evrex";
80
+ systemRunner = {
81
+ platform: process.platform,
82
+ run(command, args, stdin) {
83
+ return new Promise((resolve2) => {
84
+ const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"] });
85
+ let stdout = "";
86
+ let stderr = "";
87
+ child.stdout.on("data", (d) => stdout += d.toString());
88
+ child.stderr.on("data", (d) => stderr += d.toString());
89
+ child.on("error", () => resolve2({ code: 127, stdout: "", stderr: "" }));
90
+ child.on("close", (code) => resolve2({ code: code ?? 1, stdout, stderr }));
91
+ if (stdin !== void 0) child.stdin.write(stdin);
92
+ child.stdin.end();
93
+ });
94
+ }
95
+ };
96
+ NoKeychainError = class extends Error {
97
+ constructor(platform) {
98
+ super(
99
+ `evrex could not find a credential store on this machine (${platform}).
100
+ macOS needs \`security\`, which ships with the system.
101
+ Linux needs \`secret-tool\` \u2014 install libsecret-tools (Debian/Ubuntu)
102
+ or libsecret (Fedora/Arch), and make sure a keyring daemon is running.
103
+ Windows needs PowerShell.
104
+ evrex will not fall back to writing the credential in a plain file.`
105
+ );
106
+ this.name = "NoKeychainError";
107
+ }
108
+ };
109
+ CredentialStore = class {
110
+ constructor(runner = systemRunner) {
111
+ this.runner = runner;
112
+ }
113
+ async available() {
114
+ switch (this.runner.platform) {
115
+ case "darwin":
116
+ return (await this.runner.run("security", ["help"])).code !== 127;
117
+ case "win32":
118
+ return (await this.runner.run("powershell", ["-Command", "$PSVersionTable.PSVersion.Major"])).code !== 127;
119
+ default:
120
+ return (await this.runner.run("secret-tool", ["--version"])).code !== 127;
121
+ }
122
+ }
123
+ /**
124
+ * Replaces any existing credential rather than adding a second one. A
125
+ * machine that re-enrols after expiry must end up with exactly one entry, or
126
+ * the next read is a coin flip between the live credential and a dead one.
127
+ */
128
+ async store(secret) {
129
+ if (!await this.available()) throw new NoKeychainError(this.runner.platform);
130
+ switch (this.runner.platform) {
131
+ case "darwin": {
132
+ const result = await this.runner.run(
133
+ "security",
134
+ ["add-generic-password", "-a", ACCOUNT, "-s", SERVICE, "-U", "-w"],
135
+ `${secret}
136
+ ${secret}
137
+ `
138
+ );
139
+ if (result.code !== 0) throw new Error(`Keychain write failed: ${result.stderr.trim()}`);
140
+ return;
141
+ }
142
+ case "win32": {
143
+ const result = await this.runner.run(
144
+ "powershell",
145
+ ["-NoProfile", "-Command", WINDOWS_STORE],
146
+ secret
147
+ );
148
+ if (result.code !== 0) throw new Error(`DPAPI write failed: ${result.stderr.trim()}`);
149
+ return;
150
+ }
151
+ default: {
152
+ const result = await this.runner.run(
153
+ "secret-tool",
154
+ ["store", "--label=evrex capture credential", "service", SERVICE, "account", ACCOUNT],
155
+ secret
156
+ );
157
+ if (result.code !== 0) throw new Error(`secret-tool write failed: ${result.stderr.trim()}`);
158
+ return;
159
+ }
160
+ }
161
+ }
162
+ /** Null when there is nothing stored, which is the normal pre-enrolment state. */
163
+ async retrieve() {
164
+ if (!await this.available()) throw new NoKeychainError(this.runner.platform);
165
+ switch (this.runner.platform) {
166
+ case "darwin": {
167
+ const r = await this.runner.run("security", [
168
+ "find-generic-password",
169
+ "-a",
170
+ ACCOUNT,
171
+ "-s",
172
+ SERVICE,
173
+ "-w"
174
+ ]);
175
+ return r.code === 0 ? r.stdout.trim() || null : null;
176
+ }
177
+ case "win32": {
178
+ const r = await this.runner.run("powershell", ["-NoProfile", "-Command", WINDOWS_RETRIEVE]);
179
+ return r.code === 0 ? r.stdout.trim() || null : null;
180
+ }
181
+ default: {
182
+ const r = await this.runner.run("secret-tool", [
183
+ "lookup",
184
+ "service",
185
+ SERVICE,
186
+ "account",
187
+ ACCOUNT
188
+ ]);
189
+ return r.code === 0 ? r.stdout.trim() || null : null;
190
+ }
191
+ }
192
+ }
193
+ /** Idempotent: removing a credential that is not there is not an error. */
194
+ async remove() {
195
+ if (!await this.available()) return;
196
+ switch (this.runner.platform) {
197
+ case "darwin":
198
+ await this.runner.run("security", [
199
+ "delete-generic-password",
200
+ "-a",
201
+ ACCOUNT,
202
+ "-s",
203
+ SERVICE
204
+ ]);
205
+ return;
206
+ case "win32":
207
+ await this.runner.run("powershell", ["-NoProfile", "-Command", WINDOWS_REMOVE]);
208
+ return;
209
+ default:
210
+ await this.runner.run("secret-tool", [
211
+ "clear",
212
+ "service",
213
+ SERVICE,
214
+ "account",
215
+ ACCOUNT
216
+ ]);
217
+ return;
218
+ }
219
+ }
220
+ };
221
+ WINDOWS_PATH = "$env:APPDATA\\evrex\\capture.cred";
222
+ WINDOWS_STORE = `
223
+ $ErrorActionPreference = 'Stop'
224
+ $p = "${WINDOWS_PATH}"
225
+ New-Item -ItemType Directory -Force -Path (Split-Path $p) | Out-Null
226
+ $secret = [Console]::In.ReadToEnd().Trim()
227
+ ConvertTo-SecureString $secret -AsPlainText -Force | ConvertFrom-SecureString | Set-Content -Path $p
228
+ `.trim();
229
+ WINDOWS_RETRIEVE = `
230
+ $ErrorActionPreference = 'Stop'
231
+ $p = "${WINDOWS_PATH}"
232
+ if (-not (Test-Path $p)) { exit 1 }
233
+ $sec = Get-Content $p | ConvertTo-SecureString
234
+ [Runtime.InteropServices.Marshal]::PtrToStringAuto(
235
+ [Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec))
236
+ `.trim();
237
+ WINDOWS_REMOVE = `
238
+ $p = "${WINDOWS_PATH}"
239
+ if (Test-Path $p) { Remove-Item $p -Force }
240
+ `.trim();
241
+ }
242
+ });
243
+
244
+ // src/import.ts
245
+ import { realpathSync } from "node:fs";
246
+ import { homedir as homedir5 } from "node:os";
247
+ import { fileURLToPath } from "node:url";
248
+ import { resolve } from "node:path";
249
+
250
+ // ../../packages/ingest-core/src/index.ts
251
+ import { userInfo } from "node:os";
252
+
253
+ // ../../packages/ingest-core/src/git-history.ts
254
+ import { execFileSync } from "node:child_process";
255
+ var DIFF_CAP = 2e4;
256
+ var FIELD_SEP = "";
257
+ var EVREX_SESSION_TRAILER_KEY = "Evrex-Session";
258
+ function git(repoPath, args, input) {
259
+ return execFileSync("git", args, {
260
+ cwd: repoPath,
261
+ maxBuffer: 1024 * 1024 * 64,
262
+ input
263
+ }).toString("utf-8");
264
+ }
265
+ function gitQuiet(repoPath, args) {
266
+ return execFileSync("git", args, {
267
+ cwd: repoPath,
268
+ maxBuffer: 1024 * 1024 * 8,
269
+ stdio: ["ignore", "pipe", "ignore"]
270
+ }).toString("utf-8");
271
+ }
272
+ function normalizeRepoRemote(url) {
273
+ const trimmed = url.trim();
274
+ if (!trimmed) return null;
275
+ let host;
276
+ let path;
277
+ const scpLike = /^(?:[^/@]+@)?([^/:@]+\.[^/:@]+):(?!\/)(.+)$/.exec(trimmed);
278
+ if (scpLike?.[1] && scpLike[2]) {
279
+ host = scpLike[1];
280
+ path = scpLike[2];
281
+ } else {
282
+ let parsed;
283
+ try {
284
+ parsed = new URL(trimmed);
285
+ } catch {
286
+ return null;
287
+ }
288
+ if (parsed.protocol === "file:" || !parsed.hostname) return null;
289
+ host = parsed.hostname;
290
+ path = parsed.pathname;
291
+ }
292
+ const cleanedPath = path.replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/i, "");
293
+ if (!cleanedPath) return null;
294
+ return `${host}/${cleanedPath}`.toLowerCase();
295
+ }
296
+ function repoIdFromRemote(url) {
297
+ const normalized = normalizeRepoRemote(url);
298
+ return normalized ? `remote:${normalized}` : null;
299
+ }
300
+ function repoIdFromRootCommit(sha) {
301
+ return `root:${sha}`;
302
+ }
303
+ function repoIdFromPath(repoPath) {
304
+ return `path:${repoPath}`;
305
+ }
306
+ function firstRemoteUrl(repoPath) {
307
+ try {
308
+ const origin = gitQuiet(repoPath, ["remote", "get-url", "origin"]).trim();
309
+ if (origin) return origin;
310
+ } catch {
311
+ }
312
+ try {
313
+ const names = gitQuiet(repoPath, ["remote"]).split("\n").map((n) => n.trim()).filter(Boolean).sort();
314
+ for (const name of names) {
315
+ const url = gitQuiet(repoPath, ["remote", "get-url", name]).trim();
316
+ if (url) return url;
317
+ }
318
+ } catch {
319
+ }
320
+ return null;
321
+ }
322
+ function rootCommitSha(repoPath) {
323
+ try {
324
+ const shas = gitQuiet(repoPath, ["rev-list", "--max-parents=0", "HEAD"]).split("\n").map((s) => s.trim()).filter(Boolean).sort();
325
+ return shas[0] ?? null;
326
+ } catch {
327
+ return null;
328
+ }
329
+ }
330
+ function deriveRepoId(repoPath) {
331
+ const remote = firstRemoteUrl(repoPath);
332
+ if (remote) {
333
+ const fromRemote = repoIdFromRemote(remote);
334
+ if (fromRemote) return fromRemote;
335
+ }
336
+ const root = rootCommitSha(repoPath);
337
+ if (root) return repoIdFromRootCommit(root);
338
+ return repoIdFromPath(repoPath);
339
+ }
340
+ function getGitUserName(repoPath) {
341
+ try {
342
+ const name = git(repoPath, ["config", "user.name"]).trim();
343
+ return name || null;
344
+ } catch {
345
+ return null;
346
+ }
347
+ }
348
+ function listShas(repoPath) {
349
+ const out = git(repoPath, ["rev-list", "--reverse", "HEAD"]).trim();
350
+ return out.length ? out.split("\n") : [];
351
+ }
352
+ function commitMeta(repoPath, sha) {
353
+ const line = git(repoPath, [
354
+ "show",
355
+ "-s",
356
+ `--format=%H${FIELD_SEP}%an${FIELD_SEP}%ae${FIELD_SEP}%aI`,
357
+ sha
358
+ ]).trim();
359
+ const parts = line.split(FIELD_SEP);
360
+ const message = git(repoPath, ["show", "-s", "--format=%B", sha]).replace(/\n+$/, "");
361
+ return {
362
+ sha: parts[0] ?? sha,
363
+ author: parts[1] ?? "",
364
+ authorEmail: parts[2] ?? "",
365
+ ts: parts[3] ?? (/* @__PURE__ */ new Date()).toISOString(),
366
+ message
367
+ };
368
+ }
369
+ function extractTrailer(repoPath, message, key) {
370
+ try {
371
+ const out = git(
372
+ repoPath,
373
+ ["interpret-trailers", "--parse", "--no-divider"],
374
+ message
375
+ ).trim();
376
+ for (const line of out.split("\n")) {
377
+ const idx = line.indexOf(":");
378
+ if (idx === -1) continue;
379
+ const trailerKey = line.slice(0, idx).trim();
380
+ if (trailerKey.toLowerCase() === key.toLowerCase()) {
381
+ return line.slice(idx + 1).trim();
382
+ }
383
+ }
384
+ return null;
385
+ } catch {
386
+ return null;
387
+ }
388
+ }
389
+ function commitBranch(repoPath, sha) {
390
+ try {
391
+ const out = git(repoPath, ["branch", "--contains", sha, "--format=%(refname:short)"]).trim();
392
+ const first = out.split("\n").find((b) => b.length > 0);
393
+ return first ?? null;
394
+ } catch {
395
+ return null;
396
+ }
397
+ }
398
+ function numstat(repoPath, sha) {
399
+ const out = git(repoPath, ["show", "--numstat", "--format=", sha]).trim();
400
+ const map = /* @__PURE__ */ new Map();
401
+ if (!out) return map;
402
+ for (const line of out.split("\n")) {
403
+ const [ins, del, path] = line.split(" ");
404
+ if (!path) continue;
405
+ map.set(path, {
406
+ insertions: !ins || ins === "-" ? 0 : Number.parseInt(ins, 10) || 0,
407
+ deletions: !del || del === "-" ? 0 : Number.parseInt(del, 10) || 0
408
+ });
409
+ }
410
+ return map;
411
+ }
412
+ function parseDiffBlocks(diff) {
413
+ const map = /* @__PURE__ */ new Map();
414
+ if (!diff.trim()) return map;
415
+ const blocks = diff.split(/^diff --git /m).filter(Boolean);
416
+ for (const block of blocks) {
417
+ const full = `diff --git ${block}`;
418
+ const pathMatch = block.match(/^a\/(.+?) b\/(.+?)\n/);
419
+ const path = pathMatch?.[2] ?? block.match(/^\S+/)?.[0] ?? "unknown";
420
+ const hunkHeaders = [...full.matchAll(/^@@ .+? @@.*$/gm)].map((m) => m[0]);
421
+ map.set(path, {
422
+ hunkHeaders,
423
+ diffText: full.length > DIFF_CAP ? full.slice(0, DIFF_CAP) : full
424
+ });
425
+ }
426
+ return map;
427
+ }
428
+ function commitFiles(repoPath, sha) {
429
+ const stats = numstat(repoPath, sha);
430
+ const diff = git(repoPath, ["show", "--unified=0", "--format=", sha]);
431
+ const blocks = parseDiffBlocks(diff);
432
+ const paths = /* @__PURE__ */ new Set([...stats.keys(), ...blocks.keys()]);
433
+ const files = [];
434
+ for (const path of paths) {
435
+ const stat = stats.get(path) ?? { insertions: 0, deletions: 0 };
436
+ const block = blocks.get(path) ?? { hunkHeaders: [], diffText: "" };
437
+ files.push({
438
+ path,
439
+ insertions: stat.insertions,
440
+ deletions: stat.deletions,
441
+ hunkHeaders: block.hunkHeaders,
442
+ diffText: block.diffText,
443
+ diffTruncated: block.diffText.length >= DIFF_CAP
444
+ });
445
+ }
446
+ return files;
447
+ }
448
+ function parseGitLog(repoPath, repoId, known) {
449
+ const shas = listShas(repoPath).filter((sha) => !known?.has(sha));
450
+ const id = repoId ?? deriveRepoId(repoPath);
451
+ return shas.map((sha) => {
452
+ const meta = commitMeta(repoPath, sha);
453
+ return {
454
+ sha: meta.sha,
455
+ repoId: id,
456
+ repoPath,
457
+ author: meta.author,
458
+ authorEmail: meta.authorEmail,
459
+ ts: meta.ts,
460
+ message: meta.message,
461
+ branch: commitBranch(repoPath, sha),
462
+ evrexSessionTrailer: extractTrailer(repoPath, meta.message, EVREX_SESSION_TRAILER_KEY),
463
+ files: commitFiles(repoPath, sha)
464
+ };
465
+ });
466
+ }
467
+
468
+ // ../../packages/ingest-core/src/claude-sessions.ts
469
+ import { existsSync, readdirSync, readFileSync, statSync as statSync2 } from "node:fs";
470
+
471
+ // ../../packages/ingest-core/src/incremental.ts
472
+ import { statSync } from "node:fs";
473
+ function planRead(path, cursor) {
474
+ let size;
475
+ let modifiedAt;
476
+ if (cursor && (cursor.parserVersion ?? 0) < PARSER_VERSION) {
477
+ cursor = void 0;
478
+ }
479
+ try {
480
+ const stat = statSync(path);
481
+ size = stat.size;
482
+ modifiedAt = stat.mtimeMs;
483
+ } catch {
484
+ return { path, from: 0, reason: "unchanged" };
485
+ }
486
+ if (!cursor) return { path, from: 0, reason: "new" };
487
+ if (size < cursor.size) return { path, from: 0, reason: "rewritten" };
488
+ if (size === cursor.size && modifiedAt <= cursor.modifiedAt) {
489
+ return { path, from: cursor.offset, reason: "unchanged" };
490
+ }
491
+ if (size === cursor.offset) return { path, from: cursor.offset, reason: "unchanged" };
492
+ return { path, from: cursor.offset, reason: "appended" };
493
+ }
494
+ function advanceCursor(path, consumedTo) {
495
+ let size = consumedTo;
496
+ let modifiedAt = Date.now();
497
+ try {
498
+ const stat = statSync(path);
499
+ size = stat.size;
500
+ modifiedAt = stat.mtimeMs;
501
+ } catch {
502
+ }
503
+ return { offset: consumedTo, size, modifiedAt, parserVersion: PARSER_VERSION };
504
+ }
505
+ var PARSER_VERSION = 4;
506
+
507
+ // ../../packages/ingest-core/src/claude-sessions.ts
508
+ import { homedir } from "node:os";
509
+ import { join } from "node:path";
510
+
511
+ // ../../packages/ingest-core/src/redact.ts
512
+ var PATTERNS = [
513
+ { type: "private_key", regex: /-----BEGIN(?: [A-Z]+)? PRIVATE KEY-----[\s\S]*?-----END(?: [A-Z]+)? PRIVATE KEY-----/g },
514
+ { type: "aws_access_key", regex: /AKIA[0-9A-Z]{16}/g },
515
+ { type: "github_token", regex: /gh[pousr]_[A-Za-z0-9]{36,}/g },
516
+ { type: "slack_token", regex: /xox[baprs]-[A-Za-z0-9-]{10,}/g },
517
+ { type: "stripe_key", regex: /sk_(live|test)_[A-Za-z0-9]{24,}/g },
518
+ { type: "openai_key", regex: /sk-[A-Za-z0-9]{20,}/g },
519
+ { type: "anthropic_key", regex: /sk-ant-[A-Za-z0-9-_]{20,}/g },
520
+ { type: "jwt", regex: /eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g },
521
+ { type: "bearer_token", regex: /Bearer\s+[A-Za-z0-9\-_.]{20,}/g },
522
+ {
523
+ type: "env_secret",
524
+ regex: /\b([A-Z0-9_]*(?:_KEY|_SECRET|_TOKEN|_PASSWORD|PASSWD)[A-Z0-9_]*)\s*[:=]\s*["']?[^\s"'\n]{6,}["']?/g
525
+ }
526
+ ];
527
+ function redactJsonValue(value) {
528
+ let count = 0;
529
+ const walk = (v) => {
530
+ if (typeof v === "string") {
531
+ const r = redactSecrets(v);
532
+ count += r.count;
533
+ return r.text;
534
+ }
535
+ if (Array.isArray(v)) return v.map(walk);
536
+ if (v && typeof v === "object") {
537
+ const out = {};
538
+ for (const [k, val] of Object.entries(v)) {
539
+ out[k] = walk(val);
540
+ }
541
+ return out;
542
+ }
543
+ return v;
544
+ };
545
+ return { value: walk(value), count };
546
+ }
547
+ function redactSecrets(input) {
548
+ let text = input;
549
+ let count = 0;
550
+ for (const { type, regex } of PATTERNS) {
551
+ text = text.replace(regex, (match, group1) => {
552
+ count += 1;
553
+ if (type === "env_secret" && group1) {
554
+ return `${group1}=[REDACTED:${type}]`;
555
+ }
556
+ return `[REDACTED:${type}]`;
557
+ });
558
+ }
559
+ return { text, count };
560
+ }
561
+
562
+ // ../../packages/ingest-core/src/subject-linking.ts
563
+ function committedSubjects(command) {
564
+ const out = [];
565
+ const invocation = /\bgit\s+(?:-C\s+\S+\s+)?commit\b/g;
566
+ for (let found = invocation.exec(command); found !== null; found = invocation.exec(command)) {
567
+ const rest = command.slice(found.index + found[0].length);
568
+ const heredoc = /^[^\n]*<<-?\s*(['"]?)(\w+)\1[^\n]*\r?\n([^\n]*)/.exec(rest);
569
+ if (heredoc) {
570
+ const subject = heredoc[3].trim();
571
+ if (subject) out.push(subject);
572
+ continue;
573
+ }
574
+ const inline = /^[^\n]*?-m\s+(['"])([\s\S]*?)\1/.exec(rest);
575
+ if (inline) {
576
+ const subject = inline[2].split("\n")[0].trim();
577
+ if (subject) out.push(subject);
578
+ }
579
+ }
580
+ return out;
581
+ }
582
+
583
+ // ../../packages/ingest-core/src/types.ts
584
+ var CONVERSATION_KINDS = [
585
+ "claude-code",
586
+ "cursor",
587
+ "codex",
588
+ "gemini",
589
+ "slack"
590
+ ];
591
+ var REFERENCE_KINDS = ["linear", "jira", "confluence"];
592
+ var SOURCE_KINDS = [
593
+ ...CONVERSATION_KINDS,
594
+ ...REFERENCE_KINDS
595
+ ];
596
+ var EMPTY_USAGE = {
597
+ inputTokens: null,
598
+ outputTokens: null,
599
+ cacheReadTokens: null,
600
+ cacheWriteTokens: null,
601
+ model: null
602
+ };
603
+
604
+ // ../../packages/ingest-core/src/claude-sessions.ts
605
+ var MIN_MEANINGFUL_LINE_LENGTH = 6;
606
+ function meaningfulLines(lines) {
607
+ return lines.map((l) => l.trim()).filter((l) => l.length >= MIN_MEANINGFUL_LINE_LENGTH);
608
+ }
609
+ function extractEditedLines(toolUseResult) {
610
+ if (!toolUseResult || typeof toolUseResult !== "object") return null;
611
+ const r = toolUseResult;
612
+ if (!r.filePath) return null;
613
+ const added = [];
614
+ const removed = [];
615
+ if (Array.isArray(r.structuredPatch) && r.structuredPatch.length > 0) {
616
+ for (const hunk of r.structuredPatch) {
617
+ for (const line of hunk.lines ?? []) {
618
+ if (line.startsWith("+")) added.push(line.slice(1));
619
+ else if (line.startsWith("-")) removed.push(line.slice(1));
620
+ }
621
+ }
622
+ } else if (r.type === "create" && typeof r.content === "string") {
623
+ added.push(...r.content.split("\n"));
624
+ } else {
625
+ return null;
626
+ }
627
+ const meaningfulAdded = meaningfulLines(added);
628
+ const meaningfulRemoved = meaningfulLines(removed);
629
+ if (meaningfulAdded.length === 0 && meaningfulRemoved.length === 0) return null;
630
+ return { path: r.filePath, added: meaningfulAdded, removed: meaningfulRemoved };
631
+ }
632
+ var MAX_TURN_TEXT_LENGTH = 4e3;
633
+ function slugifyCwd(repoPath) {
634
+ return repoPath.replace(/\//g, "-");
635
+ }
636
+ function claudeProjectsDir() {
637
+ return join(homedir(), ".claude", "projects");
638
+ }
639
+ function findSessionFiles(repoPath) {
640
+ const dir = join(claudeProjectsDir(), slugifyCwd(repoPath));
641
+ if (!existsSync(dir)) return [];
642
+ return readdirSync(dir).filter((name) => name.endsWith(".jsonl")).map((name) => join(dir, name));
643
+ }
644
+ var FILE_PATH_TOOLS = /* @__PURE__ */ new Set(["Read", "Edit", "Write", "NotebookEdit"]);
645
+ var PATH_TOKEN_RE = /(?:[.~]?\/)?(?:[\w-]+\/)+[\w.-]+/g;
646
+ function extractPathsFromText(text) {
647
+ const matches = text.match(PATH_TOKEN_RE) ?? [];
648
+ return [...new Set(matches)].filter((p) => p.length > 3 && p.length < 300);
649
+ }
650
+ function blockText(content) {
651
+ if (typeof content === "string") return content;
652
+ if (Array.isArray(content)) {
653
+ return content.map((c) => typeof c === "string" ? c : c?.text ?? "").filter(Boolean).join("\n");
654
+ }
655
+ return "";
656
+ }
657
+ function extractFromAssistantContent(content) {
658
+ const textParts = [];
659
+ const filesTouched = [];
660
+ for (const block of content) {
661
+ if (block.type === "text" && block.text) {
662
+ textParts.push(block.text);
663
+ for (const p of extractPathsFromText(block.text)) {
664
+ filesTouched.push({ path: p, source: "prose" });
665
+ }
666
+ } else if (block.type === "tool_use") {
667
+ const name = block.name ?? "tool";
668
+ const input = block.input ?? {};
669
+ if (FILE_PATH_TOOLS.has(name) && typeof input.file_path === "string") {
670
+ textParts.push(`[tool_call: ${name}] ${input.file_path}`);
671
+ filesTouched.push({ path: input.file_path, source: "tool_path", tool: name });
672
+ } else if (name === "Bash" && typeof input.command === "string") {
673
+ const desc = typeof input.description === "string" ? input.description : input.command;
674
+ textParts.push(`[tool_call: Bash] ${desc}`);
675
+ for (const p of extractPathsFromText(input.command)) {
676
+ filesTouched.push({ path: p, source: "tool_bash", tool: "Bash" });
677
+ }
678
+ } else {
679
+ textParts.push(`[tool_call: ${name}]`);
680
+ }
681
+ }
682
+ }
683
+ return { text: textParts.join("\n"), filesTouched };
684
+ }
685
+ var SYNTHETIC_CONTENT_RE = /^\s*(<task-notification>|<system-reminder>|\[SYSTEM NOTIFICATION)/;
686
+ function extractFromUserContent(content) {
687
+ if (typeof content === "string") {
688
+ return {
689
+ text: content,
690
+ filesTouched: extractPathsFromText(content).map((path) => ({ path, source: "prose" })),
691
+ isSyntheticInput: SYNTHETIC_CONTENT_RE.test(content)
692
+ };
693
+ }
694
+ if (Array.isArray(content)) {
695
+ const toolParts = [];
696
+ const textParts = [];
697
+ for (const block of content) {
698
+ if (block.type === "tool_result") {
699
+ toolParts.push(blockText(block.content));
700
+ } else if (block.type === "text" && typeof block.text === "string") {
701
+ textParts.push(block.text);
702
+ }
703
+ }
704
+ if (toolParts.length > 0) {
705
+ return {
706
+ text: toolParts.join("\n"),
707
+ filesTouched: [],
708
+ isSyntheticInput: true
709
+ };
710
+ }
711
+ const text = textParts.join("\n");
712
+ return {
713
+ text,
714
+ // Same treatment the string branch gives prose, so a path a person
715
+ // names in a block-formatted message is found too.
716
+ filesTouched: extractPathsFromText(text).map((path) => ({
717
+ path,
718
+ source: "prose"
719
+ })),
720
+ isSyntheticInput: SYNTHETIC_CONTENT_RE.test(text)
721
+ };
722
+ }
723
+ return { text: "", filesTouched: [], isSyntheticInput: false };
724
+ }
725
+ function usageOnce(record, billed) {
726
+ const message = record.message;
727
+ const raw = message?.usage;
728
+ if (!raw || typeof raw !== "object") return { ...EMPTY_USAGE };
729
+ const messageId = message?.id;
730
+ if (!messageId || billed.has(messageId)) return { ...EMPTY_USAGE };
731
+ billed.add(messageId);
732
+ const num = (key) => {
733
+ const value = raw[key];
734
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
735
+ };
736
+ return {
737
+ inputTokens: num("input_tokens"),
738
+ outputTokens: num("output_tokens"),
739
+ cacheReadTokens: num("cache_read_input_tokens"),
740
+ cacheWriteTokens: num("cache_creation_input_tokens"),
741
+ model: typeof message?.model === "string" ? message.model : null
742
+ };
743
+ }
744
+ function parseSessionFile(filePath, repoPath, repoId = deriveRepoId(repoPath)) {
745
+ const raw = readFileSync(filePath, "utf-8");
746
+ const lines = raw.split("\n").filter((l) => l.trim().length > 0);
747
+ const turns = [];
748
+ const billedMessages = /* @__PURE__ */ new Set();
749
+ let sessionId = null;
750
+ const cwd = repoPath;
751
+ let aiTitle = null;
752
+ let totalRedactions = 0;
753
+ for (const line of lines) {
754
+ let record;
755
+ try {
756
+ record = JSON.parse(line);
757
+ } catch {
758
+ continue;
759
+ }
760
+ if (record.type === "ai-title" && typeof record.aiTitle === "string") {
761
+ aiTitle = record.aiTitle;
762
+ continue;
763
+ }
764
+ if (record.type !== "user" && record.type !== "assistant") continue;
765
+ const id = record.uuid;
766
+ const ts = record.timestamp;
767
+ if (!id || !ts) continue;
768
+ sessionId ??= record.sessionId ?? record.session_id ?? null;
769
+ let text = "";
770
+ let filesTouched = [];
771
+ let editedLines = [];
772
+ let isSyntheticInput = false;
773
+ if (record.type === "assistant") {
774
+ const content = record.message?.content;
775
+ if (Array.isArray(content)) {
776
+ const extracted = extractFromAssistantContent(content);
777
+ text = extracted.text;
778
+ filesTouched = extracted.filesTouched;
779
+ }
780
+ } else {
781
+ const extracted = extractFromUserContent(record.message?.content);
782
+ text = extracted.text;
783
+ filesTouched = extracted.filesTouched;
784
+ isSyntheticInput = extracted.isSyntheticInput;
785
+ const edited = extractEditedLines(record.toolUseResult);
786
+ if (edited) editedLines = [edited];
787
+ }
788
+ const redacted = redactSecrets(text);
789
+ totalRedactions += redacted.count;
790
+ turns.push({
791
+ id,
792
+ sessionId: sessionId ?? "unknown",
793
+ role: record.type,
794
+ ts,
795
+ text: redacted.text.slice(0, MAX_TURN_TEXT_LENGTH),
796
+ filesTouched,
797
+ editedLines,
798
+ parentUuid: record.parentUuid ?? null,
799
+ isSidechain: Boolean(record.isSidechain),
800
+ redacted: redacted.count > 0,
801
+ isSyntheticInput,
802
+ usage: usageOnce(record, billedMessages)
803
+ });
804
+ }
805
+ if (!sessionId || turns.length === 0) return null;
806
+ const sortedTs = turns.map((t) => t.ts).sort();
807
+ const rawContent = lines.map((line) => {
808
+ try {
809
+ return JSON.stringify(redactJsonValue(JSON.parse(line)).value);
810
+ } catch {
811
+ return redactSecrets(line).text;
812
+ }
813
+ }).join("\n");
814
+ if (totalRedactions > 0) {
815
+ console.log(`[ingest-core] redacted ${totalRedactions} potential secret(s) in session ${sessionId}`);
816
+ }
817
+ return {
818
+ id: sessionId,
819
+ agentKind: "claude-code",
820
+ repoId,
821
+ cwd,
822
+ startedAt: sortedTs[0] ?? null,
823
+ endedAt: sortedTs[sortedTs.length - 1] ?? null,
824
+ turnCount: turns.length,
825
+ aiTitle,
826
+ author: null,
827
+ // filled in by the caller — see getGitUserName in git-history.ts
828
+ sourceFile: filePath,
829
+ redactionCount: totalRedactions,
830
+ committedSubjects: collectCommittedSubjects(lines),
831
+ rawContent,
832
+ rawFormat: "jsonl",
833
+ turns
834
+ };
835
+ }
836
+ function collectCommittedSubjects(lines) {
837
+ const subjects = /* @__PURE__ */ new Set();
838
+ for (const line of lines) {
839
+ if (!line.includes("git commit")) continue;
840
+ let record;
841
+ try {
842
+ record = JSON.parse(line);
843
+ } catch {
844
+ continue;
845
+ }
846
+ const content = record?.message?.content;
847
+ if (!Array.isArray(content)) continue;
848
+ for (const block of content) {
849
+ if (block?.type !== "tool_use") continue;
850
+ const command = block?.input?.command;
851
+ if (typeof command !== "string") continue;
852
+ for (const subject of committedSubjects(command)) subjects.add(subject);
853
+ }
854
+ }
855
+ return [...subjects];
856
+ }
857
+ function parseAllSessions(repoPath, repoId, cursors = {}) {
858
+ const id = repoId ?? deriveRepoId(repoPath);
859
+ const next = { ...cursors };
860
+ const sessions = [];
861
+ let skipped = 0;
862
+ for (const file of findSessionFiles(repoPath)) {
863
+ const plan = planRead(file, cursors[file]);
864
+ if (plan.reason === "unchanged") {
865
+ skipped++;
866
+ continue;
867
+ }
868
+ const parsed = parseSessionFile(file, repoPath, id);
869
+ if (!parsed) continue;
870
+ sessions.push(parsed);
871
+ next[file] = advanceCursor(file, statSync2(file).size);
872
+ }
873
+ return { sessions, cursors: next, skipped };
874
+ }
875
+
876
+ // ../../packages/ingest-core/src/cursor-sessions.ts
877
+ import { execFileSync as execFileSync2 } from "node:child_process";
878
+ import { existsSync as existsSync2 } from "node:fs";
879
+ import { homedir as homedir2 } from "node:os";
880
+ import { join as join2, sep } from "node:path";
881
+
882
+ // ../../packages/ingest-core/src/derive-uuid.ts
883
+ import { createHash } from "node:crypto";
884
+ function deriveUuid(name) {
885
+ const h = createHash("sha1").update(name).digest("hex");
886
+ const variant = (parseInt(h.slice(16, 17) || "0", 16) & 3 | 8).toString(16);
887
+ const s = h.slice(0, 12) + // time-low + time-mid
888
+ "5" + // version 5 (name-based, SHA-1)
889
+ h.slice(13, 16) + variant + h.slice(17, 32);
890
+ return `${s.slice(0, 8)}-${s.slice(8, 12)}-${s.slice(12, 16)}-${s.slice(16, 20)}-${s.slice(20, 32)}`;
891
+ }
892
+
893
+ // ../../packages/ingest-core/src/cursor-sessions.ts
894
+ var MAX_TURN_TEXT_LENGTH2 = 4e3;
895
+ var PATH_TOKEN_RE2 = /(?:[.~]?\/)?(?:[\w-]+\/)+[\w.-]+/g;
896
+ function extractPathsFromText2(text) {
897
+ const matches = text.match(PATH_TOKEN_RE2) ?? [];
898
+ return [...new Set(matches)].filter((p) => p.length > 3 && p.length < 300);
899
+ }
900
+ function cursorStateDbPath() {
901
+ const home = homedir2();
902
+ if (process.platform === "darwin") {
903
+ return join2(home, "Library", "Application Support", "Cursor", "User", "globalStorage", "state.vscdb");
904
+ }
905
+ if (process.platform === "win32") {
906
+ const appData = process.env.APPDATA ?? join2(home, "AppData", "Roaming");
907
+ return join2(appData, "Cursor", "User", "globalStorage", "state.vscdb");
908
+ }
909
+ return join2(home, ".config", "Cursor", "User", "globalStorage", "state.vscdb");
910
+ }
911
+ function sqliteJson(dbPath, sql) {
912
+ try {
913
+ const out = execFileSync2("sqlite3", ["-readonly", "-json", dbPath, sql], {
914
+ maxBuffer: 1024 * 1024 * 256,
915
+ timeout: 6e4
916
+ }).toString("utf-8").trim();
917
+ if (!out) return [];
918
+ return JSON.parse(out);
919
+ } catch {
920
+ return [];
921
+ }
922
+ }
923
+ function sqlLiteral(value) {
924
+ return `'${value.replace(/'/g, "''")}'`;
925
+ }
926
+ function deriveCursorSessionId(composerId, repoPath) {
927
+ return deriveUuid(`evrex-cursor-session\0${composerId}\0${repoPath}`);
928
+ }
929
+ function deriveCursorTurnId(bubbleId, repoPath) {
930
+ return deriveUuid(`evrex-cursor-turn\0${bubbleId}\0${repoPath}`);
931
+ }
932
+ function loadComposers(dbPath) {
933
+ return sqliteJson(
934
+ dbPath,
935
+ `SELECT
936
+ json_extract(value,'$.composerId') AS composerId,
937
+ json_extract(value,'$.name') AS name,
938
+ json_extract(value,'$.createdAt') AS createdAt,
939
+ json_extract(value,'$.lastUpdatedAt') AS lastUpdatedAt,
940
+ json_extract(value,'$.workspaceIdentifier.uri.path') AS workspacePath,
941
+ json_extract(value,'$.fullConversationHeadersOnly') AS headers
942
+ FROM cursorDiskKV
943
+ WHERE key GLOB 'composerData:*'`
944
+ );
945
+ }
946
+ function composerIdsReferencingRepo(dbPath, repoPath) {
947
+ const needle = sqlLiteral(`%${repoPath}%`);
948
+ const rows = sqliteJson(
949
+ dbPath,
950
+ `SELECT DISTINCT substr(key, 10, 36) AS composerId
951
+ FROM cursorDiskKV
952
+ WHERE key GLOB 'bubbleId:*' AND value LIKE ${needle}`
953
+ );
954
+ return rows.map((r) => r.composerId).filter((id) => !!id);
955
+ }
956
+ function bubbleIdsReferencingRepo(dbPath, composerId, repoPath) {
957
+ const prefix = sqlLiteral(`bubbleId:${composerId}:%`);
958
+ const needle = sqlLiteral(`%${repoPath}%`);
959
+ const rows = sqliteJson(
960
+ dbPath,
961
+ `SELECT substr(key, ${10 + composerId.length + 1}) AS bubbleId
962
+ FROM cursorDiskKV
963
+ WHERE key LIKE ${prefix} AND value LIKE ${needle}`
964
+ );
965
+ return new Set(rows.map((r) => r.bubbleId).filter((id) => !!id));
966
+ }
967
+ function loadBubbles(dbPath, composerId) {
968
+ const prefix = sqlLiteral(`bubbleId:${composerId}:%`);
969
+ const rows = sqliteJson(
970
+ dbPath,
971
+ `SELECT
972
+ substr(key, ${10 + composerId.length + 1}) AS bubbleId,
973
+ json_extract(value,'$.type') AS type,
974
+ json_extract(value,'$.text') AS text,
975
+ json_extract(value,'$.thinking.text') AS thinking,
976
+ json_extract(value,'$.toolFormerData.name') AS toolName,
977
+ json_extract(value,'$.toolFormerData.rawArgs') AS rawArgs,
978
+ json_extract(value,'$.createdAt') AS createdAt
979
+ FROM cursorDiskKV
980
+ WHERE key LIKE ${prefix}`
981
+ );
982
+ const map = /* @__PURE__ */ new Map();
983
+ for (const r of rows) {
984
+ if (r.bubbleId) map.set(r.bubbleId, r);
985
+ }
986
+ return map;
987
+ }
988
+ function loadRawRecords(dbPath, composerId, bubbleIds) {
989
+ const keys = [
990
+ `composerData:${composerId}`,
991
+ ...bubbleIds.map((b) => `bubbleId:${composerId}:${b}`)
992
+ ];
993
+ const inList = keys.map((k) => sqlLiteral(k)).join(", ");
994
+ const rows = sqliteJson(
995
+ dbPath,
996
+ `SELECT key, value FROM cursorDiskKV WHERE key IN (${inList})`
997
+ );
998
+ const records = {};
999
+ for (const { key, value } of rows) {
1000
+ if (!key) continue;
1001
+ try {
1002
+ records[key] = value ? JSON.parse(value) : null;
1003
+ } catch {
1004
+ records[key] = value;
1005
+ }
1006
+ }
1007
+ return records;
1008
+ }
1009
+ function parseHeaders(headers2) {
1010
+ if (!headers2) return [];
1011
+ try {
1012
+ const parsed = JSON.parse(headers2);
1013
+ return Array.isArray(parsed) ? parsed.filter((h) => h && h.bubbleId) : [];
1014
+ } catch {
1015
+ return [];
1016
+ }
1017
+ }
1018
+ function filesFromTool(toolName, rawArgs) {
1019
+ const files = [];
1020
+ let label = `[tool_call: ${toolName}]`;
1021
+ if (!rawArgs) return { label, files };
1022
+ let args = {};
1023
+ try {
1024
+ args = JSON.parse(rawArgs);
1025
+ } catch {
1026
+ return { label, files };
1027
+ }
1028
+ const filePath = typeof args.target_file === "string" && args.target_file || typeof args.path === "string" && args.path || typeof args.relative_workspace_path === "string" && args.relative_workspace_path || null;
1029
+ const command = typeof args.command === "string" ? args.command : null;
1030
+ if (filePath) {
1031
+ label = `[tool_call: ${toolName}] ${filePath}`;
1032
+ files.push({ path: filePath, source: "tool_path", tool: toolName });
1033
+ } else if (command) {
1034
+ label = `[tool_call: ${toolName}] ${command}`;
1035
+ for (const p of extractPathsFromText2(command)) {
1036
+ files.push({ path: p, source: "tool_bash", tool: toolName });
1037
+ }
1038
+ }
1039
+ return { label, files };
1040
+ }
1041
+ function toIso(value, fallbackMs) {
1042
+ if (typeof value === "string" && value) {
1043
+ const t = Date.parse(value);
1044
+ if (!Number.isNaN(t)) return new Date(t).toISOString();
1045
+ }
1046
+ if (typeof value === "number" && Number.isFinite(value)) {
1047
+ return new Date(value).toISOString();
1048
+ }
1049
+ return new Date(fallbackMs).toISOString();
1050
+ }
1051
+ function parseCursorComposer(dbPath, composer, repoPath, options = { scoped: false }, repoId = deriveRepoId(repoPath)) {
1052
+ const composerId = composer.composerId;
1053
+ if (!composerId) return null;
1054
+ const headers2 = parseHeaders(composer.headers);
1055
+ if (headers2.length === 0) return null;
1056
+ const bubbles = loadBubbles(dbPath, composerId);
1057
+ const composerCreatedMs = typeof composer.createdAt === "number" ? composer.createdAt : Date.now();
1058
+ const referencing = options.scoped ? bubbleIdsReferencingRepo(dbPath, composerId, repoPath) : null;
1059
+ const built = [];
1060
+ for (const header of headers2) {
1061
+ const bubble = bubbles.get(header.bubbleId);
1062
+ if (!bubble) continue;
1063
+ const role = header.type === 1 ? "user" : "assistant";
1064
+ const textParts = [];
1065
+ const filesTouched = [];
1066
+ if (bubble.text && bubble.text.trim()) {
1067
+ textParts.push(bubble.text);
1068
+ for (const p of extractPathsFromText2(bubble.text)) {
1069
+ filesTouched.push({ path: p, source: "prose" });
1070
+ }
1071
+ }
1072
+ if (role === "assistant" && bubble.thinking && bubble.thinking.trim()) {
1073
+ textParts.push(bubble.thinking);
1074
+ }
1075
+ if (bubble.toolName) {
1076
+ const { label, files } = filesFromTool(bubble.toolName, bubble.rawArgs);
1077
+ textParts.push(label);
1078
+ filesTouched.push(...files);
1079
+ }
1080
+ const text = textParts.join("\n");
1081
+ if (!text.trim() && filesTouched.length === 0) continue;
1082
+ const redacted = redactSecrets(text);
1083
+ built.push({
1084
+ bubbleId: header.bubbleId,
1085
+ role,
1086
+ referencesRepo: referencing ? referencing.has(header.bubbleId) : true,
1087
+ redactionCount: redacted.count,
1088
+ turn: {
1089
+ id: header.bubbleId,
1090
+ // rewritten to the per-repo derived id below
1091
+ sessionId: composerId,
1092
+ // rewritten to the per-repo derived id below
1093
+ role,
1094
+ ts: toIso(bubble.createdAt, composerCreatedMs),
1095
+ text: redacted.text.slice(0, MAX_TURN_TEXT_LENGTH2),
1096
+ filesTouched,
1097
+ // Cursor's bubble format doesn't expose a structuredPatch-equivalent
1098
+ // the way Claude Code's toolUseResult does — content-overlap scoring
1099
+ // (see scoreContentOverlap in the linker) simply has no signal here
1100
+ // yet, same tier as any other not-yet-supported evidence source.
1101
+ editedLines: [],
1102
+ // Cursor has no parent-pointer chain; header order is authoritative, so
1103
+ // parentUuid is left null (the backend/linker never require it).
1104
+ parentUuid: null,
1105
+ isSidechain: false,
1106
+ redacted: redacted.count > 0,
1107
+ // Cursor's "user" bubbles (header.type === 1, see `role` above) are a
1108
+ // structural field distinct from tool output — not the Claude Code
1109
+ // wire-format ambiguity where a tool result also arrives as a
1110
+ // `role: "user"` record. No synthetic-input misattribution risk here.
1111
+ isSyntheticInput: false,
1112
+ // Neither source reports what a turn cost, so it is unknown rather than free.
1113
+ usage: { ...EMPTY_USAGE }
1114
+ }
1115
+ });
1116
+ }
1117
+ let kept;
1118
+ if (referencing) {
1119
+ const keep = /* @__PURE__ */ new Set();
1120
+ for (let i = 0; i < built.length; i++) {
1121
+ const cur = built[i];
1122
+ if (!cur || !cur.referencesRepo) continue;
1123
+ keep.add(i);
1124
+ const prev = built[i - 1];
1125
+ if (prev && prev.role === "user") keep.add(i - 1);
1126
+ }
1127
+ kept = built.filter((_, i) => keep.has(i));
1128
+ } else {
1129
+ kept = built;
1130
+ }
1131
+ if (kept.length === 0) return null;
1132
+ const sessionId = deriveCursorSessionId(composerId, repoPath);
1133
+ const turns = kept.map((b) => ({
1134
+ ...b.turn,
1135
+ id: deriveCursorTurnId(b.bubbleId, repoPath),
1136
+ sessionId
1137
+ }));
1138
+ const totalRedactions = kept.reduce((sum, b) => sum + b.redactionCount, 0);
1139
+ const sortedTs = turns.map((t) => t.ts).sort();
1140
+ if (totalRedactions > 0) {
1141
+ console.log(`[ingest-core] redacted ${totalRedactions} potential secret(s) in cursor session ${composerId}`);
1142
+ }
1143
+ const rawRecords = loadRawRecords(dbPath, composerId, kept.map((b) => b.bubbleId));
1144
+ const rawContent = JSON.stringify(
1145
+ redactJsonValue({ format: "cursor-composer", composerId, records: rawRecords }).value
1146
+ );
1147
+ return {
1148
+ id: sessionId,
1149
+ agentKind: "cursor",
1150
+ repoId,
1151
+ cwd: repoPath,
1152
+ startedAt: sortedTs[0] ?? null,
1153
+ endedAt: sortedTs[sortedTs.length - 1] ?? null,
1154
+ turnCount: turns.length,
1155
+ aiTitle: composer.name && composer.name.trim() ? composer.name : null,
1156
+ author: null,
1157
+ // filled in by the caller — see collectRepoData
1158
+ // Includes repoPath so a shared composer's per-repo copies have distinct,
1159
+ // traceable source refs (the raw composerId alone is no longer unique).
1160
+ sourceFile: `${dbPath}#${composerId}#${repoPath}`,
1161
+ redactionCount: totalRedactions,
1162
+ // Read back off the turns rather than the raw records: unlike Claude Code,
1163
+ // a Cursor tool call's label keeps the command itself, so the invocation is
1164
+ // still there to read.
1165
+ committedSubjects: [
1166
+ ...new Set(turns.flatMap((t) => committedSubjects(t.text)))
1167
+ ],
1168
+ rawContent,
1169
+ rawFormat: "json",
1170
+ turns
1171
+ };
1172
+ }
1173
+ function workspaceMatchesRepo(workspacePath, repoPath) {
1174
+ if (!workspacePath) return false;
1175
+ return workspacePath === repoPath || workspacePath.startsWith(`${repoPath}${sep}`);
1176
+ }
1177
+ function parseAllCursorSessions(repoPath, repoId) {
1178
+ const dbPath = cursorStateDbPath();
1179
+ if (!existsSync2(dbPath)) return [];
1180
+ const composers = loadComposers(dbPath);
1181
+ if (composers.length === 0) return [];
1182
+ const byId = /* @__PURE__ */ new Map();
1183
+ for (const c of composers) {
1184
+ if (c.composerId) byId.set(c.composerId, c);
1185
+ }
1186
+ const workspaceDedicated = /* @__PURE__ */ new Set();
1187
+ for (const c of composers) {
1188
+ if (c.composerId && workspaceMatchesRepo(c.workspacePath, repoPath)) {
1189
+ workspaceDedicated.add(c.composerId);
1190
+ }
1191
+ }
1192
+ const attributed = new Set(workspaceDedicated);
1193
+ for (const id of composerIdsReferencingRepo(dbPath, repoPath)) {
1194
+ attributed.add(id);
1195
+ }
1196
+ const resolvedRepoId = repoId ?? deriveRepoId(repoPath);
1197
+ const sessions = [];
1198
+ for (const id of attributed) {
1199
+ const composer = byId.get(id);
1200
+ if (!composer) continue;
1201
+ const scoped = !workspaceDedicated.has(id);
1202
+ const parsed = parseCursorComposer(dbPath, composer, repoPath, { scoped }, resolvedRepoId);
1203
+ if (parsed) sessions.push(parsed);
1204
+ }
1205
+ return sessions;
1206
+ }
1207
+
1208
+ // ../../packages/ingest-core/src/codex-sessions.ts
1209
+ import { createHash as createHash2 } from "node:crypto";
1210
+ import { existsSync as existsSync3, readFileSync as readFileSync2, readdirSync as readdirSync2, statSync as statSync3 } from "node:fs";
1211
+ import { homedir as homedir3 } from "node:os";
1212
+ import { join as join3 } from "node:path";
1213
+ var CODEX_DIR = ".codex";
1214
+ function codexSessionsDir() {
1215
+ return join3(homedir3(), CODEX_DIR, "sessions");
1216
+ }
1217
+ function deriveUuid2(name) {
1218
+ const h = createHash2("sha1").update(name).digest("hex");
1219
+ const variant = (parseInt(h.slice(16, 17) || "0", 16) & 3 | 8).toString(16);
1220
+ const s = h.slice(0, 12) + "5" + h.slice(13, 16) + variant + h.slice(17, 32);
1221
+ return `${s.slice(0, 8)}-${s.slice(8, 12)}-${s.slice(12, 16)}-${s.slice(16, 20)}-${s.slice(20, 32)}`;
1222
+ }
1223
+ function deriveCodexTurnId(sessionId, ordinal) {
1224
+ return deriveUuid2(`evrex-codex-turn\0${sessionId}\0${ordinal}`);
1225
+ }
1226
+ function findCodexSessionFiles(root = codexSessionsDir()) {
1227
+ const found = [];
1228
+ const walk = (dir) => {
1229
+ let entries;
1230
+ try {
1231
+ entries = readdirSync2(dir);
1232
+ } catch {
1233
+ return;
1234
+ }
1235
+ for (const entry of entries) {
1236
+ const full = join3(dir, entry);
1237
+ let isDir = false;
1238
+ try {
1239
+ isDir = statSync3(full).isDirectory();
1240
+ } catch {
1241
+ continue;
1242
+ }
1243
+ if (isDir) walk(full);
1244
+ else if (entry.endsWith(".jsonl")) found.push(full);
1245
+ }
1246
+ };
1247
+ if (existsSync3(root)) walk(root);
1248
+ return found.sort();
1249
+ }
1250
+ function parseLines(raw) {
1251
+ const out = [];
1252
+ for (const line of raw.split("\n")) {
1253
+ if (!line.trim()) continue;
1254
+ try {
1255
+ out.push(JSON.parse(line));
1256
+ } catch {
1257
+ continue;
1258
+ }
1259
+ }
1260
+ return out;
1261
+ }
1262
+ function payloadType(line) {
1263
+ const t = line.payload?.type;
1264
+ return typeof t === "string" ? t : void 0;
1265
+ }
1266
+ function asString(value) {
1267
+ return typeof value === "string" && value.length > 0 ? value : null;
1268
+ }
1269
+ function conversationEvents(lines) {
1270
+ const out = [];
1271
+ let pending = null;
1272
+ let model = null;
1273
+ lines.forEach((line, index) => {
1274
+ if (line.type === "turn_context") {
1275
+ model = asString(line.payload?.model) ?? model;
1276
+ return;
1277
+ }
1278
+ if (line.type !== "event_msg") return;
1279
+ const kind = payloadType(line);
1280
+ if (kind === "token_count") {
1281
+ const delta = lastUsage(line.payload);
1282
+ if (delta) pending = addUsage(pending, delta);
1283
+ return;
1284
+ }
1285
+ if (kind !== "user_message" && kind !== "agent_message") return;
1286
+ const text = asString(line.payload?.message);
1287
+ if (!text) return;
1288
+ const role = kind === "user_message" ? "user" : "assistant";
1289
+ const usage = role === "assistant" && pending ? { ...pending, model: pending.model ?? model } : { ...EMPTY_USAGE };
1290
+ if (role === "assistant") pending = null;
1291
+ out.push({ role, text, ts: asString(line.timestamp), ordinal: index, usage });
1292
+ });
1293
+ return out;
1294
+ }
1295
+ function lastUsage(payload) {
1296
+ const info = payload?.info;
1297
+ const last = info?.last_token_usage;
1298
+ if (!last) return null;
1299
+ const num = (key) => {
1300
+ const value = last[key];
1301
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
1302
+ };
1303
+ const cached = num("cached_input_tokens");
1304
+ return {
1305
+ inputTokens: Math.max(0, num("input_tokens") - cached),
1306
+ outputTokens: num("output_tokens"),
1307
+ cacheReadTokens: cached,
1308
+ // Codex reports no cache-write figure; unknown rather than zero.
1309
+ cacheWriteTokens: null,
1310
+ model: null
1311
+ };
1312
+ }
1313
+ function addUsage(into, next) {
1314
+ if (!into) return { ...next };
1315
+ const sum = (a, b) => a === null && b === null ? null : (a ?? 0) + (b ?? 0);
1316
+ return {
1317
+ inputTokens: sum(into.inputTokens, next.inputTokens),
1318
+ outputTokens: sum(into.outputTokens, next.outputTokens),
1319
+ cacheReadTokens: sum(into.cacheReadTokens, next.cacheReadTokens),
1320
+ cacheWriteTokens: sum(into.cacheWriteTokens, next.cacheWriteTokens),
1321
+ model: into.model ?? next.model
1322
+ };
1323
+ }
1324
+ function committedSubjectsFromToolCalls(lines) {
1325
+ const subjects = /* @__PURE__ */ new Set();
1326
+ for (const line of lines) {
1327
+ if (line.type !== "response_item") continue;
1328
+ if (payloadType(line) !== "custom_tool_call") continue;
1329
+ const input = asString(line.payload?.input);
1330
+ if (!input) continue;
1331
+ for (const subject of committedSubjects(input)) subjects.add(subject);
1332
+ }
1333
+ return [...subjects];
1334
+ }
1335
+ function filesFromToolCalls(lines) {
1336
+ const byPath = /* @__PURE__ */ new Map();
1337
+ for (const line of lines) {
1338
+ if (line.type !== "response_item") continue;
1339
+ if (payloadType(line) !== "custom_tool_call") continue;
1340
+ const input = asString(line.payload?.input);
1341
+ if (!input) continue;
1342
+ const tool = asString(line.payload?.name) ?? "exec";
1343
+ for (const path of extractPathsFromText(input)) {
1344
+ if (!byPath.has(path)) byPath.set(path, { path, source: "tool_bash", tool });
1345
+ }
1346
+ }
1347
+ return [...byPath.values()];
1348
+ }
1349
+ function parseCodexSessionFile(filePath, repoPath, repoId) {
1350
+ let raw;
1351
+ try {
1352
+ raw = readFileSync2(filePath, "utf-8");
1353
+ } catch {
1354
+ return null;
1355
+ }
1356
+ const lines = parseLines(raw);
1357
+ const meta = lines.find((l) => l.type === "session_meta")?.payload;
1358
+ if (!meta) return null;
1359
+ const cwd = asString(meta.cwd);
1360
+ if (!cwd) return null;
1361
+ if (cwd.replace(/\/+$/, "") !== repoPath.replace(/\/+$/, "")) return null;
1362
+ const sessionId = asString(meta.session_id) ?? asString(meta.id);
1363
+ if (!sessionId) return null;
1364
+ const events = conversationEvents(lines);
1365
+ if (events.length === 0) return null;
1366
+ const filesTouched = filesFromToolCalls(lines);
1367
+ let redactionCount = 0;
1368
+ const turns = events.map((event, i) => {
1369
+ const { text, count } = redactSecrets(event.text);
1370
+ redactionCount += count;
1371
+ return {
1372
+ id: deriveCodexTurnId(sessionId, event.ordinal),
1373
+ sessionId,
1374
+ role: event.role,
1375
+ ts: event.ts ?? asString(meta.timestamp) ?? (/* @__PURE__ */ new Date()).toISOString(),
1376
+ text,
1377
+ // Attributed to the assistant turns, which are the ones that ran the
1378
+ // tools; a user turn touched no files.
1379
+ filesTouched: event.role === "assistant" && i === events.length - 1 ? filesTouched : [],
1380
+ editedLines: [],
1381
+ parentUuid: null,
1382
+ isSidechain: false,
1383
+ redacted: count > 0,
1384
+ isSyntheticInput: false,
1385
+ usage: event.usage
1386
+ };
1387
+ });
1388
+ const { content: rawContent, count: rawRedactions } = redactRollout(raw);
1389
+ redactionCount += rawRedactions;
1390
+ const firstUser = events.find((e) => e.role === "user");
1391
+ return {
1392
+ id: sessionId,
1393
+ agentKind: "codex",
1394
+ // Codex records the remote itself, so identity survives a moved or deleted
1395
+ // checkout. Falls back to the caller's derivation when it is absent.
1396
+ repoId: repoIdFromMeta(meta) ?? repoId,
1397
+ cwd,
1398
+ startedAt: asString(meta.timestamp) ?? events[0]?.ts ?? null,
1399
+ endedAt: events[events.length - 1]?.ts ?? null,
1400
+ turnCount: turns.length,
1401
+ committedSubjects: committedSubjectsFromToolCalls(lines),
1402
+ aiTitle: firstUser ? firstUser.text.slice(0, 120).trim() : null,
1403
+ author: null,
1404
+ sourceFile: filePath,
1405
+ redactionCount,
1406
+ turns,
1407
+ rawContent,
1408
+ rawFormat: "jsonl"
1409
+ };
1410
+ }
1411
+ function repoIdFromMeta(meta) {
1412
+ const git2 = meta.git;
1413
+ if (typeof git2 !== "object" || git2 === null) return null;
1414
+ const url = asString(git2.repository_url);
1415
+ if (!url) return null;
1416
+ const normalized = normalizeRepoRemote(url);
1417
+ return normalized ? `remote:${normalized}` : null;
1418
+ }
1419
+ function redactRollout(raw) {
1420
+ let count = 0;
1421
+ const lines = raw.split("\n").map((line) => {
1422
+ if (!line.trim()) return line;
1423
+ const { text, count: n } = redactSecrets(line);
1424
+ count += n;
1425
+ return text;
1426
+ });
1427
+ return { content: lines.join("\n"), count };
1428
+ }
1429
+ function parseAllCodexSessions(repoPath, repoId) {
1430
+ const sessions = [];
1431
+ for (const file of findCodexSessionFiles()) {
1432
+ const parsed = parseCodexSessionFile(file, repoPath, repoId);
1433
+ if (parsed) sessions.push(parsed);
1434
+ }
1435
+ return sessions;
1436
+ }
1437
+
1438
+ // ../../packages/ingest-core/src/sanitize.ts
1439
+ var NUL = String.fromCharCode(0);
1440
+ function stripNulls(value) {
1441
+ return value.includes(NUL) ? value.split(NUL).join("") : value;
1442
+ }
1443
+ function stripNullsDeep(value) {
1444
+ if (typeof value === "string") return stripNulls(value);
1445
+ if (Array.isArray(value)) return value.map((v) => stripNullsDeep(v));
1446
+ if (value && typeof value === "object") {
1447
+ if (value instanceof Date) return value;
1448
+ const out = {};
1449
+ for (const [k, v] of Object.entries(value)) {
1450
+ out[k] = stripNullsDeep(v);
1451
+ }
1452
+ return out;
1453
+ }
1454
+ return value;
1455
+ }
1456
+
1457
+ // ../../packages/ingest-core/src/index.ts
1458
+ function resolveFallbackAuthor(repoPath, commits) {
1459
+ const configured = getGitUserName(repoPath);
1460
+ if (configured) return configured;
1461
+ const counts = /* @__PURE__ */ new Map();
1462
+ for (const c of commits) {
1463
+ if (!c.author) continue;
1464
+ counts.set(c.author, (counts.get(c.author) ?? 0) + 1);
1465
+ }
1466
+ const mostFrequent = [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
1467
+ if (mostFrequent) return mostFrequent;
1468
+ try {
1469
+ return userInfo().username || null;
1470
+ } catch {
1471
+ return null;
1472
+ }
1473
+ }
1474
+ function collectRepoData(repoPath, options = {}) {
1475
+ const repoId = deriveRepoId(repoPath);
1476
+ const commits = parseGitLog(repoPath, repoId, options.knownCommits);
1477
+ const author = resolveFallbackAuthor(repoPath, commits);
1478
+ const claude = parseAllSessions(repoPath, repoId, options.cursors ?? {});
1479
+ const sessions = [
1480
+ ...claude.sessions,
1481
+ ...parseAllCursorSessions(repoPath, repoId),
1482
+ ...parseAllCodexSessions(repoPath, repoId)
1483
+ ].map((s) => ({
1484
+ ...s,
1485
+ author: s.author ?? author
1486
+ }));
1487
+ const totalRedactions = sessions.reduce((sum, s) => sum + s.redactionCount, 0);
1488
+ return {
1489
+ sessions: stripNullsDeep(sessions),
1490
+ commits: stripNullsDeep(commits),
1491
+ totalRedactions,
1492
+ cursors: claude.cursors,
1493
+ skippedTranscripts: claude.skipped
1494
+ };
1495
+ }
1496
+
1497
+ // src/batch.ts
1498
+ var MAX_BATCH_BYTES = 24 * 1024 * 1024;
1499
+ function measure(items) {
1500
+ return items.map((item) => ({
1501
+ item,
1502
+ bytes: Buffer.byteLength(JSON.stringify(item), "utf8")
1503
+ }));
1504
+ }
1505
+ function batchByBytes(items, maxBytes = MAX_BATCH_BYTES) {
1506
+ const batches = [];
1507
+ let current = [];
1508
+ let size = 0;
1509
+ for (const { item, bytes } of measure(items)) {
1510
+ if (current.length > 0 && size + bytes > maxBytes) {
1511
+ batches.push(current);
1512
+ current = [];
1513
+ size = 0;
1514
+ }
1515
+ current.push(item);
1516
+ size += bytes;
1517
+ }
1518
+ if (current.length > 0) batches.push(current);
1519
+ return batches;
1520
+ }
1521
+
1522
+ // src/import-state.ts
1523
+ import { existsSync as existsSync4, mkdirSync, readFileSync as readFileSync3, renameSync, writeFileSync } from "node:fs";
1524
+ import { dirname, join as join4 } from "node:path";
1525
+ import { homedir as homedir4 } from "node:os";
1526
+ function importStatePath(home = homedir4()) {
1527
+ return join4(home, ".evrex", "import-state.json");
1528
+ }
1529
+ function readImportState(path) {
1530
+ try {
1531
+ const parsed = JSON.parse(readFileSync3(path, "utf8"));
1532
+ if (!parsed || typeof parsed.repos !== "object") return { repos: {} };
1533
+ return { repos: parsed.repos ?? {} };
1534
+ } catch {
1535
+ return { repos: {} };
1536
+ }
1537
+ }
1538
+ function writeImportState(path, state) {
1539
+ mkdirSync(dirname(path), { recursive: true });
1540
+ const tmp = `${path}.${process.pid}.tmp`;
1541
+ writeFileSync(tmp, JSON.stringify(state, null, 2), { mode: 384 });
1542
+ renameSync(tmp, path);
1543
+ }
1544
+ function stateFor(state, repoId) {
1545
+ return state.repos[repoId] ?? {
1546
+ cursors: {},
1547
+ knownCommits: [],
1548
+ lastImportedAt: (/* @__PURE__ */ new Date(0)).toISOString()
1549
+ };
1550
+ }
1551
+
1552
+ // src/import.ts
1553
+ async function importRepo(repoPath, deps) {
1554
+ const statePath = importStatePath(deps.home);
1555
+ const state = readImportState(statePath);
1556
+ const repoId = deps.deriveRepoId(repoPath);
1557
+ const known = stateFor(state, repoId);
1558
+ const data = deps.collect(repoPath, {
1559
+ cursors: known.cursors,
1560
+ knownCommits: new Set(known.knownCommits)
1561
+ });
1562
+ const result = {
1563
+ sessions: 0,
1564
+ commits: 0,
1565
+ skippedTranscripts: data.skippedTranscripts,
1566
+ redactions: data.totalRedactions,
1567
+ batches: 0,
1568
+ failed: false
1569
+ };
1570
+ const deliveredCommits = [];
1571
+ for (const batch of batchByBytes(data.commits)) {
1572
+ result.batches += 1;
1573
+ if (!await deps.post("/ingest/commits", { commits: batch })) {
1574
+ result.failed = true;
1575
+ break;
1576
+ }
1577
+ deliveredCommits.push(...batch.map((c) => c.sha));
1578
+ result.commits += batch.length;
1579
+ deps.log(` commits ${result.commits}/${data.commits.length}`);
1580
+ }
1581
+ const deliveredCursors = {};
1582
+ if (!result.failed) {
1583
+ for (const batch of batchByBytes(data.sessions)) {
1584
+ result.batches += 1;
1585
+ if (!await deps.post("/ingest/sessions", { sessions: batch })) {
1586
+ result.failed = true;
1587
+ break;
1588
+ }
1589
+ for (const session of batch) {
1590
+ const cursor = data.cursors[session.sourceFile];
1591
+ if (cursor !== void 0) deliveredCursors[session.sourceFile] = cursor;
1592
+ }
1593
+ result.sessions += batch.length;
1594
+ deps.log(` sessions ${result.sessions}/${data.sessions.length}`);
1595
+ }
1596
+ }
1597
+ state.repos[repoId] = {
1598
+ cursors: { ...known.cursors, ...deliveredCursors },
1599
+ knownCommits: [.../* @__PURE__ */ new Set([...known.knownCommits, ...deliveredCommits])],
1600
+ lastImportedAt: deps.now().toISOString()
1601
+ };
1602
+ writeImportState(statePath, state);
1603
+ return result;
1604
+ }
1605
+ async function main() {
1606
+ const target = resolve(process.argv[3] ?? process.cwd());
1607
+ const { evrexApi: evrexApi2 } = await Promise.resolve().then(() => (init_client(), client_exports));
1608
+ const { CredentialStore: CredentialStore2 } = await Promise.resolve().then(() => (init_credential_store(), credential_store_exports));
1609
+ const token = process.env.EVREX_TOKEN ?? await new CredentialStore2().retrieve().catch(() => null);
1610
+ if (!token) {
1611
+ console.error(
1612
+ "evrex: this machine is not enrolled.\n Run `npx -y evrex-mcp enrol` first, or set EVREX_TOKEN."
1613
+ );
1614
+ process.exit(1);
1615
+ }
1616
+ console.error(`Importing ${target}`);
1617
+ const result = await importRepo(target, {
1618
+ collect: collectRepoData,
1619
+ deriveRepoId,
1620
+ home: homedir5(),
1621
+ now: () => /* @__PURE__ */ new Date(),
1622
+ log: (line) => console.error(line),
1623
+ post: async (path, body) => {
1624
+ try {
1625
+ const res = await fetch(`${evrexApi2.baseUrl}${path}`, {
1626
+ method: "POST",
1627
+ headers: {
1628
+ "content-type": "application/json",
1629
+ authorization: `Bearer ${token}`
1630
+ },
1631
+ body: JSON.stringify(body)
1632
+ });
1633
+ if (!res.ok) console.error(` ${path} -> ${res.status}`);
1634
+ return res.ok;
1635
+ } catch (error) {
1636
+ console.error(` ${path} -> ${error.message}`);
1637
+ return false;
1638
+ }
1639
+ }
1640
+ });
1641
+ console.error(
1642
+ `
1643
+ ${result.commits} commits, ${result.sessions} sessions` + (result.skippedTranscripts ? `, ${result.skippedTranscripts} transcripts unchanged` : "") + (result.redactions ? `, ${result.redactions} secrets redacted` : "")
1644
+ );
1645
+ if (result.failed) {
1646
+ console.error(" Import stopped early. Run it again to resume.");
1647
+ }
1648
+ process.exit(result.failed ? 1 : 0);
1649
+ }
1650
+ function isMain() {
1651
+ const invoked = process.argv[1];
1652
+ if (!invoked) return false;
1653
+ try {
1654
+ return realpathSync(invoked) === fileURLToPath(import.meta.url);
1655
+ } catch {
1656
+ return false;
1657
+ }
1658
+ }
1659
+ if (isMain()) {
1660
+ main().catch((error) => {
1661
+ console.error("evrex import failed:", error);
1662
+ process.exit(1);
1663
+ });
1664
+ }
1665
+ export {
1666
+ importRepo
1667
+ };