evrex-mcp 0.7.0 → 0.8.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 CHANGED
@@ -9,257 +9,75 @@ var __export = (target, all) => {
9
9
  __defProp(target, name, { get: all[name], enumerable: true });
10
10
  };
11
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;
12
+ // ../../packages/ingest-core/src/types.ts
13
+ function isReferenceKind(kind) {
14
+ return REFERENCE_KINDS.includes(kind ?? "");
21
15
  }
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}`;
16
+ function lacksFileAttribution(kind) {
17
+ return kind === "slack" || isReferenceKind(kind);
27
18
  }
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();
19
+ function provenanceTrailerFor(key) {
20
+ const k = key.toLowerCase();
21
+ return PROVENANCE_TRAILERS.find((t) => t.key.toLowerCase() === k);
37
22
  }
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
- // The paginated transcript — see reads.service.ts#getSessionTurns. `ts, id`
58
- // ordering server-side makes offsets stable across requests.
59
- sessionTurns: (id, offset, limit) => getOrNull(
60
- `/sessions/${encodeURIComponent(id)}/turns?offset=${offset}&limit=${limit}`
61
- ),
62
- ask: (repoPath, text, filePaths) => post("/ask", { repoPath, text, filePaths }),
63
- // Evidence-only retrieval (BM25 + embedding), no LLM synthesis call — see
64
- // apps/backend/src/query/query.service.ts#search. Used by evrex_search,
65
- // which wants ranked hits fast, not a synthesized paragraph.
66
- search: (repoPath, text, filePaths) => post("/search", { repoPath, text, filePaths })
67
- };
68
- }
69
- });
70
-
71
- // src/credential-store.ts
72
- var credential_store_exports = {};
73
- __export(credential_store_exports, {
74
- CredentialStore: () => CredentialStore,
75
- NoKeychainError: () => NoKeychainError,
76
- systemRunner: () => systemRunner
77
- });
78
- import { spawn } from "node:child_process";
79
- var SERVICE, ACCOUNT, systemRunner, NoKeychainError, CredentialStore, WINDOWS_PATH, WINDOWS_STORE, WINDOWS_RETRIEVE, WINDOWS_REMOVE;
80
- var init_credential_store = __esm({
81
- "src/credential-store.ts"() {
23
+ var CONVERSATION_KINDS, REFERENCE_KINDS, SOURCE_KINDS, EMPTY_USAGE, PROVENANCE_TRAILERS, STATED_TRAILERS;
24
+ var init_types = __esm({
25
+ "../../packages/ingest-core/src/types.ts"() {
82
26
  "use strict";
83
- SERVICE = "evrex-capture";
84
- ACCOUNT = "evrex";
85
- systemRunner = {
86
- platform: process.platform,
87
- run(command, args, stdin) {
88
- return new Promise((resolve2) => {
89
- const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"] });
90
- let stdout = "";
91
- let stderr = "";
92
- child.stdout.on("data", (d) => stdout += d.toString());
93
- child.stderr.on("data", (d) => stderr += d.toString());
94
- child.on("error", () => resolve2({ code: 127, stdout: "", stderr: "" }));
95
- child.on("close", (code) => resolve2({ code: code ?? 1, stdout, stderr }));
96
- if (stdin !== void 0) child.stdin.write(stdin);
97
- child.stdin.end();
98
- });
99
- }
100
- };
101
- NoKeychainError = class extends Error {
102
- constructor(platform) {
103
- super(
104
- `evrex could not find a credential store on this machine (${platform}).
105
- macOS needs \`security\`, which ships with the system.
106
- Linux needs \`secret-tool\` \u2014 install libsecret-tools (Debian/Ubuntu)
107
- or libsecret (Fedora/Arch), and make sure a keyring daemon is running.
108
- Windows needs PowerShell.
109
- evrex will not fall back to writing the credential in a plain file.`
110
- );
111
- this.name = "NoKeychainError";
112
- }
113
- };
114
- CredentialStore = class {
115
- constructor(runner = systemRunner) {
116
- this.runner = runner;
117
- }
118
- async available() {
119
- switch (this.runner.platform) {
120
- case "darwin":
121
- return (await this.runner.run("security", ["help"])).code !== 127;
122
- case "win32":
123
- return (await this.runner.run("powershell", ["-Command", "$PSVersionTable.PSVersion.Major"])).code !== 127;
124
- default:
125
- return (await this.runner.run("secret-tool", ["--version"])).code !== 127;
126
- }
127
- }
128
- /**
129
- * Replaces any existing credential rather than adding a second one. A
130
- * machine that re-enrols after expiry must end up with exactly one entry, or
131
- * the next read is a coin flip between the live credential and a dead one.
132
- */
133
- async store(secret) {
134
- if (!await this.available()) throw new NoKeychainError(this.runner.platform);
135
- switch (this.runner.platform) {
136
- case "darwin": {
137
- const result = await this.runner.run(
138
- "security",
139
- ["add-generic-password", "-a", ACCOUNT, "-s", SERVICE, "-U", "-w"],
140
- `${secret}
141
- ${secret}
142
- `
143
- );
144
- if (result.code !== 0) throw new Error(`Keychain write failed: ${result.stderr.trim()}`);
145
- return;
146
- }
147
- case "win32": {
148
- const result = await this.runner.run(
149
- "powershell",
150
- ["-NoProfile", "-Command", WINDOWS_STORE],
151
- secret
152
- );
153
- if (result.code !== 0) throw new Error(`DPAPI write failed: ${result.stderr.trim()}`);
154
- return;
155
- }
156
- default: {
157
- const result = await this.runner.run(
158
- "secret-tool",
159
- ["store", "--label=evrex capture credential", "service", SERVICE, "account", ACCOUNT],
160
- secret
161
- );
162
- if (result.code !== 0) throw new Error(`secret-tool write failed: ${result.stderr.trim()}`);
163
- return;
164
- }
165
- }
166
- }
167
- /** Null when there is nothing stored, which is the normal pre-enrolment state. */
168
- async retrieve() {
169
- if (!await this.available()) throw new NoKeychainError(this.runner.platform);
170
- switch (this.runner.platform) {
171
- case "darwin": {
172
- const r = await this.runner.run("security", [
173
- "find-generic-password",
174
- "-a",
175
- ACCOUNT,
176
- "-s",
177
- SERVICE,
178
- "-w"
179
- ]);
180
- return r.code === 0 ? r.stdout.trim() || null : null;
181
- }
182
- case "win32": {
183
- const r = await this.runner.run("powershell", ["-NoProfile", "-Command", WINDOWS_RETRIEVE]);
184
- return r.code === 0 ? r.stdout.trim() || null : null;
185
- }
186
- default: {
187
- const r = await this.runner.run("secret-tool", [
188
- "lookup",
189
- "service",
190
- SERVICE,
191
- "account",
192
- ACCOUNT
193
- ]);
194
- return r.code === 0 ? r.stdout.trim() || null : null;
195
- }
196
- }
197
- }
198
- /** Idempotent: removing a credential that is not there is not an error. */
199
- async remove() {
200
- if (!await this.available()) return;
201
- switch (this.runner.platform) {
202
- case "darwin":
203
- await this.runner.run("security", [
204
- "delete-generic-password",
205
- "-a",
206
- ACCOUNT,
207
- "-s",
208
- SERVICE
209
- ]);
210
- return;
211
- case "win32":
212
- await this.runner.run("powershell", ["-NoProfile", "-Command", WINDOWS_REMOVE]);
213
- return;
214
- default:
215
- await this.runner.run("secret-tool", [
216
- "clear",
217
- "service",
218
- SERVICE,
219
- "account",
220
- ACCOUNT
221
- ]);
222
- return;
223
- }
224
- }
27
+ CONVERSATION_KINDS = [
28
+ "claude-code",
29
+ // Xcode's coding assistant — the Claude Agent SDK embedded, writing the
30
+ // identical transcript format under ~/Library/Developer/Xcode. A separate
31
+ // kind because a conversation in an IDE panel is not a CLI session, and the
32
+ // Slack mislabel already taught this list what an absent entry costs.
33
+ "claude-xcode",
34
+ "cursor",
35
+ "codex",
36
+ "gemini",
37
+ // OpenCode keeps its sessions in a SQLite store rather than files; the
38
+ // parser reads the store, so this kind arrives through the same importer
39
+ // as Cursor's.
40
+ "opencode",
41
+ // GitHub Copilot CLI writes an events file per session under ~/.copilot;
42
+ // the coding agent on github.com is a different thing and arrives as a
43
+ // reference through the Agent-Logs-Url trailer, not as this kind.
44
+ "copilot",
45
+ "slack"
46
+ ];
47
+ REFERENCE_KINDS = ["linear", "jira", "confluence"];
48
+ SOURCE_KINDS = [
49
+ ...CONVERSATION_KINDS,
50
+ ...REFERENCE_KINDS
51
+ ];
52
+ EMPTY_USAGE = {
53
+ inputTokens: null,
54
+ outputTokens: null,
55
+ cacheReadTokens: null,
56
+ cacheWriteTokens: null,
57
+ model: null
225
58
  };
226
- WINDOWS_PATH = "$env:APPDATA\\evrex\\capture.cred";
227
- WINDOWS_STORE = `
228
- $ErrorActionPreference = 'Stop'
229
- $p = "${WINDOWS_PATH}"
230
- New-Item -ItemType Directory -Force -Path (Split-Path $p) | Out-Null
231
- $secret = [Console]::In.ReadToEnd().Trim()
232
- ConvertTo-SecureString $secret -AsPlainText -Force | ConvertFrom-SecureString | Set-Content -Path $p
233
- `.trim();
234
- WINDOWS_RETRIEVE = `
235
- $ErrorActionPreference = 'Stop'
236
- $p = "${WINDOWS_PATH}"
237
- if (-not (Test-Path $p)) { exit 1 }
238
- $sec = Get-Content $p | ConvertTo-SecureString
239
- [Runtime.InteropServices.Marshal]::PtrToStringAuto(
240
- [Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec))
241
- `.trim();
242
- WINDOWS_REMOVE = `
243
- $p = "${WINDOWS_PATH}"
244
- if (Test-Path $p) { Remove-Item $p -Force }
245
- `.trim();
59
+ PROVENANCE_TRAILERS = [
60
+ // GitHub's Copilot coding agent stamps every commit it makes with a link to
61
+ // its session logs (changelog, 2026-03-20). Copilot exposes no lifecycle
62
+ // hooks evrex could capture through, so this is the only way one of its
63
+ // commits ever gets a session behind it.
64
+ { key: "Agent-Logs-Url", agent: "copilot", label: "GitHub Copilot coding agent" },
65
+ // Entire's Checkpoints CLI: a checkpoint id, resolvable on entire.io or in
66
+ // the repo's own `refs/entire/checkpoints/` refs.
67
+ { key: "Entire-Checkpoint", agent: "entire", label: "Entire checkpoint" },
68
+ // AgentsRoom's Commit Context: the whole conversation, as an unlisted gist.
69
+ { key: "Agent-Conversation", agent: "agentsroom", label: "AgentsRoom conversation" }
70
+ ];
71
+ STATED_TRAILERS = [
72
+ { key: "Evrex-Rejected", kind: "rejected" },
73
+ { key: "Evrex-Constraint", kind: "constraint" },
74
+ { key: "Evrex-Decision", kind: "decision" }
75
+ ];
246
76
  }
247
77
  });
248
78
 
249
- // src/import.ts
250
- import { realpathSync } from "node:fs";
251
- import { homedir as homedir5 } from "node:os";
252
- import { fileURLToPath } from "node:url";
253
- import { resolve } from "node:path";
254
-
255
- // ../../packages/ingest-core/src/index.ts
256
- import { userInfo } from "node:os";
257
-
258
79
  // ../../packages/ingest-core/src/git-history.ts
259
80
  import { execFileSync } from "node:child_process";
260
- var DIFF_CAP = 2e4;
261
- var FIELD_SEP = "";
262
- var EVREX_SESSION_TRAILER_KEY = "Evrex-Session";
263
81
  function git(repoPath, args, input) {
264
82
  return execFileSync("git", args, {
265
83
  cwd: repoPath,
@@ -308,6 +126,10 @@ function repoIdFromRootCommit(sha) {
308
126
  function repoIdFromPath(repoPath) {
309
127
  return `path:${repoPath}`;
310
128
  }
129
+ function repoNameFromId(repoId) {
130
+ const withoutPrefix = repoId.replace(/^(remote|root|path):/, "");
131
+ return withoutPrefix.split("/").filter(Boolean).pop() ?? repoId;
132
+ }
311
133
  function firstRemoteUrl(repoPath) {
312
134
  try {
313
135
  const origin = gitQuiet(repoPath, ["remote", "get-url", "origin"]).trim();
@@ -371,25 +193,58 @@ function commitMeta(repoPath, sha) {
371
193
  message
372
194
  };
373
195
  }
374
- function extractTrailer(repoPath, message, key) {
196
+ function parseTrailers(repoPath, message) {
375
197
  try {
376
198
  const out = git(
377
199
  repoPath,
378
200
  ["interpret-trailers", "--parse", "--no-divider"],
379
201
  message
380
202
  ).trim();
203
+ const trailers = [];
381
204
  for (const line of out.split("\n")) {
382
205
  const idx = line.indexOf(":");
383
206
  if (idx === -1) continue;
384
- const trailerKey = line.slice(0, idx).trim();
385
- if (trailerKey.toLowerCase() === key.toLowerCase()) {
386
- return line.slice(idx + 1).trim();
387
- }
207
+ const key = line.slice(0, idx).trim();
208
+ const value = line.slice(idx + 1).trim();
209
+ if (key && value) trailers.push({ key, value });
388
210
  }
389
- return null;
211
+ return trailers;
390
212
  } catch {
391
- return null;
213
+ return [];
214
+ }
215
+ }
216
+ function trailersFromMessage(message) {
217
+ const paragraphs = message.replace(/\r\n/g, "\n").trim().split(/\n{2,}/);
218
+ const last = paragraphs[paragraphs.length - 1] ?? "";
219
+ if (paragraphs.length < 2) return [];
220
+ const lines = last.split("\n");
221
+ const trailers = [];
222
+ for (const line of lines) {
223
+ const m = /^([A-Za-z][A-Za-z0-9-]*):\s+(.+?)\s*$/.exec(line);
224
+ if (!m) return [];
225
+ trailers.push({ key: m[1], value: m[2] });
226
+ }
227
+ return trailers;
228
+ }
229
+ function statedInsightsOf(trailers) {
230
+ const out = [];
231
+ for (const t of trailers) {
232
+ const known = STATED_TRAILERS.find((s) => s.key.toLowerCase() === t.key.toLowerCase());
233
+ if (known && t.value.trim()) out.push({ kind: known.kind, text: t.value.trim() });
234
+ }
235
+ return out;
236
+ }
237
+ function trailerValue(trailers, key) {
238
+ const k = key.toLowerCase();
239
+ return trailers.find((t) => t.key.toLowerCase() === k)?.value ?? null;
240
+ }
241
+ function agentTrailersOf(trailers) {
242
+ const out = [];
243
+ for (const t of trailers) {
244
+ const known = provenanceTrailerFor(t.key);
245
+ if (known) out.push({ key: known.key, value: t.value, agent: known.agent });
392
246
  }
247
+ return out;
393
248
  }
394
249
  function commitBranch(repoPath, sha) {
395
250
  try {
@@ -455,6 +310,7 @@ function parseGitLog(repoPath, repoId, known) {
455
310
  const id = repoId ?? deriveRepoId(repoPath);
456
311
  return shas.map((sha) => {
457
312
  const meta = commitMeta(repoPath, sha);
313
+ const trailers = parseTrailers(repoPath, meta.message);
458
314
  return {
459
315
  sha: meta.sha,
460
316
  repoId: id,
@@ -464,14 +320,23 @@ function parseGitLog(repoPath, repoId, known) {
464
320
  ts: meta.ts,
465
321
  message: meta.message,
466
322
  branch: commitBranch(repoPath, sha),
467
- evrexSessionTrailer: extractTrailer(repoPath, meta.message, EVREX_SESSION_TRAILER_KEY),
323
+ evrexSessionTrailer: trailerValue(trailers, EVREX_SESSION_TRAILER_KEY),
324
+ agentTrailers: agentTrailersOf(trailers),
325
+ statedInsights: statedInsightsOf(trailers),
468
326
  files: commitFiles(repoPath, sha)
469
327
  };
470
328
  });
471
329
  }
472
-
473
- // ../../packages/ingest-core/src/claude-sessions.ts
474
- import { existsSync, readdirSync, readFileSync, statSync as statSync2 } from "node:fs";
330
+ var DIFF_CAP, FIELD_SEP, EVREX_SESSION_TRAILER_KEY;
331
+ var init_git_history = __esm({
332
+ "../../packages/ingest-core/src/git-history.ts"() {
333
+ "use strict";
334
+ init_types();
335
+ DIFF_CAP = 2e4;
336
+ FIELD_SEP = "";
337
+ EVREX_SESSION_TRAILER_KEY = "Evrex-Session";
338
+ }
339
+ });
475
340
 
476
341
  // ../../packages/ingest-core/src/incremental.ts
477
342
  import { statSync } from "node:fs";
@@ -507,28 +372,60 @@ function advanceCursor(path, consumedTo) {
507
372
  }
508
373
  return { offset: consumedTo, size, modifiedAt, parserVersion: PARSER_VERSION };
509
374
  }
510
- var PARSER_VERSION = 4;
375
+ function splitCompleteLines(chunk) {
376
+ const lastNewline = chunk.lastIndexOf("\n");
377
+ if (lastNewline === -1) return { lines: [], consumedBytes: 0 };
378
+ const complete = chunk.slice(0, lastNewline);
379
+ return {
380
+ lines: complete.split("\n").filter((l) => l.trim().length > 0),
381
+ consumedBytes: Buffer.byteLength(complete, "utf-8") + 1
382
+ };
383
+ }
384
+ function sessionSignature(session) {
385
+ const last = session.turns[session.turns.length - 1]?.ts ?? session.endedAt ?? "";
386
+ return `v${PARSER_VERSION}:${session.turnCount}:${last}`;
387
+ }
388
+ function changedSessions(sessions, known) {
389
+ const signatures = {};
390
+ const changed = [];
391
+ for (const session of sessions) {
392
+ const signature = sessionSignature(session);
393
+ signatures[session.id] = signature;
394
+ if (known[session.id] !== signature) changed.push(session);
395
+ }
396
+ return { changed, signatures };
397
+ }
398
+ var PARSER_VERSION;
399
+ var init_incremental = __esm({
400
+ "../../packages/ingest-core/src/incremental.ts"() {
401
+ "use strict";
402
+ PARSER_VERSION = 4;
403
+ }
404
+ });
511
405
 
512
- // ../../packages/ingest-core/src/claude-sessions.ts
513
- import { homedir } from "node:os";
514
- import { join } from "node:path";
406
+ // ../../packages/ingest-core/src/derive-uuid.ts
407
+ import { createHash } from "node:crypto";
408
+ function deriveUuid(name) {
409
+ const h = createHash("sha1").update(name).digest("hex");
410
+ const variant = (parseInt(h.slice(16, 17) || "0", 16) & 3 | 8).toString(16);
411
+ const s = h.slice(0, 12) + // time-low + time-mid
412
+ "5" + // version 5 (name-based, SHA-1)
413
+ h.slice(13, 16) + variant + h.slice(17, 32);
414
+ return `${s.slice(0, 8)}-${s.slice(8, 12)}-${s.slice(12, 16)}-${s.slice(16, 20)}-${s.slice(20, 32)}`;
415
+ }
416
+ function deriveSlackSessionId(channel, threadTs) {
417
+ return deriveUuid(`evrex-slack-session ${channel} ${threadTs}`);
418
+ }
419
+ function deriveSlackTurnId(channel, messageTs) {
420
+ return deriveUuid(`evrex-slack-turn ${channel} ${messageTs}`);
421
+ }
422
+ var init_derive_uuid = __esm({
423
+ "../../packages/ingest-core/src/derive-uuid.ts"() {
424
+ "use strict";
425
+ }
426
+ });
515
427
 
516
428
  // ../../packages/ingest-core/src/redact.ts
517
- var PATTERNS = [
518
- { type: "private_key", regex: /-----BEGIN(?: [A-Z]+)? PRIVATE KEY-----[\s\S]*?-----END(?: [A-Z]+)? PRIVATE KEY-----/g },
519
- { type: "aws_access_key", regex: /AKIA[0-9A-Z]{16}/g },
520
- { type: "github_token", regex: /gh[pousr]_[A-Za-z0-9]{36,}/g },
521
- { type: "slack_token", regex: /xox[baprs]-[A-Za-z0-9-]{10,}/g },
522
- { type: "stripe_key", regex: /sk_(live|test)_[A-Za-z0-9]{24,}/g },
523
- { type: "openai_key", regex: /sk-[A-Za-z0-9]{20,}/g },
524
- { type: "anthropic_key", regex: /sk-ant-[A-Za-z0-9-_]{20,}/g },
525
- { type: "jwt", regex: /eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g },
526
- { type: "bearer_token", regex: /Bearer\s+[A-Za-z0-9\-_.]{20,}/g },
527
- {
528
- type: "env_secret",
529
- regex: /\b([A-Z0-9_]*(?:_KEY|_SECRET|_TOKEN|_PASSWORD|PASSWD)[A-Z0-9_]*)\s*[:=]\s*["']?[^\s"'\n]{6,}["']?/g
530
- }
531
- ];
532
429
  function redactJsonValue(value) {
533
430
  let count = 0;
534
431
  const walk = (v) => {
@@ -549,7 +446,31 @@ function redactJsonValue(value) {
549
446
  };
550
447
  return { value: walk(value), count };
551
448
  }
552
- var PRIVATE_BLOCK = /<private>[\s\S]*?(?:<\/private>|$)/gi;
449
+ function redactRawTranscript(content, format) {
450
+ let count = 0;
451
+ if (format === "jsonl") {
452
+ const out = content.split("\n").map((line) => {
453
+ if (!line.trim()) return line;
454
+ try {
455
+ const r = redactJsonValue(JSON.parse(line));
456
+ count += r.count;
457
+ return JSON.stringify(r.value);
458
+ } catch {
459
+ const r = redactSecrets(line);
460
+ count += r.count;
461
+ return r.text;
462
+ }
463
+ });
464
+ return { content: out.join("\n"), count };
465
+ }
466
+ try {
467
+ const r = redactJsonValue(JSON.parse(content));
468
+ return { content: JSON.stringify(r.value), count: r.count };
469
+ } catch {
470
+ const r = redactSecrets(content);
471
+ return { content: r.text, count: r.count };
472
+ }
473
+ }
553
474
  function redactSecrets(input) {
554
475
  let text = input;
555
476
  let count = 0;
@@ -568,8 +489,74 @@ function redactSecrets(input) {
568
489
  }
569
490
  return { text, count };
570
491
  }
492
+ var PATTERNS, PRIVATE_BLOCK;
493
+ var init_redact = __esm({
494
+ "../../packages/ingest-core/src/redact.ts"() {
495
+ "use strict";
496
+ PATTERNS = [
497
+ { type: "private_key", regex: /-----BEGIN(?: [A-Z]+)? PRIVATE KEY-----[\s\S]*?-----END(?: [A-Z]+)? PRIVATE KEY-----/g },
498
+ { type: "aws_access_key", regex: /AKIA[0-9A-Z]{16}/g },
499
+ { type: "github_token", regex: /gh[pousr]_[A-Za-z0-9]{36,}/g },
500
+ { type: "slack_token", regex: /xox[baprs]-[A-Za-z0-9-]{10,}/g },
501
+ { type: "stripe_key", regex: /sk_(live|test)_[A-Za-z0-9]{24,}/g },
502
+ { type: "openai_key", regex: /sk-[A-Za-z0-9]{20,}/g },
503
+ { type: "anthropic_key", regex: /sk-ant-[A-Za-z0-9-_]{20,}/g },
504
+ { type: "jwt", regex: /eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g },
505
+ { type: "bearer_token", regex: /Bearer\s+[A-Za-z0-9\-_.]{20,}/g },
506
+ {
507
+ type: "env_secret",
508
+ regex: /\b([A-Z0-9_]*(?:_KEY|_SECRET|_TOKEN|_PASSWORD|PASSWD)[A-Z0-9_]*)\s*[:=]\s*["']?[^\s"'\n]{6,}["']?/g
509
+ }
510
+ ];
511
+ PRIVATE_BLOCK = /<private>[\s\S]*?(?:<\/private>|$)/gi;
512
+ }
513
+ });
571
514
 
572
515
  // ../../packages/ingest-core/src/subject-linking.ts
516
+ function matchCommitToSession(commit, sessions) {
517
+ const subject = commit.subject.trim();
518
+ if (subject.length === 0) {
519
+ return { sha: commit.sha, sessionId: null, reason: "absent" };
520
+ }
521
+ const ran = sessions.filter((s) => s.committedSubjects.includes(subject));
522
+ if (ran.length === 0) {
523
+ return { sha: commit.sha, sessionId: null, reason: "absent" };
524
+ }
525
+ if (ran.length === 1) {
526
+ return {
527
+ sha: commit.sha,
528
+ sessionId: ran[0].sessionId,
529
+ evidence: "ran-the-commit",
530
+ confidence: INFERRED_CONFIDENCE
531
+ };
532
+ }
533
+ const oneLineage = ran.every((s) => s.startedAt === ran[0].startedAt);
534
+ if (!oneLineage) {
535
+ return { sha: commit.sha, sessionId: null, reason: "ambiguous" };
536
+ }
537
+ const original = [...ran].sort((a, b) => a.endedAt - b.endedAt)[0];
538
+ return {
539
+ sha: commit.sha,
540
+ sessionId: original.sessionId,
541
+ evidence: "ran-the-commit-then-lineage",
542
+ confidence: INFERRED_CONFIDENCE
543
+ };
544
+ }
545
+ function proposeLinks(commits, sessions) {
546
+ const out = [];
547
+ for (const commit of commits) {
548
+ const match = matchCommitToSession(commit, sessions);
549
+ if (match.sessionId !== null) {
550
+ out.push({
551
+ sha: match.sha,
552
+ sessionId: match.sessionId,
553
+ confidence: match.confidence,
554
+ evidence: match.evidence
555
+ });
556
+ }
557
+ }
558
+ return out;
559
+ }
573
560
  function committedSubjects(command) {
574
561
  const out = [];
575
562
  const invocation = /\bgit\s+(?:-C\s+\S+\s+)?commit\b/g;
@@ -589,30 +576,18 @@ function committedSubjects(command) {
589
576
  }
590
577
  return out;
591
578
  }
592
-
593
- // ../../packages/ingest-core/src/types.ts
594
- var CONVERSATION_KINDS = [
595
- "claude-code",
596
- "cursor",
597
- "codex",
598
- "gemini",
599
- "slack"
600
- ];
601
- var REFERENCE_KINDS = ["linear", "jira", "confluence"];
602
- var SOURCE_KINDS = [
603
- ...CONVERSATION_KINDS,
604
- ...REFERENCE_KINDS
605
- ];
606
- var EMPTY_USAGE = {
607
- inputTokens: null,
608
- outputTokens: null,
609
- cacheReadTokens: null,
610
- cacheWriteTokens: null,
611
- model: null
612
- };
579
+ var INFERRED_CONFIDENCE;
580
+ var init_subject_linking = __esm({
581
+ "../../packages/ingest-core/src/subject-linking.ts"() {
582
+ "use strict";
583
+ INFERRED_CONFIDENCE = 0.9;
584
+ }
585
+ });
613
586
 
614
587
  // ../../packages/ingest-core/src/claude-sessions.ts
615
- var MIN_MEANINGFUL_LINE_LENGTH = 6;
588
+ import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, statSync as statSync2 } from "node:fs";
589
+ import { homedir } from "node:os";
590
+ import { join } from "node:path";
616
591
  function meaningfulLines(lines) {
617
592
  return lines.map((l) => l.trim()).filter((l) => l.length >= MIN_MEANINGFUL_LINE_LENGTH);
618
593
  }
@@ -639,20 +614,56 @@ function extractEditedLines(toolUseResult) {
639
614
  if (meaningfulAdded.length === 0 && meaningfulRemoved.length === 0) return null;
640
615
  return { path: r.filePath, added: meaningfulAdded, removed: meaningfulRemoved };
641
616
  }
642
- var MAX_TURN_TEXT_LENGTH = 4e3;
643
617
  function slugifyCwd(repoPath) {
644
- return repoPath.replace(/\//g, "-");
618
+ return repoPath.replace(/[^A-Za-z0-9]/g, "-");
645
619
  }
646
620
  function claudeProjectsDir() {
647
621
  return join(homedir(), ".claude", "projects");
648
622
  }
623
+ function xcodeAssistantProjectsDir() {
624
+ return join(
625
+ homedir(),
626
+ "Library",
627
+ "Developer",
628
+ "Xcode",
629
+ "CodingAssistant",
630
+ "ClaudeAgentConfig",
631
+ "projects"
632
+ );
633
+ }
649
634
  function findSessionFiles(repoPath) {
650
- const dir = join(claudeProjectsDir(), slugifyCwd(repoPath));
651
- if (!existsSync(dir)) return [];
652
- return readdirSync(dir).filter((name) => name.endsWith(".jsonl")).map((name) => join(dir, name));
635
+ const roots = [
636
+ { dir: claudeProjectsDir(), agentKind: "claude-code" },
637
+ { dir: xcodeAssistantProjectsDir(), agentKind: "claude-xcode" }
638
+ ];
639
+ const out = [];
640
+ for (const { dir, agentKind } of roots) {
641
+ const slug = join(dir, slugifyCwd(repoPath));
642
+ if (!existsSync(slug)) continue;
643
+ for (const name of readdirSync(slug)) {
644
+ if (name.endsWith(".jsonl")) out.push({ file: join(slug, name), agentKind });
645
+ const subagents = join(slug, name, "subagents");
646
+ if (!name.endsWith(".jsonl") && existsSync(subagents)) {
647
+ for (const child of readdirSync(subagents)) {
648
+ if (child.endsWith(".jsonl")) out.push({ file: join(subagents, child), agentKind });
649
+ }
650
+ }
651
+ }
652
+ }
653
+ return out;
654
+ }
655
+ function editFromXcodeInput(input) {
656
+ const filePath = input.filePath;
657
+ if (typeof filePath !== "string" || !filePath) return null;
658
+ const oldLines = typeof input.oldString === "string" ? input.oldString.split("\n") : [];
659
+ const newLines = typeof input.newString === "string" ? input.newString.split("\n") : [];
660
+ const oldSet = new Set(oldLines);
661
+ const newSet = new Set(newLines);
662
+ const added = meaningfulLines(newLines.filter((l) => !oldSet.has(l)));
663
+ const removed = meaningfulLines(oldLines.filter((l) => !newSet.has(l)));
664
+ if (added.length === 0 && removed.length === 0) return null;
665
+ return { path: filePath, added, removed };
653
666
  }
654
- var FILE_PATH_TOOLS = /* @__PURE__ */ new Set(["Read", "Edit", "Write", "NotebookEdit"]);
655
- var PATH_TOKEN_RE = /(?:[.~]?\/)?(?:[\w-]+\/)+[\w.-]+/g;
656
667
  function extractPathsFromText(text) {
657
668
  const matches = text.match(PATH_TOKEN_RE) ?? [];
658
669
  return [...new Set(matches)].filter((p) => p.length > 3 && p.length < 300);
@@ -667,6 +678,7 @@ function blockText(content) {
667
678
  function extractFromAssistantContent(content) {
668
679
  const textParts = [];
669
680
  const filesTouched = [];
681
+ const editedLines = [];
670
682
  for (const block of content) {
671
683
  if (block.type === "text" && block.text) {
672
684
  textParts.push(block.text);
@@ -676,7 +688,15 @@ function extractFromAssistantContent(content) {
676
688
  } else if (block.type === "tool_use") {
677
689
  const name = block.name ?? "tool";
678
690
  const input = block.input ?? {};
679
- if (FILE_PATH_TOOLS.has(name) && typeof input.file_path === "string") {
691
+ if (XCODE_EDIT_TOOL.test(name) && typeof input.filePath === "string") {
692
+ textParts.push(`[tool_call: ${name}] ${input.filePath}`);
693
+ filesTouched.push({ path: input.filePath, source: "tool_path", tool: name });
694
+ const edit = editFromXcodeInput(input);
695
+ if (edit) editedLines.push(edit);
696
+ } else if (XCODE_PATH_TOOLS.test(name) && typeof input.filePath === "string") {
697
+ textParts.push(`[tool_call: ${name}] ${input.filePath}`);
698
+ filesTouched.push({ path: input.filePath, source: "tool_path", tool: name });
699
+ } else if (FILE_PATH_TOOLS.has(name) && typeof input.file_path === "string") {
680
700
  textParts.push(`[tool_call: ${name}] ${input.file_path}`);
681
701
  filesTouched.push({ path: input.file_path, source: "tool_path", tool: name });
682
702
  } else if (name === "Bash" && typeof input.command === "string") {
@@ -690,9 +710,8 @@ function extractFromAssistantContent(content) {
690
710
  }
691
711
  }
692
712
  }
693
- return { text: textParts.join("\n"), filesTouched };
713
+ return { text: textParts.join("\n"), filesTouched, editedLines };
694
714
  }
695
- var SYNTHETIC_CONTENT_RE = /^\s*(<task-notification>|<system-reminder>|\[SYSTEM NOTIFICATION)/;
696
715
  function extractFromUserContent(content) {
697
716
  if (typeof content === "string") {
698
717
  return {
@@ -704,9 +723,11 @@ function extractFromUserContent(content) {
704
723
  if (Array.isArray(content)) {
705
724
  const toolParts = [];
706
725
  const textParts = [];
726
+ let isToolError = false;
707
727
  for (const block of content) {
708
728
  if (block.type === "tool_result") {
709
729
  toolParts.push(blockText(block.content));
730
+ if (block.is_error === true) isToolError = true;
710
731
  } else if (block.type === "text" && typeof block.text === "string") {
711
732
  textParts.push(block.text);
712
733
  }
@@ -715,7 +736,8 @@ function extractFromUserContent(content) {
715
736
  return {
716
737
  text: toolParts.join("\n"),
717
738
  filesTouched: [],
718
- isSyntheticInput: true
739
+ isSyntheticInput: true,
740
+ isToolError
719
741
  };
720
742
  }
721
743
  const text = textParts.join("\n");
@@ -751,8 +773,35 @@ function usageOnce(record, billed) {
751
773
  model: typeof message?.model === "string" ? message.model : null
752
774
  };
753
775
  }
754
- function parseSessionFile(filePath, repoPath, repoId = deriveRepoId(repoPath)) {
755
- const raw = readFileSync(filePath, "utf-8");
776
+ function readTranscript(filePath) {
777
+ const size = statSync2(filePath).size;
778
+ if (size <= MAX_TRANSCRIPT_BYTES) {
779
+ return { text: readFileSync(filePath, "utf-8"), capped: false };
780
+ }
781
+ const fd = openSync(filePath, "r");
782
+ try {
783
+ const buf = Buffer.alloc(MAX_TRANSCRIPT_BYTES);
784
+ readSync(fd, buf, 0, MAX_TRANSCRIPT_BYTES, size - MAX_TRANSCRIPT_BYTES);
785
+ const text = buf.toString("utf-8");
786
+ return { text: text.slice(text.indexOf("\n") + 1), capped: true };
787
+ } finally {
788
+ closeSync(fd);
789
+ }
790
+ }
791
+ function readSubagentMeta(transcriptPath) {
792
+ const metaPath = transcriptPath.replace(/\.jsonl$/, ".meta.json");
793
+ try {
794
+ const raw = JSON.parse(readFileSync(metaPath, "utf-8"));
795
+ return {
796
+ agentType: typeof raw.agentType === "string" ? raw.agentType : null,
797
+ description: typeof raw.description === "string" ? raw.description : null
798
+ };
799
+ } catch {
800
+ return null;
801
+ }
802
+ }
803
+ function parseSessionFile(filePath, repoPath, repoId = deriveRepoId(repoPath), agentKind = "claude-code") {
804
+ const { text: raw, capped } = readTranscript(filePath);
756
805
  const lines = raw.split("\n").filter((l) => l.trim().length > 0);
757
806
  const turns = [];
758
807
  const billedMessages = /* @__PURE__ */ new Set();
@@ -760,6 +809,8 @@ function parseSessionFile(filePath, repoPath, repoId = deriveRepoId(repoPath)) {
760
809
  const cwd = repoPath;
761
810
  let aiTitle = null;
762
811
  let totalRedactions = 0;
812
+ let branch = null;
813
+ let agentId = null;
763
814
  for (const line of lines) {
764
815
  let record;
765
816
  try {
@@ -776,22 +827,27 @@ function parseSessionFile(filePath, repoPath, repoId = deriveRepoId(repoPath)) {
776
827
  const ts = record.timestamp;
777
828
  if (!id || !ts) continue;
778
829
  sessionId ??= record.sessionId ?? record.session_id ?? null;
830
+ if (typeof record.gitBranch === "string" && record.gitBranch) branch = record.gitBranch;
831
+ if (typeof record.agentId === "string" && record.agentId) agentId ??= record.agentId;
779
832
  let text = "";
780
833
  let filesTouched = [];
781
834
  let editedLines = [];
782
835
  let isSyntheticInput = false;
836
+ let isToolError = false;
783
837
  if (record.type === "assistant") {
784
838
  const content = record.message?.content;
785
839
  if (Array.isArray(content)) {
786
840
  const extracted = extractFromAssistantContent(content);
787
841
  text = extracted.text;
788
842
  filesTouched = extracted.filesTouched;
843
+ editedLines = extracted.editedLines;
789
844
  }
790
845
  } else {
791
846
  const extracted = extractFromUserContent(record.message?.content);
792
847
  text = extracted.text;
793
848
  filesTouched = extracted.filesTouched;
794
849
  isSyntheticInput = extracted.isSyntheticInput;
850
+ isToolError = extracted.isToolError ?? false;
795
851
  const edited = extractEditedLines(record.toolUseResult);
796
852
  if (edited) editedLines = [edited];
797
853
  }
@@ -809,12 +865,21 @@ function parseSessionFile(filePath, repoPath, repoId = deriveRepoId(repoPath)) {
809
865
  isSidechain: Boolean(record.isSidechain),
810
866
  redacted: redacted.count > 0,
811
867
  isSyntheticInput,
868
+ isToolError,
812
869
  usage: usageOnce(record, billedMessages)
813
870
  });
814
871
  }
815
872
  if (!sessionId || turns.length === 0) return null;
873
+ const parentSessionId = agentId ? sessionId : null;
874
+ if (agentId) {
875
+ sessionId = deriveUuid(`evrex-subagent\0${parentSessionId}\0${agentId}`);
876
+ for (const t of turns) t.sessionId = sessionId;
877
+ }
878
+ const meta = agentId ? readSubagentMeta(filePath) : null;
879
+ const subagent = agentId ? { agentId, agentType: meta?.agentType ?? null, description: meta?.description ?? null } : null;
880
+ if (agentId && !aiTitle && meta?.description) aiTitle = meta.description;
816
881
  const sortedTs = turns.map((t) => t.ts).sort();
817
- const rawContent = lines.map((line) => {
882
+ const rawContent = capped ? "" : lines.map((line) => {
818
883
  try {
819
884
  return JSON.stringify(redactJsonValue(JSON.parse(line)).value);
820
885
  } catch {
@@ -826,7 +891,7 @@ function parseSessionFile(filePath, repoPath, repoId = deriveRepoId(repoPath)) {
826
891
  }
827
892
  return {
828
893
  id: sessionId,
829
- agentKind: "claude-code",
894
+ agentKind,
830
895
  repoId,
831
896
  cwd,
832
897
  startedAt: sortedTs[0] ?? null,
@@ -838,6 +903,9 @@ function parseSessionFile(filePath, repoPath, repoId = deriveRepoId(repoPath)) {
838
903
  sourceFile: filePath,
839
904
  redactionCount: totalRedactions,
840
905
  committedSubjects: collectCommittedSubjects(lines),
906
+ branch,
907
+ parentSessionId,
908
+ subagent,
841
909
  rawContent,
842
910
  rawFormat: "jsonl",
843
911
  turns
@@ -869,40 +937,45 @@ function parseAllSessions(repoPath, repoId, cursors = {}) {
869
937
  const next = { ...cursors };
870
938
  const sessions = [];
871
939
  let skipped = 0;
872
- for (const file of findSessionFiles(repoPath)) {
940
+ for (const { file, agentKind } of findSessionFiles(repoPath)) {
873
941
  const plan = planRead(file, cursors[file]);
874
942
  if (plan.reason === "unchanged") {
875
943
  skipped++;
876
944
  continue;
877
945
  }
878
- const parsed = parseSessionFile(file, repoPath, id);
946
+ const parsed = parseSessionFile(file, repoPath, id, agentKind);
879
947
  if (!parsed) continue;
880
948
  sessions.push(parsed);
881
949
  next[file] = advanceCursor(file, statSync2(file).size);
882
950
  }
883
951
  return { sessions, cursors: next, skipped };
884
952
  }
953
+ var MIN_MEANINGFUL_LINE_LENGTH, MAX_TURN_TEXT_LENGTH, FILE_PATH_TOOLS, XCODE_EDIT_TOOL, XCODE_PATH_TOOLS, PATH_TOKEN_RE, SYNTHETIC_CONTENT_RE, MAX_TRANSCRIPT_BYTES;
954
+ var init_claude_sessions = __esm({
955
+ "../../packages/ingest-core/src/claude-sessions.ts"() {
956
+ "use strict";
957
+ init_incremental();
958
+ init_git_history();
959
+ init_derive_uuid();
960
+ init_redact();
961
+ init_subject_linking();
962
+ init_types();
963
+ MIN_MEANINGFUL_LINE_LENGTH = 6;
964
+ MAX_TURN_TEXT_LENGTH = 4e3;
965
+ FILE_PATH_TOOLS = /* @__PURE__ */ new Set(["Read", "Edit", "Write", "NotebookEdit"]);
966
+ XCODE_EDIT_TOOL = /XcodeUpdate$/;
967
+ XCODE_PATH_TOOLS = /Xcode(Read|Grep|Glob|RefreshCodeIssuesInFile)$/;
968
+ PATH_TOKEN_RE = /(?:[.~]?\/)?(?:[\w-]+\/)+[\w.-]+/g;
969
+ SYNTHETIC_CONTENT_RE = /^\s*(<task-notification>|<system-reminder>|\[SYSTEM NOTIFICATION)/;
970
+ MAX_TRANSCRIPT_BYTES = 256 * 1024 * 1024;
971
+ }
972
+ });
885
973
 
886
974
  // ../../packages/ingest-core/src/cursor-sessions.ts
887
975
  import { execFileSync as execFileSync2 } from "node:child_process";
888
976
  import { existsSync as existsSync2 } from "node:fs";
889
977
  import { homedir as homedir2 } from "node:os";
890
978
  import { join as join2, sep } from "node:path";
891
-
892
- // ../../packages/ingest-core/src/derive-uuid.ts
893
- import { createHash } from "node:crypto";
894
- function deriveUuid(name) {
895
- const h = createHash("sha1").update(name).digest("hex");
896
- const variant = (parseInt(h.slice(16, 17) || "0", 16) & 3 | 8).toString(16);
897
- const s = h.slice(0, 12) + // time-low + time-mid
898
- "5" + // version 5 (name-based, SHA-1)
899
- h.slice(13, 16) + variant + h.slice(17, 32);
900
- return `${s.slice(0, 8)}-${s.slice(8, 12)}-${s.slice(12, 16)}-${s.slice(16, 20)}-${s.slice(20, 32)}`;
901
- }
902
-
903
- // ../../packages/ingest-core/src/cursor-sessions.ts
904
- var MAX_TURN_TEXT_LENGTH2 = 4e3;
905
- var PATH_TOKEN_RE2 = /(?:[.~]?\/)?(?:[\w-]+\/)+[\w.-]+/g;
906
979
  function extractPathsFromText2(text) {
907
980
  const matches = text.match(PATH_TOKEN_RE2) ?? [];
908
981
  return [...new Set(matches)].filter((p) => p.length > 3 && p.length < 300);
@@ -1119,6 +1192,7 @@ function parseCursorComposer(dbPath, composer, repoPath, options = { scoped: fal
1119
1192
  // wire-format ambiguity where a tool result also arrives as a
1120
1193
  // `role: "user"` record. No synthetic-input misattribution risk here.
1121
1194
  isSyntheticInput: false,
1195
+ isToolError: false,
1122
1196
  // Neither source reports what a turn cost, so it is unknown rather than free.
1123
1197
  usage: { ...EMPTY_USAGE }
1124
1198
  }
@@ -1176,6 +1250,9 @@ function parseCursorComposer(dbPath, composer, repoPath, options = { scoped: fal
1176
1250
  ...new Set(turns.flatMap((t) => committedSubjects(t.text)))
1177
1251
  ],
1178
1252
  rawContent,
1253
+ branch: null,
1254
+ parentSessionId: null,
1255
+ subagent: null,
1179
1256
  rawFormat: "json",
1180
1257
  turns
1181
1258
  };
@@ -1184,6 +1261,12 @@ function workspaceMatchesRepo(workspacePath, repoPath) {
1184
1261
  if (!workspacePath) return false;
1185
1262
  return workspacePath === repoPath || workspacePath.startsWith(`${repoPath}${sep}`);
1186
1263
  }
1264
+ function parseCursorConversation(composerId, repoPath, repoId, dbPath = cursorStateDbPath()) {
1265
+ if (!existsSync2(dbPath)) return null;
1266
+ const composer = loadComposers(dbPath).find((c) => c.composerId === composerId);
1267
+ if (!composer) return null;
1268
+ return parseCursorComposer(dbPath, composer, repoPath, { scoped: false }, repoId);
1269
+ }
1187
1270
  function parseAllCursorSessions(repoPath, repoId) {
1188
1271
  const dbPath = cursorStateDbPath();
1189
1272
  if (!existsSync2(dbPath)) return [];
@@ -1214,15 +1297,497 @@ function parseAllCursorSessions(repoPath, repoId) {
1214
1297
  }
1215
1298
  return sessions;
1216
1299
  }
1300
+ var MAX_TURN_TEXT_LENGTH2, PATH_TOKEN_RE2;
1301
+ var init_cursor_sessions = __esm({
1302
+ "../../packages/ingest-core/src/cursor-sessions.ts"() {
1303
+ "use strict";
1304
+ init_git_history();
1305
+ init_redact();
1306
+ init_derive_uuid();
1307
+ init_subject_linking();
1308
+ init_types();
1309
+ MAX_TURN_TEXT_LENGTH2 = 4e3;
1310
+ PATH_TOKEN_RE2 = /(?:[.~]?\/)?(?:[\w-]+\/)+[\w.-]+/g;
1311
+ }
1312
+ });
1217
1313
 
1218
- // ../../packages/ingest-core/src/codex-sessions.ts
1219
- import { createHash as createHash2 } from "node:crypto";
1220
- import { existsSync as existsSync3, readFileSync as readFileSync2, readdirSync as readdirSync2, statSync as statSync3 } from "node:fs";
1314
+ // ../../packages/ingest-core/src/opencode-sessions.ts
1315
+ import { existsSync as existsSync3 } from "node:fs";
1221
1316
  import { homedir as homedir3 } from "node:os";
1222
1317
  import { join as join3 } from "node:path";
1223
- var CODEX_DIR = ".codex";
1318
+ function opencodeDbPath(env = process.env, home = homedir3()) {
1319
+ const data = env.XDG_DATA_HOME || join3(home, ".local", "share");
1320
+ return join3(data, "opencode", "opencode.db");
1321
+ }
1322
+ function q(value) {
1323
+ return `'${value.replace(/'/g, "''")}'`;
1324
+ }
1325
+ function parseJson(raw) {
1326
+ try {
1327
+ return JSON.parse(raw);
1328
+ } catch {
1329
+ return null;
1330
+ }
1331
+ }
1332
+ function deriveOpenCodeSessionId(sessionId) {
1333
+ return deriveUuid(`evrex-opencode\0${sessionId}`);
1334
+ }
1335
+ function deriveTurnId(partOrMessageId, suffix = "") {
1336
+ return deriveUuid(`evrex-opencode-turn\0${partOrMessageId}${suffix}`);
1337
+ }
1338
+ function filesFromTool2(tool, input) {
1339
+ const out = [];
1340
+ for (const key of ["filePath", "path", "file"]) {
1341
+ const v = input[key];
1342
+ if (typeof v === "string" && v.length > 0) out.push({ path: v, source: "tool_path", tool });
1343
+ }
1344
+ const command = input.command;
1345
+ if (tool === "bash" && typeof command === "string") {
1346
+ for (const p of extractPathsFromText(command)) out.push({ path: p, source: "tool_bash", tool });
1347
+ }
1348
+ return out;
1349
+ }
1350
+ function usageOf(message) {
1351
+ const t = message.tokens;
1352
+ if (!t) return { ...EMPTY_USAGE, model: message.modelID ?? null };
1353
+ return {
1354
+ inputTokens: t.input ?? null,
1355
+ outputTokens: t.output ?? null,
1356
+ cacheReadTokens: t.cache?.read ?? null,
1357
+ cacheWriteTokens: t.cache?.write ?? null,
1358
+ model: message.modelID ?? null
1359
+ };
1360
+ }
1361
+ function buildOpenCodeSession(session, messages, parts, repoId, sourceFile) {
1362
+ const id = deriveOpenCodeSessionId(session.id);
1363
+ const partsByMessage = /* @__PURE__ */ new Map();
1364
+ for (const p of parts) {
1365
+ const list = partsByMessage.get(p.message_id) ?? [];
1366
+ list.push(p);
1367
+ partsByMessage.set(p.message_id, list);
1368
+ }
1369
+ const turns = [];
1370
+ let redactionCount = 0;
1371
+ let previous = null;
1372
+ const subjects = [];
1373
+ const push = (turn) => {
1374
+ const redacted = redactSecrets(turn.text);
1375
+ redactionCount += redacted.count;
1376
+ turns.push({
1377
+ ...turn,
1378
+ text: redacted.text,
1379
+ sessionId: id,
1380
+ parentUuid: previous,
1381
+ isSidechain: false,
1382
+ redacted: redacted.count > 0,
1383
+ editedLines: []
1384
+ });
1385
+ previous = turn.id;
1386
+ };
1387
+ const at = (ms) => new Date(ms).toISOString();
1388
+ for (const m of [...messages].sort((a, b) => a.time_created - b.time_created)) {
1389
+ const data = parseJson(m.data);
1390
+ if (!data?.role) continue;
1391
+ const mparts = (partsByMessage.get(m.id) ?? []).sort((a, b) => a.time_created - b.time_created);
1392
+ if (data.role === "user") {
1393
+ const text = mparts.map((p) => parseJson(p.data)).filter((p) => !!p && p.type === "text" && typeof p.text === "string").map((p) => p.text).join("\n").trim();
1394
+ if (!text) continue;
1395
+ push({
1396
+ id: deriveTurnId(m.id),
1397
+ role: "user",
1398
+ ts: at(data.time?.created ?? m.time_created),
1399
+ text,
1400
+ filesTouched: extractPathsFromText(text).map((path) => ({ path, source: "prose" })),
1401
+ usage: EMPTY_USAGE,
1402
+ isSyntheticInput: false,
1403
+ isToolError: false
1404
+ });
1405
+ continue;
1406
+ }
1407
+ const usage = usageOf(data);
1408
+ let usageGiven = false;
1409
+ const take = () => {
1410
+ if (usageGiven) return EMPTY_USAGE;
1411
+ usageGiven = true;
1412
+ return usage;
1413
+ };
1414
+ for (const p of mparts) {
1415
+ const part = parseJson(p.data);
1416
+ if (!part) continue;
1417
+ if (part.type === "text" && typeof part.text === "string" && part.text.trim()) {
1418
+ push({
1419
+ id: deriveTurnId(p.id),
1420
+ role: "assistant",
1421
+ ts: at(p.time_created),
1422
+ text: part.text.trim(),
1423
+ filesTouched: [],
1424
+ usage: take(),
1425
+ isSyntheticInput: false,
1426
+ isToolError: false
1427
+ });
1428
+ } else if (part.type === "tool" && typeof part.tool === "string") {
1429
+ const input = part.state?.input ?? {};
1430
+ const args = JSON.stringify(input);
1431
+ const files = filesFromTool2(part.tool, input);
1432
+ if (part.tool === "bash" && typeof input.command === "string") {
1433
+ subjects.push(...committedSubjects(input.command));
1434
+ }
1435
+ push({
1436
+ id: deriveTurnId(p.id, ":call"),
1437
+ role: "assistant",
1438
+ ts: at(p.time_created),
1439
+ text: `[tool: ${part.tool}] ${args.length > 600 ? `${args.slice(0, 600)}\u2026` : args}`,
1440
+ filesTouched: files,
1441
+ usage: take(),
1442
+ isSyntheticInput: false,
1443
+ isToolError: false
1444
+ });
1445
+ const failed = part.state?.status === "error";
1446
+ const output = failed ? part.state?.error ?? part.state?.output ?? "" : part.state?.output ?? "";
1447
+ push({
1448
+ id: deriveTurnId(p.id, ":result"),
1449
+ role: "user",
1450
+ ts: at(p.time_created),
1451
+ text: output.length > MAX_OUTPUT_CHARS ? `${output.slice(0, MAX_OUTPUT_CHARS)}\u2026` : output,
1452
+ filesTouched: [],
1453
+ usage: EMPTY_USAGE,
1454
+ isSyntheticInput: true,
1455
+ isToolError: failed
1456
+ });
1457
+ }
1458
+ }
1459
+ }
1460
+ if (turns.length === 0) return null;
1461
+ return {
1462
+ id,
1463
+ agentKind: "opencode",
1464
+ repoId,
1465
+ cwd: session.directory,
1466
+ startedAt: new Date(session.time_created).toISOString(),
1467
+ endedAt: new Date(session.time_updated).toISOString(),
1468
+ turnCount: turns.length,
1469
+ aiTitle: session.title || null,
1470
+ author: null,
1471
+ sourceFile,
1472
+ redactionCount,
1473
+ committedSubjects: [...new Set(subjects)],
1474
+ parentSessionId: session.parent_id ? deriveOpenCodeSessionId(session.parent_id) : null,
1475
+ subagent: session.parent_id ? { agentId: session.id, agentType: session.agent ?? null, description: session.title || null } : null,
1476
+ branch: null,
1477
+ // The archive is the rows themselves, so a reader later has what the
1478
+ // store had — minus the reasoning parts, which OpenCode itself hides.
1479
+ rawContent: JSON.stringify({
1480
+ session,
1481
+ messages,
1482
+ parts: parts.filter((p) => !p.data.includes('"type":"reasoning"'))
1483
+ }),
1484
+ rawFormat: "json",
1485
+ turns
1486
+ };
1487
+ }
1488
+ function parseAllOpenCodeSessions(repoPath, repoId = deriveRepoId(repoPath), dbPath = opencodeDbPath()) {
1489
+ if (!existsSync3(dbPath)) return [];
1490
+ const sessions = sqliteJson(
1491
+ dbPath,
1492
+ `SELECT s.id, s.parent_id, s.directory, s.title, s.agent, s.model, s.time_created, s.time_updated, p.worktree
1493
+ FROM session s LEFT JOIN project p ON p.id = s.project_id
1494
+ WHERE s.time_archived IS NULL`
1495
+ ).filter((s) => workspaceMatchesRepo(s.directory, repoPath) || workspaceMatchesRepo(s.worktree, repoPath));
1496
+ const out = [];
1497
+ for (const s of sessions) {
1498
+ const messages = sqliteJson(
1499
+ dbPath,
1500
+ `SELECT id, session_id, time_created, data FROM message WHERE session_id = ${q(s.id)} ORDER BY time_created, id`
1501
+ );
1502
+ const parts = sqliteJson(
1503
+ dbPath,
1504
+ `SELECT id, message_id, time_created, data FROM part WHERE session_id = ${q(s.id)} ORDER BY time_created, id`
1505
+ );
1506
+ const parsed = buildOpenCodeSession(s, messages, parts, repoId, `${dbPath}#${s.id}`);
1507
+ if (parsed) out.push(parsed);
1508
+ }
1509
+ return out;
1510
+ }
1511
+ var MAX_OUTPUT_CHARS;
1512
+ var init_opencode_sessions = __esm({
1513
+ "../../packages/ingest-core/src/opencode-sessions.ts"() {
1514
+ "use strict";
1515
+ init_claude_sessions();
1516
+ init_cursor_sessions();
1517
+ init_derive_uuid();
1518
+ init_git_history();
1519
+ init_redact();
1520
+ init_subject_linking();
1521
+ init_types();
1522
+ MAX_OUTPUT_CHARS = 2e3;
1523
+ }
1524
+ });
1525
+
1526
+ // ../../packages/ingest-core/src/copilot-sessions.ts
1527
+ import { existsSync as existsSync4, readFileSync as readFileSync2, readdirSync as readdirSync2, statSync as statSync3 } from "node:fs";
1528
+ import { homedir as homedir4 } from "node:os";
1529
+ import { join as join4 } from "node:path";
1530
+ function copilotSessionsDir(home = homedir4()) {
1531
+ return join4(home, ".copilot", "session-state");
1532
+ }
1533
+ function parseCopilotWorkspace(text) {
1534
+ const map = /* @__PURE__ */ new Map();
1535
+ for (const line of text.split("\n")) {
1536
+ const m = /^([a-z_]+):\s*(.*)$/.exec(line);
1537
+ if (!m) continue;
1538
+ let value = m[2].trim();
1539
+ if (value.length >= 2 && value.startsWith("'") && value.endsWith("'")) {
1540
+ value = value.slice(1, -1).replace(/''/g, "'");
1541
+ } else if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
1542
+ try {
1543
+ value = JSON.parse(value);
1544
+ } catch {
1545
+ value = value.slice(1, -1);
1546
+ }
1547
+ }
1548
+ map.set(m[1], value);
1549
+ }
1550
+ const get2 = (k) => {
1551
+ const v = map.get(k);
1552
+ return v === void 0 || v === "" || v === "null" ? null : v;
1553
+ };
1554
+ return {
1555
+ id: get2("id"),
1556
+ cwd: get2("cwd"),
1557
+ gitRoot: get2("git_root"),
1558
+ branch: get2("branch"),
1559
+ name: get2("name"),
1560
+ createdAt: get2("created_at"),
1561
+ updatedAt: get2("updated_at")
1562
+ };
1563
+ }
1564
+ function findCopilotSessionDirs(root = copilotSessionsDir()) {
1565
+ if (!existsSync4(root)) return [];
1566
+ return readdirSync2(root).map((name) => join4(root, name)).filter((dir) => {
1567
+ try {
1568
+ return statSync3(dir).isDirectory() && existsSync4(join4(dir, "events.jsonl"));
1569
+ } catch {
1570
+ return false;
1571
+ }
1572
+ }).sort((a, b) => statSync3(b).mtimeMs - statSync3(a).mtimeMs);
1573
+ }
1574
+ function deriveTurnId2(key) {
1575
+ return deriveUuid(`evrex-copilot-turn ${key}`);
1576
+ }
1577
+ function filesFromTool3(tool, args) {
1578
+ const out = [];
1579
+ for (const key of ["path", "filePath", "file"]) {
1580
+ const v = args[key];
1581
+ if (typeof v === "string" && v.length > 0) out.push({ path: v, source: "tool_path", tool });
1582
+ }
1583
+ if (tool === "bash" && typeof args.command === "string") {
1584
+ for (const p of extractPathsFromText(args.command)) out.push({ path: p, source: "tool_bash", tool });
1585
+ }
1586
+ return out;
1587
+ }
1588
+ function shutdownUsage(data, model) {
1589
+ const details = data.tokenDetails;
1590
+ if (!details) return { ...EMPTY_USAGE, model };
1591
+ const n = (k) => typeof details[k]?.tokenCount === "number" ? details[k].tokenCount : null;
1592
+ return {
1593
+ inputTokens: n("input"),
1594
+ outputTokens: n("output"),
1595
+ cacheReadTokens: n("cache_read"),
1596
+ cacheWriteTokens: n("cache_write"),
1597
+ model
1598
+ };
1599
+ }
1600
+ function buildCopilotSession(eventsText, workspace, repoId, sourceFile) {
1601
+ const events = [];
1602
+ const archived = [];
1603
+ for (const line of eventsText.split("\n")) {
1604
+ if (!line.trim()) continue;
1605
+ let e;
1606
+ try {
1607
+ e = JSON.parse(line);
1608
+ } catch {
1609
+ continue;
1610
+ }
1611
+ events.push(e);
1612
+ if (e.data && Object.keys(e.data).some((k) => REASONING_KEYS.has(k))) {
1613
+ const data = Object.fromEntries(Object.entries(e.data).filter(([k]) => !REASONING_KEYS.has(k)));
1614
+ archived.push(JSON.stringify({ ...e, data }));
1615
+ } else {
1616
+ archived.push(line);
1617
+ }
1618
+ }
1619
+ const start = events.find((e) => e.type === "session.start");
1620
+ const startData = start?.data ?? {};
1621
+ const sessionId = workspace.id ?? startData.sessionId ?? null;
1622
+ if (!sessionId) return null;
1623
+ const cwd = workspace.cwd ?? startData.context?.cwd ?? null;
1624
+ if (!cwd) return null;
1625
+ const turns = [];
1626
+ let redactionCount = 0;
1627
+ let previous = null;
1628
+ const subjects = [];
1629
+ let lastModel = null;
1630
+ const push = (turn) => {
1631
+ const redacted = redactSecrets(turn.text);
1632
+ redactionCount += redacted.count;
1633
+ turns.push({
1634
+ ...turn,
1635
+ text: redacted.text,
1636
+ sessionId,
1637
+ parentUuid: previous,
1638
+ isSidechain: false,
1639
+ redacted: redacted.count > 0,
1640
+ editedLines: []
1641
+ });
1642
+ previous = turn.id;
1643
+ };
1644
+ const toolNames = /* @__PURE__ */ new Map();
1645
+ for (const e of events) {
1646
+ const d = e.data ?? {};
1647
+ const ts = e.timestamp ?? (/* @__PURE__ */ new Date(0)).toISOString();
1648
+ const id = e.id ?? deriveTurnId2(`${sessionId}:${turns.length}`);
1649
+ switch (e.type) {
1650
+ case "user.message": {
1651
+ const text = typeof d.content === "string" ? d.content.trim() : "";
1652
+ if (!text) break;
1653
+ push({
1654
+ id,
1655
+ role: "user",
1656
+ ts,
1657
+ text,
1658
+ filesTouched: extractPathsFromText(text).map((path) => ({ path, source: "prose" })),
1659
+ usage: EMPTY_USAGE,
1660
+ isSyntheticInput: false,
1661
+ isToolError: false
1662
+ });
1663
+ break;
1664
+ }
1665
+ case "assistant.message": {
1666
+ if (typeof d.model === "string") lastModel = d.model;
1667
+ const text = typeof d.content === "string" ? d.content.trim() : "";
1668
+ if (!text) break;
1669
+ push({ id, role: "assistant", ts, text, filesTouched: [], usage: EMPTY_USAGE, isSyntheticInput: false, isToolError: false });
1670
+ break;
1671
+ }
1672
+ case "tool.execution_start": {
1673
+ const tool = typeof d.toolName === "string" ? d.toolName : "tool";
1674
+ const callId = typeof d.toolCallId === "string" ? d.toolCallId : id;
1675
+ const args = d.arguments && typeof d.arguments === "object" ? d.arguments : {};
1676
+ toolNames.set(callId, tool);
1677
+ if (tool === "bash" && typeof args.command === "string") subjects.push(...committedSubjects(args.command));
1678
+ const rendered = JSON.stringify(args);
1679
+ push({
1680
+ id: deriveTurnId2(`${callId}:call`),
1681
+ role: "assistant",
1682
+ ts,
1683
+ text: `[tool: ${tool}] ${rendered.length > 600 ? `${rendered.slice(0, 600)}\u2026` : rendered}`,
1684
+ filesTouched: filesFromTool3(tool, args),
1685
+ usage: EMPTY_USAGE,
1686
+ isSyntheticInput: false,
1687
+ isToolError: false
1688
+ });
1689
+ break;
1690
+ }
1691
+ case "tool.execution_complete": {
1692
+ const callId = typeof d.toolCallId === "string" ? d.toolCallId : id;
1693
+ const failed = d.success === false;
1694
+ const result = d.result ?? {};
1695
+ const error = d.error ?? {};
1696
+ const output = (failed ? error.message ?? result.content ?? "Tool call failed" : result.content ?? "").trim();
1697
+ push({
1698
+ id: deriveTurnId2(`${callId}:result`),
1699
+ role: "user",
1700
+ ts,
1701
+ text: output.length > MAX_OUTPUT_CHARS2 ? `${output.slice(0, MAX_OUTPUT_CHARS2)}\u2026` : output,
1702
+ filesTouched: [],
1703
+ usage: EMPTY_USAGE,
1704
+ isSyntheticInput: true,
1705
+ isToolError: failed
1706
+ });
1707
+ break;
1708
+ }
1709
+ case "session.shutdown": {
1710
+ const usage = shutdownUsage(d, typeof d.currentModel === "string" ? d.currentModel : lastModel);
1711
+ for (let i = turns.length - 1; i >= 0; i--) {
1712
+ if (turns[i].role === "assistant") {
1713
+ turns[i].usage = usage;
1714
+ break;
1715
+ }
1716
+ }
1717
+ break;
1718
+ }
1719
+ default:
1720
+ break;
1721
+ }
1722
+ }
1723
+ if (turns.length === 0) return null;
1724
+ const first = turns[0].ts;
1725
+ const last = turns[turns.length - 1].ts;
1726
+ return {
1727
+ id: sessionId,
1728
+ agentKind: "copilot",
1729
+ repoId,
1730
+ cwd,
1731
+ startedAt: workspace.createdAt ?? startData.startTime ?? first,
1732
+ endedAt: events[events.length - 1]?.timestamp ?? workspace.updatedAt ?? last,
1733
+ turnCount: turns.length,
1734
+ aiTitle: workspace.name,
1735
+ author: null,
1736
+ sourceFile,
1737
+ redactionCount,
1738
+ committedSubjects: [...new Set(subjects)],
1739
+ parentSessionId: null,
1740
+ subagent: null,
1741
+ branch: workspace.branch,
1742
+ rawContent: archived.join("\n"),
1743
+ rawFormat: "jsonl",
1744
+ turns
1745
+ };
1746
+ }
1747
+ function parseCopilotSessionDir(dir, repoId) {
1748
+ const eventsPath = join4(dir, "events.jsonl");
1749
+ if (!existsSync4(eventsPath)) return null;
1750
+ const workspacePath = join4(dir, "workspace.yaml");
1751
+ const workspace = existsSync4(workspacePath) ? parseCopilotWorkspace(readFileSync2(workspacePath, "utf-8")) : parseCopilotWorkspace("");
1752
+ return buildCopilotSession(readFileSync2(eventsPath, "utf-8"), workspace, repoId, eventsPath);
1753
+ }
1754
+ function parseAllCopilotSessions(repoPath, repoId = deriveRepoId(repoPath), root = copilotSessionsDir()) {
1755
+ const out = [];
1756
+ for (const dir of findCopilotSessionDirs(root)) {
1757
+ const workspacePath = join4(dir, "workspace.yaml");
1758
+ const workspace = existsSync4(workspacePath) ? parseCopilotWorkspace(readFileSync2(workspacePath, "utf-8")) : parseCopilotWorkspace("");
1759
+ const known = workspace.gitRoot ?? workspace.cwd;
1760
+ if (known && !workspaceMatchesRepo(known, repoPath)) continue;
1761
+ const parsed = buildCopilotSession(readFileSync2(join4(dir, "events.jsonl"), "utf-8"), workspace, repoId, join4(dir, "events.jsonl"));
1762
+ if (!parsed) continue;
1763
+ if (!known && !workspaceMatchesRepo(parsed.cwd, repoPath)) continue;
1764
+ out.push(parsed);
1765
+ }
1766
+ return out;
1767
+ }
1768
+ var MAX_OUTPUT_CHARS2, REASONING_KEYS;
1769
+ var init_copilot_sessions = __esm({
1770
+ "../../packages/ingest-core/src/copilot-sessions.ts"() {
1771
+ "use strict";
1772
+ init_claude_sessions();
1773
+ init_cursor_sessions();
1774
+ init_derive_uuid();
1775
+ init_git_history();
1776
+ init_redact();
1777
+ init_subject_linking();
1778
+ init_types();
1779
+ MAX_OUTPUT_CHARS2 = 2e3;
1780
+ REASONING_KEYS = /* @__PURE__ */ new Set(["reasoningOpaque", "reasoningText", "reasoningBlocks"]);
1781
+ }
1782
+ });
1783
+
1784
+ // ../../packages/ingest-core/src/codex-sessions.ts
1785
+ import { createHash as createHash2 } from "node:crypto";
1786
+ import { existsSync as existsSync5, readFileSync as readFileSync3, readdirSync as readdirSync3, statSync as statSync4 } from "node:fs";
1787
+ import { homedir as homedir5 } from "node:os";
1788
+ import { join as join5 } from "node:path";
1224
1789
  function codexSessionsDir() {
1225
- return join3(homedir3(), CODEX_DIR, "sessions");
1790
+ return join5(homedir5(), CODEX_DIR, "sessions");
1226
1791
  }
1227
1792
  function deriveUuid2(name) {
1228
1793
  const h = createHash2("sha1").update(name).digest("hex");
@@ -1238,15 +1803,15 @@ function findCodexSessionFiles(root = codexSessionsDir()) {
1238
1803
  const walk = (dir) => {
1239
1804
  let entries;
1240
1805
  try {
1241
- entries = readdirSync2(dir);
1806
+ entries = readdirSync3(dir);
1242
1807
  } catch {
1243
1808
  return;
1244
1809
  }
1245
1810
  for (const entry of entries) {
1246
- const full = join3(dir, entry);
1811
+ const full = join5(dir, entry);
1247
1812
  let isDir = false;
1248
1813
  try {
1249
- isDir = statSync3(full).isDirectory();
1814
+ isDir = statSync4(full).isDirectory();
1250
1815
  } catch {
1251
1816
  continue;
1252
1817
  }
@@ -1254,7 +1819,7 @@ function findCodexSessionFiles(root = codexSessionsDir()) {
1254
1819
  else if (entry.endsWith(".jsonl")) found.push(full);
1255
1820
  }
1256
1821
  };
1257
- if (existsSync3(root)) walk(root);
1822
+ if (existsSync5(root)) walk(root);
1258
1823
  return found.sort();
1259
1824
  }
1260
1825
  function parseLines(raw) {
@@ -1359,7 +1924,7 @@ function filesFromToolCalls(lines) {
1359
1924
  function parseCodexSessionFile(filePath, repoPath, repoId) {
1360
1925
  let raw;
1361
1926
  try {
1362
- raw = readFileSync2(filePath, "utf-8");
1927
+ raw = readFileSync3(filePath, "utf-8");
1363
1928
  } catch {
1364
1929
  return null;
1365
1930
  }
@@ -1392,6 +1957,7 @@ function parseCodexSessionFile(filePath, repoPath, repoId) {
1392
1957
  isSidechain: false,
1393
1958
  redacted: count > 0,
1394
1959
  isSyntheticInput: false,
1960
+ isToolError: false,
1395
1961
  usage: event.usage
1396
1962
  };
1397
1963
  });
@@ -1401,6 +1967,9 @@ function parseCodexSessionFile(filePath, repoPath, repoId) {
1401
1967
  return {
1402
1968
  id: sessionId,
1403
1969
  agentKind: "codex",
1970
+ branch: null,
1971
+ parentSessionId: null,
1972
+ subagent: null,
1404
1973
  // Codex records the remote itself, so identity survives a moved or deleted
1405
1974
  // checkout. Falls back to the caller's derivation when it is absent.
1406
1975
  repoId: repoIdFromMeta(meta) ?? repoId,
@@ -1419,9 +1988,9 @@ function parseCodexSessionFile(filePath, repoPath, repoId) {
1419
1988
  };
1420
1989
  }
1421
1990
  function repoIdFromMeta(meta) {
1422
- const git2 = meta.git;
1423
- if (typeof git2 !== "object" || git2 === null) return null;
1424
- const url = asString(git2.repository_url);
1991
+ const git3 = meta.git;
1992
+ if (typeof git3 !== "object" || git3 === null) return null;
1993
+ const url = asString(git3.repository_url);
1425
1994
  if (!url) return null;
1426
1995
  const normalized = normalizeRepoRemote(url);
1427
1996
  return normalized ? `remote:${normalized}` : null;
@@ -1444,9 +2013,20 @@ function parseAllCodexSessions(repoPath, repoId) {
1444
2013
  }
1445
2014
  return sessions;
1446
2015
  }
2016
+ var CODEX_DIR;
2017
+ var init_codex_sessions = __esm({
2018
+ "../../packages/ingest-core/src/codex-sessions.ts"() {
2019
+ "use strict";
2020
+ init_claude_sessions();
2021
+ init_git_history();
2022
+ init_redact();
2023
+ init_subject_linking();
2024
+ init_types();
2025
+ CODEX_DIR = ".codex";
2026
+ }
2027
+ });
1447
2028
 
1448
2029
  // ../../packages/ingest-core/src/sanitize.ts
1449
- var NUL = String.fromCharCode(0);
1450
2030
  function stripNulls(value) {
1451
2031
  return value.includes(NUL) ? value.split(NUL).join("") : value;
1452
2032
  }
@@ -1463,8 +2043,991 @@ function stripNullsDeep(value) {
1463
2043
  }
1464
2044
  return value;
1465
2045
  }
1466
-
2046
+ var NUL;
2047
+ var init_sanitize = __esm({
2048
+ "../../packages/ingest-core/src/sanitize.ts"() {
2049
+ "use strict";
2050
+ NUL = String.fromCharCode(0);
2051
+ }
2052
+ });
2053
+
2054
+ // ../../packages/ingest-core/src/tickets.ts
2055
+ function deriveTicketId(args) {
2056
+ return deriveUuid(
2057
+ `evrex-ticket ${args.orgId} ${args.kind} ${args.workspace} ${args.externalId}`
2058
+ );
2059
+ }
2060
+ function ticketReferencesIn(text) {
2061
+ return [...new Set([...text.matchAll(TICKET_REFERENCE)].map((m) => m[0]))];
2062
+ }
2063
+ var TICKET_REFERENCE;
2064
+ var init_tickets = __esm({
2065
+ "../../packages/ingest-core/src/tickets.ts"() {
2066
+ "use strict";
2067
+ init_derive_uuid();
2068
+ TICKET_REFERENCE = /(?<![A-Za-z0-9_-])([A-Z][A-Z0-9]{1,9})-(\d{1,6})(?![A-Za-z0-9_-])/g;
2069
+ }
2070
+ });
2071
+
2072
+ // ../../packages/ingest-core/src/linear.ts
2073
+ function linearIssueToTicket(issue) {
2074
+ if (!issue.identifier) return null;
2075
+ const attributes = {
2076
+ title: issue.title ?? issue.identifier,
2077
+ status: issue.state?.name ?? null,
2078
+ statusCategory: issue.state?.type ? STATE_TYPE[issue.state.type] ?? null : null,
2079
+ assignee: issue.assignee?.displayName ?? null,
2080
+ assigneeId: issue.assignee?.id ?? null,
2081
+ team: issue.team?.key ?? null,
2082
+ project: issue.project?.name ?? null,
2083
+ // Linear has one kind of issue; the field exists so a Jira row and a
2084
+ // Linear row are the same shape, which is what U5 exists to prove.
2085
+ issueType: null,
2086
+ // Null rather than "No priority" when the field is absent: unset and
2087
+ // explicitly-not-prioritised are different claims.
2088
+ priority: typeof issue.priority === "number" ? PRIORITY[issue.priority] ?? null : null,
2089
+ // Linear records completion as a state, not as a separate resolution.
2090
+ resolution: null
2091
+ };
2092
+ return {
2093
+ externalId: issue.identifier,
2094
+ kind: "linear",
2095
+ url: issue.url ?? `https://linear.app/issue/${issue.identifier}`,
2096
+ createdAt: issue.createdAt ?? null,
2097
+ updatedAt: issue.updatedAt ?? null,
2098
+ attributes
2099
+ };
2100
+ }
2101
+ function linearAuthHeader(auth) {
2102
+ if (auth.accessToken) return `Bearer ${auth.accessToken}`;
2103
+ if (auth.apiKey) return auth.apiKey;
2104
+ throw new Error(
2105
+ "Linear needs either an API key or an OAuth access token; got neither."
2106
+ );
2107
+ }
2108
+ async function fetchLinearIssues(options) {
2109
+ const call2 = options.fetch ?? globalThis.fetch;
2110
+ const pageSize = options.pageSize ?? 50;
2111
+ const limit = options.limit ?? Infinity;
2112
+ const tickets = [];
2113
+ let after = null;
2114
+ while (tickets.length < limit) {
2115
+ const response = await call2(LINEAR_GRAPHQL_URL, {
2116
+ method: "POST",
2117
+ headers: {
2118
+ Authorization: linearAuthHeader(options.auth),
2119
+ "Content-Type": "application/json"
2120
+ },
2121
+ body: JSON.stringify({
2122
+ query: LINEAR_ISSUES_QUERY,
2123
+ variables: {
2124
+ first: Math.min(pageSize, limit - tickets.length),
2125
+ after
2126
+ }
2127
+ })
2128
+ });
2129
+ if (!response.ok) {
2130
+ throw new Error(
2131
+ `Linear returned ${response.status} ${response.statusText}`
2132
+ );
2133
+ }
2134
+ const body = await response.json();
2135
+ if (body.errors?.length) {
2136
+ throw new Error(
2137
+ `Linear rejected the query: ${body.errors.map((e) => e.message).join("; ")}`
2138
+ );
2139
+ }
2140
+ const page = body.data?.issues;
2141
+ for (const node of page?.nodes ?? []) {
2142
+ const ticket = linearIssueToTicket(node);
2143
+ if (ticket) tickets.push(ticket);
2144
+ }
2145
+ if (!page?.pageInfo?.hasNextPage || !page.pageInfo.endCursor) break;
2146
+ after = page.pageInfo.endCursor;
2147
+ }
2148
+ return tickets;
2149
+ }
2150
+ var LINEAR_GRAPHQL_URL, LINEAR_AUTHORIZE_URL, LINEAR_TOKEN_URL, LINEAR_READ_SCOPE, PRIORITY, STATE_TYPE, LINEAR_ISSUES_QUERY;
2151
+ var init_linear = __esm({
2152
+ "../../packages/ingest-core/src/linear.ts"() {
2153
+ "use strict";
2154
+ LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql";
2155
+ LINEAR_AUTHORIZE_URL = "https://linear.app/oauth/authorize";
2156
+ LINEAR_TOKEN_URL = "https://api.linear.app/oauth/token";
2157
+ LINEAR_READ_SCOPE = "read";
2158
+ PRIORITY = {
2159
+ 0: "No priority",
2160
+ 1: "Urgent",
2161
+ 2: "High",
2162
+ 3: "Medium",
2163
+ 4: "Low"
2164
+ };
2165
+ STATE_TYPE = {
2166
+ triage: "triage",
2167
+ backlog: "backlog",
2168
+ unstarted: "todo",
2169
+ started: "in-progress",
2170
+ completed: "done",
2171
+ canceled: "cancelled"
2172
+ };
2173
+ LINEAR_ISSUES_QUERY = `
2174
+ query EvrexIssues($first: Int!, $after: String) {
2175
+ issues(first: $first, after: $after, orderBy: updatedAt) {
2176
+ nodes {
2177
+ identifier
2178
+ title
2179
+ url
2180
+ priority
2181
+ createdAt
2182
+ updatedAt
2183
+ state { name type }
2184
+ assignee { id displayName }
2185
+ team { key name }
2186
+ project { name }
2187
+ }
2188
+ pageInfo { hasNextPage endCursor }
2189
+ }
2190
+ }
2191
+ `;
2192
+ }
2193
+ });
2194
+
2195
+ // ../../packages/ingest-core/src/jira.ts
2196
+ function jiraIssueToTicket(issue, siteUrl) {
2197
+ if (!issue.key) return null;
2198
+ const f = issue.fields ?? {};
2199
+ const attributes = {
2200
+ title: f.summary ?? issue.key,
2201
+ status: f.status?.name ?? null,
2202
+ statusCategory: f.status?.statusCategory?.key ? STATUS_CATEGORY[f.status.statusCategory.key] ?? null : null,
2203
+ // Null when the assignee's privacy settings withhold it, which is not the
2204
+ // same claim as an unassigned ticket — `assigneeId` distinguishes them.
2205
+ assignee: f.assignee?.displayName ?? null,
2206
+ assigneeId: f.assignee?.accountId ?? null,
2207
+ // Jira has no team on an issue; the project is the closest equivalent and
2208
+ // is reported as itself rather than smuggled into `team`.
2209
+ team: null,
2210
+ project: f.project?.key ?? null,
2211
+ issueType: f.issuetype?.name ?? null,
2212
+ priority: f.priority?.name ?? null,
2213
+ resolution: f.resolution?.name ?? null
2214
+ };
2215
+ return {
2216
+ externalId: issue.key,
2217
+ kind: "jira",
2218
+ url: `${siteUrl.replace(/\/+$/, "")}/browse/${issue.key}`,
2219
+ createdAt: f.created ?? null,
2220
+ updatedAt: f.updated ?? null,
2221
+ attributes
2222
+ };
2223
+ }
2224
+ function jiraBasicAuth(email, apiToken) {
2225
+ return `Basic ${Buffer.from(`${email}:${apiToken}`).toString("base64")}`;
2226
+ }
2227
+ async function fetchJiraIssues(options) {
2228
+ const call2 = options.fetch ?? globalThis.fetch;
2229
+ const site = options.siteUrl.replace(/\/+$/, "");
2230
+ const pageSize = options.pageSize ?? 100;
2231
+ const limit = options.limit ?? Infinity;
2232
+ const jql = options.jql ?? "ORDER BY updated DESC";
2233
+ const tickets = [];
2234
+ let nextPageToken = null;
2235
+ while (tickets.length < limit) {
2236
+ const response = await call2(`${site}${JIRA_SEARCH_PATH}`, {
2237
+ method: "POST",
2238
+ headers: {
2239
+ Authorization: jiraBasicAuth(options.email, options.apiToken),
2240
+ "Content-Type": "application/json",
2241
+ Accept: "application/json"
2242
+ },
2243
+ body: JSON.stringify({
2244
+ jql,
2245
+ fields: [...JIRA_FIELDS],
2246
+ maxResults: Math.min(pageSize, limit - tickets.length),
2247
+ ...nextPageToken ? { nextPageToken } : {}
2248
+ })
2249
+ });
2250
+ if (!response.ok) {
2251
+ const hint = response.status === 410 ? ` \u2014 that is what the removed /rest/api/3/search returns; this client uses ${JIRA_SEARCH_PATH}` : "";
2252
+ throw new Error(
2253
+ `Jira returned ${response.status} ${response.statusText}${hint}`
2254
+ );
2255
+ }
2256
+ const body = await response.json();
2257
+ for (const issue of body.issues ?? []) {
2258
+ const ticket = jiraIssueToTicket(issue, site);
2259
+ if (ticket) tickets.push(ticket);
2260
+ }
2261
+ if (!body.nextPageToken) break;
2262
+ nextPageToken = body.nextPageToken;
2263
+ }
2264
+ return tickets;
2265
+ }
2266
+ var JIRA_SEARCH_PATH, JIRA_FIELDS, STATUS_CATEGORY;
2267
+ var init_jira = __esm({
2268
+ "../../packages/ingest-core/src/jira.ts"() {
2269
+ "use strict";
2270
+ JIRA_SEARCH_PATH = "/rest/api/3/search/jql";
2271
+ JIRA_FIELDS = [
2272
+ "summary",
2273
+ "status",
2274
+ "assignee",
2275
+ "resolution",
2276
+ "created",
2277
+ "updated",
2278
+ "project",
2279
+ "issuetype",
2280
+ "priority"
2281
+ ];
2282
+ STATUS_CATEGORY = {
2283
+ new: "todo",
2284
+ indeterminate: "in-progress",
2285
+ done: "done"
2286
+ };
2287
+ }
2288
+ });
2289
+
2290
+ // ../../packages/ingest-core/src/diff-parser.ts
2291
+ function parseUnifiedDiffHunks(diffText) {
2292
+ const hunks = [];
2293
+ let current = null;
2294
+ for (const line of diffText.split("\n")) {
2295
+ if (HUNK_HEADER_RE.test(line)) {
2296
+ current = { header: line, lines: [] };
2297
+ hunks.push(current);
2298
+ continue;
2299
+ }
2300
+ if (!current) continue;
2301
+ if (line.startsWith("+++") || line.startsWith("---")) continue;
2302
+ if (line.startsWith("+")) {
2303
+ current.lines.push({ kind: "add", text: line.slice(1) });
2304
+ } else if (line.startsWith("-")) {
2305
+ current.lines.push({ kind: "del", text: line.slice(1) });
2306
+ } else if (line.startsWith(" ")) {
2307
+ current.lines.push({ kind: "ctx", text: line.slice(1) });
2308
+ }
2309
+ }
2310
+ return hunks;
2311
+ }
2312
+ var HUNK_HEADER_RE;
2313
+ var init_diff_parser = __esm({
2314
+ "../../packages/ingest-core/src/diff-parser.ts"() {
2315
+ "use strict";
2316
+ HUNK_HEADER_RE = /^@@ .+? @@.*$/;
2317
+ }
2318
+ });
2319
+
2320
+ // ../../packages/ingest-core/src/trace-lines.ts
2321
+ import { execFileSync as execFileSync3 } from "node:child_process";
2322
+ function isHunkHeader(line) {
2323
+ return line.startsWith("@@");
2324
+ }
2325
+ function parseLineLog(raw) {
2326
+ const commits = [];
2327
+ let current = null;
2328
+ let hunk = null;
2329
+ const closeHunk = () => {
2330
+ if (current && hunk) current.hunks.push(hunk);
2331
+ hunk = null;
2332
+ };
2333
+ for (const line of raw.split("\n")) {
2334
+ if (line.startsWith(COMMIT_MARK)) {
2335
+ closeHunk();
2336
+ const [sha, at, author, ...rest] = line.slice(COMMIT_MARK.length).split(FIELD_SEP2);
2337
+ if (!sha || !at) {
2338
+ current = null;
2339
+ continue;
2340
+ }
2341
+ current = {
2342
+ sha,
2343
+ at,
2344
+ author: author ?? "",
2345
+ subject: rest.join(FIELD_SEP2),
2346
+ hunks: [],
2347
+ createdFile: false
2348
+ };
2349
+ commits.push(current);
2350
+ continue;
2351
+ }
2352
+ if (!current) continue;
2353
+ if (line.startsWith("--- ")) {
2354
+ if (line.trim() === "--- /dev/null") current.createdFile = true;
2355
+ continue;
2356
+ }
2357
+ if (line.startsWith("+++ ") || line.startsWith("diff --git ")) continue;
2358
+ if (isHunkHeader(line)) {
2359
+ closeHunk();
2360
+ hunk = { header: line, lines: [] };
2361
+ continue;
2362
+ }
2363
+ if (hunk && (line.startsWith(" ") || line.startsWith("+") || line.startsWith("-"))) {
2364
+ hunk.lines.push(line);
2365
+ }
2366
+ }
2367
+ closeHunk();
2368
+ return commits;
2369
+ }
2370
+ function detectTruncatedHistory(commits, repoPath) {
2371
+ const oldest = commits[commits.length - 1];
2372
+ if (!oldest) return false;
2373
+ const added = oldest.hunks.flatMap((h) => h.lines).filter((l) => l.startsWith("+")).map((l) => l.slice(1).trim()).filter((l) => l.length >= MOVED_LINE_MIN_LENGTH);
2374
+ const body = oldest.hunks.flatMap((h) => h.lines);
2375
+ if (body.length === 0 || !body.every((l) => l.startsWith("+"))) return false;
2376
+ if (added.length === 0 || !repoPath) return false;
2377
+ const removedElsewhere = deletedLinesElsewhere(repoPath, oldest.sha);
2378
+ if (removedElsewhere.size === 0) return false;
2379
+ const matched = added.filter((l) => removedElsewhere.has(l)).length;
2380
+ return matched / added.length >= MOVED_LINE_FRACTION;
2381
+ }
2382
+ function deletedLinesElsewhere(repoPath, sha) {
2383
+ const out = /* @__PURE__ */ new Set();
2384
+ let raw;
2385
+ try {
2386
+ raw = execFileSync3(
2387
+ "git",
2388
+ [
2389
+ "show",
2390
+ "--format=",
2391
+ "--unified=0",
2392
+ "--no-color",
2393
+ // Without this git collapses a delete-plus-identical-add into
2394
+ // "rename from/to" with no line content at all — so the very case
2395
+ // this function exists to detect would produce nothing to match.
2396
+ "--no-renames",
2397
+ sha
2398
+ ],
2399
+ {
2400
+ cwd: repoPath,
2401
+ maxBuffer: 1024 * 1024 * 64,
2402
+ stdio: ["ignore", "pipe", "ignore"]
2403
+ }
2404
+ ).toString("utf-8");
2405
+ } catch {
2406
+ return out;
2407
+ }
2408
+ for (const line of raw.split("\n")) {
2409
+ if (!line.startsWith("-") || line.startsWith("---")) continue;
2410
+ const text = line.slice(1).trim();
2411
+ if (text.length >= MOVED_LINE_MIN_LENGTH) out.add(text);
2412
+ }
2413
+ return out;
2414
+ }
2415
+ function committedLineCount(repoPath, file, rev = "HEAD") {
2416
+ try {
2417
+ const out = execFileSync3("git", ["show", `${rev}:${file}`], {
2418
+ cwd: repoPath,
2419
+ maxBuffer: 1024 * 1024 * 64,
2420
+ stdio: ["ignore", "pipe", "ignore"]
2421
+ }).toString("utf-8");
2422
+ const lines = out.split("\n");
2423
+ const count = lines[lines.length - 1] === "" ? lines.length - 1 : lines.length;
2424
+ return count > 0 ? count : null;
2425
+ } catch {
2426
+ return null;
2427
+ }
2428
+ }
2429
+ function traceLines(repoPath, file, from, to, rev) {
2430
+ const start = Math.max(MIN_LINE, Math.floor(from));
2431
+ const end = to <= 0 ? committedLineCount(repoPath, file, rev) ?? Math.max(start, 1) : Math.max(start, Math.floor(to));
2432
+ let raw;
2433
+ try {
2434
+ raw = execFileSync3(
2435
+ "git",
2436
+ [
2437
+ "log",
2438
+ // argv array, never a shell string: `file` is caller-supplied and can
2439
+ // legitimately contain spaces, quotes or a leading dash.
2440
+ `-L${start},${end}:${file}`,
2441
+ `--format=${COMMIT_MARK}%H${FIELD_SEP2}%aI${FIELD_SEP2}%an${FIELD_SEP2}%s`,
2442
+ // Anchors the walk at the revision the caller's line numbers came
2443
+ // from. Omitted, git starts at HEAD, which is right for a working-copy
2444
+ // selection and wrong for one taken from an old diff.
2445
+ ...rev ? [rev] : []
2446
+ ],
2447
+ {
2448
+ cwd: repoPath,
2449
+ maxBuffer: 1024 * 1024 * 64,
2450
+ stdio: ["ignore", "pipe", "ignore"]
2451
+ }
2452
+ ).toString("utf-8");
2453
+ } catch {
2454
+ return { commits: [], historyMayBeTruncated: false };
2455
+ }
2456
+ const commits = parseLineLog(raw);
2457
+ return {
2458
+ commits,
2459
+ historyMayBeTruncated: detectTruncatedHistory(commits, repoPath)
2460
+ };
2461
+ }
2462
+ var COMMIT_MARK, FIELD_SEP2, MIN_LINE, MOVED_LINE_MIN_LENGTH, MOVED_LINE_FRACTION;
2463
+ var init_trace_lines = __esm({
2464
+ "../../packages/ingest-core/src/trace-lines.ts"() {
2465
+ "use strict";
2466
+ COMMIT_MARK = "@@EVREX-COMMIT@@";
2467
+ FIELD_SEP2 = "";
2468
+ MIN_LINE = 1;
2469
+ MOVED_LINE_MIN_LENGTH = 6;
2470
+ MOVED_LINE_FRACTION = 0.5;
2471
+ }
2472
+ });
2473
+
2474
+ // ../../packages/ingest-core/src/trace-target.ts
2475
+ function parseTraceTarget(raw) {
2476
+ const trimmed = raw.trim();
2477
+ if (!trimmed) return null;
2478
+ const match = /^(.*?):(\d+)(?:\s*[-\u2013:]\s*(\d+))?$/.exec(trimmed);
2479
+ if (!match) return null;
2480
+ const [, file, fromText, toText] = match;
2481
+ if (!file) return null;
2482
+ const from = Number(fromText);
2483
+ const to = toText ? Number(toText) : from;
2484
+ if (!Number.isFinite(from) || from < 1) return null;
2485
+ if (!Number.isFinite(to) || to < 1) return null;
2486
+ return { file, from: Math.min(from, to), to: Math.max(from, to) };
2487
+ }
2488
+ var init_trace_target = __esm({
2489
+ "../../packages/ingest-core/src/trace-target.ts"() {
2490
+ "use strict";
2491
+ }
2492
+ });
2493
+
2494
+ // ../../packages/ingest-core/src/gemini-sessions.ts
2495
+ import { readFileSync as readFileSync4 } from "node:fs";
2496
+ function textOf(content) {
2497
+ if (typeof content === "string") return content;
2498
+ if (!Array.isArray(content)) return "";
2499
+ return content.map(
2500
+ (part) => part && typeof part === "object" && typeof part.text === "string" ? part.text : ""
2501
+ ).filter(Boolean).join("\n");
2502
+ }
2503
+ function parseGeminiSessionFile(filePath, repoPath, repoId) {
2504
+ let raw;
2505
+ try {
2506
+ raw = readFileSync4(filePath, "utf-8");
2507
+ } catch {
2508
+ return null;
2509
+ }
2510
+ const records = [];
2511
+ for (const line of raw.split("\n")) {
2512
+ if (!line.trim()) continue;
2513
+ try {
2514
+ records.push(JSON.parse(line));
2515
+ } catch {
2516
+ }
2517
+ }
2518
+ const meta = records.find((r) => r.sessionId && r.startTime);
2519
+ if (!meta?.sessionId) return null;
2520
+ let messages = [];
2521
+ for (const record of records) {
2522
+ const next = record.$set?.messages ?? record.messages;
2523
+ if (Array.isArray(next)) messages = next;
2524
+ }
2525
+ let redactionCount = 0;
2526
+ const turns = [];
2527
+ messages.forEach((record, index) => {
2528
+ const role = record.type === "user" ? "user" : record.type === "gemini" || record.type === "model" ? "assistant" : null;
2529
+ if (!role) return;
2530
+ const text = textOf(record.content ?? record.displayContent);
2531
+ if (!text.trim()) return;
2532
+ const { text: safe, count } = redactSecrets(text);
2533
+ redactionCount += count;
2534
+ turns.push({
2535
+ // Gemini's own record id when it has one, so a re-read lands on the same
2536
+ // row; a derived id keyed on position otherwise, which is stable for an
2537
+ // append-only file.
2538
+ id: record.id ? deriveUuid(`evrex-gemini-turn ${meta.sessionId} ${record.id}`) : deriveUuid(`evrex-gemini-turn ${meta.sessionId} ${index}`),
2539
+ sessionId: deriveUuid(`evrex-gemini-session ${meta.sessionId}`),
2540
+ role,
2541
+ ts: record.timestamp ?? meta.startTime ?? (/* @__PURE__ */ new Date()).toISOString(),
2542
+ text: safe,
2543
+ filesTouched: [],
2544
+ editedLines: [],
2545
+ parentUuid: null,
2546
+ isSidechain: false,
2547
+ redacted: count > 0,
2548
+ // Gemini opens every session with a `<session_context>` block delivered
2549
+ // as a user message. Attributing that to the person is the same bug this
2550
+ // repo already fixed once for Claude Code's tool results.
2551
+ isSyntheticInput: role === "user" && SYNTHETIC.test(text),
2552
+ isToolError: false,
2553
+ // Gemini records a model per message but no token counts in the
2554
+ // transcript; unknown rather than zero.
2555
+ usage: { ...EMPTY_USAGE, model: record.model ?? null }
2556
+ });
2557
+ });
2558
+ if (turns.length === 0) return null;
2559
+ const { text: rawContent, count: rawRedactions } = redactSecrets(raw);
2560
+ const firstUser = turns.find((t) => t.role === "user" && !t.isSyntheticInput);
2561
+ return {
2562
+ id: deriveUuid(`evrex-gemini-session ${meta.sessionId}`),
2563
+ agentKind: "gemini",
2564
+ repoId,
2565
+ cwd: meta.directories?.[0] ?? repoPath,
2566
+ startedAt: meta.startTime ?? turns[0]?.ts ?? null,
2567
+ endedAt: turns[turns.length - 1]?.ts ?? null,
2568
+ turnCount: turns.length,
2569
+ committedSubjects: [],
2570
+ aiTitle: firstUser ? firstUser.text.slice(0, 120).trim() : null,
2571
+ author: null,
2572
+ sourceFile: filePath,
2573
+ redactionCount: redactionCount + rawRedactions,
2574
+ turns,
2575
+ rawContent,
2576
+ branch: null,
2577
+ parentSessionId: null,
2578
+ subagent: null,
2579
+ rawFormat: "jsonl"
2580
+ };
2581
+ }
2582
+ var SYNTHETIC;
2583
+ var init_gemini_sessions = __esm({
2584
+ "../../packages/ingest-core/src/gemini-sessions.ts"() {
2585
+ "use strict";
2586
+ init_derive_uuid();
2587
+ init_redact();
2588
+ init_types();
2589
+ SYNTHETIC = /^\s*<session_context>/;
2590
+ }
2591
+ });
2592
+
2593
+ // ../../packages/ingest-core/src/transcript-parsers.ts
2594
+ function parseTranscriptFile(path, repoPath, repoId) {
2595
+ for (const parse of PARSERS) {
2596
+ const parsed = parse(path, repoPath, repoId);
2597
+ if (parsed) return parsed;
2598
+ }
2599
+ return null;
2600
+ }
2601
+ var PARSERS;
2602
+ var init_transcript_parsers = __esm({
2603
+ "../../packages/ingest-core/src/transcript-parsers.ts"() {
2604
+ "use strict";
2605
+ init_claude_sessions();
2606
+ init_codex_sessions();
2607
+ init_gemini_sessions();
2608
+ PARSERS = [parseSessionFile, parseCodexSessionFile, parseGeminiSessionFile];
2609
+ }
2610
+ });
2611
+
2612
+ // ../../packages/ingest-core/src/slack-threads.ts
2613
+ function displayName(users, id) {
2614
+ if (!id) return "unknown";
2615
+ const user = users.get(id);
2616
+ return user?.profile?.real_name ?? user?.real_name ?? user?.profile?.display_name ?? user?.name ?? id;
2617
+ }
2618
+ function slackTimestamp(ts) {
2619
+ const seconds = Number(ts);
2620
+ if (!Number.isFinite(seconds) || seconds <= 0) return null;
2621
+ return new Date(seconds * 1e3).toISOString();
2622
+ }
2623
+ function readableText(text, users) {
2624
+ return text.replace(/<@([A-Z0-9]+)(?:\|[^>]*)?>/g, (_, id) => `@${displayName(users, id)}`).replace(/<#[A-Z0-9]+\|([^>]*)>/g, (_, name) => `#${name}`).replace(/<(https?:[^>|]+)\|([^>]*)>/g, (_, __, label) => label).replace(/<(https?:[^>]+)>/g, (_, url) => url).replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">");
2625
+ }
2626
+ function isHumanMessage(message) {
2627
+ if (message.type && message.type !== "message") return false;
2628
+ if (message.subtype && MACHINE_SUBTYPES.has(message.subtype)) return false;
2629
+ if (message.bot_id && !message.user) return false;
2630
+ return Boolean(message.text?.trim());
2631
+ }
2632
+ function groupIntoThreads(messages) {
2633
+ const threads = /* @__PURE__ */ new Map();
2634
+ for (const message of messages) {
2635
+ if (!isHumanMessage(message)) continue;
2636
+ const key = message.thread_ts ?? message.ts;
2637
+ if (!key) continue;
2638
+ threads.set(key, [...threads.get(key) ?? [], message]);
2639
+ }
2640
+ return [...threads.values()].map(
2641
+ (thread) => [...thread].sort((a, b) => Number(a.ts ?? 0) - Number(b.ts ?? 0))
2642
+ );
2643
+ }
2644
+ function parseSlackExport(messages, options) {
2645
+ const users = /* @__PURE__ */ new Map();
2646
+ for (const user of options.users ?? []) if (user.id) users.set(user.id, user);
2647
+ const out = [];
2648
+ for (const thread of groupIntoThreads(messages)) {
2649
+ const root = thread[0];
2650
+ const rootTs = root?.thread_ts ?? root?.ts;
2651
+ const startedAt = slackTimestamp(rootTs ?? void 0);
2652
+ if (!rootTs || !startedAt) continue;
2653
+ const turns = [];
2654
+ let redactionCount = 0;
2655
+ for (const message of thread) {
2656
+ const ts = slackTimestamp(message.ts);
2657
+ if (!ts) continue;
2658
+ const { text, count } = redactSecrets(
2659
+ readableText(message.text ?? "", users)
2660
+ );
2661
+ if (!text.trim()) continue;
2662
+ redactionCount += count;
2663
+ turns.push({
2664
+ // A uuid, because that is what the columns these land in are. The
2665
+ // readable `slack:<channel>:<ts>` key is kept on `sourceFile`.
2666
+ id: deriveSlackTurnId(options.channel, message.ts),
2667
+ sessionId: deriveSlackSessionId(options.channel, rootTs),
2668
+ // Everything here was typed by a person. There is no assistant side,
2669
+ // and marking any of it otherwise would let it be read as recovered
2670
+ // agent reasoning.
2671
+ role: "user",
2672
+ ts,
2673
+ text: `${displayName(users, message.user)}: ${text}`,
2674
+ filesTouched: [],
2675
+ editedLines: [],
2676
+ parentUuid: null,
2677
+ isSidechain: false,
2678
+ redacted: count > 0,
2679
+ isSyntheticInput: false,
2680
+ isToolError: false,
2681
+ // Neither source reports what a turn cost, so it is unknown rather than free.
2682
+ usage: { ...EMPTY_USAGE }
2683
+ });
2684
+ }
2685
+ const size = turns.reduce((total, turn) => total + turn.text.length, 0);
2686
+ if (turns.length < MIN_THREAD_MESSAGES) continue;
2687
+ const floor = turns.length === 1 ? MIN_SOLO_CHARS : MIN_THREAD_CHARS;
2688
+ if (size < floor) continue;
2689
+ out.push({
2690
+ session: {
2691
+ id: deriveSlackSessionId(options.channel, rootTs),
2692
+ agentKind: "slack",
2693
+ repoId: options.repoId,
2694
+ cwd: options.cwd,
2695
+ startedAt,
2696
+ endedAt: turns[turns.length - 1]?.ts ?? startedAt,
2697
+ turnCount: turns.length,
2698
+ // The first message is what the thread is about, near enough, and it
2699
+ // is what Slack itself shows in a thread list.
2700
+ aiTitle: (turns[0]?.text ?? "").slice(0, 120),
2701
+ author: displayName(users, root?.user),
2702
+ sourceFile: `slack/${options.channel}/${rootTs}.json`,
2703
+ redactionCount,
2704
+ // The thread exactly as Slack gave it, redacted like every other
2705
+ // archived transcript: a teammate re-materialising this must not
2706
+ // receive a key the author pasted into a channel.
2707
+ // A Slack thread runs no commands; nothing here can place a commit.
2708
+ committedSubjects: [],
2709
+ rawContent: JSON.stringify(
2710
+ thread.map((message) => ({
2711
+ ...message,
2712
+ text: redactSecrets(message.text ?? "").text
2713
+ })),
2714
+ null,
2715
+ 2
2716
+ ),
2717
+ branch: null,
2718
+ parentSessionId: null,
2719
+ subagent: null,
2720
+ rawFormat: "json",
2721
+ turns
2722
+ },
2723
+ turns
2724
+ });
2725
+ }
2726
+ return out;
2727
+ }
2728
+ var MIN_THREAD_MESSAGES, MIN_THREAD_CHARS, MIN_SOLO_CHARS, MACHINE_SUBTYPES;
2729
+ var init_slack_threads = __esm({
2730
+ "../../packages/ingest-core/src/slack-threads.ts"() {
2731
+ "use strict";
2732
+ init_redact();
2733
+ init_derive_uuid();
2734
+ init_types();
2735
+ MIN_THREAD_MESSAGES = 1;
2736
+ MIN_THREAD_CHARS = 80;
2737
+ MIN_SOLO_CHARS = 200;
2738
+ MACHINE_SUBTYPES = /* @__PURE__ */ new Set([
2739
+ "channel_join",
2740
+ "channel_leave",
2741
+ "channel_topic",
2742
+ "channel_purpose",
2743
+ "channel_name",
2744
+ "channel_archive",
2745
+ "channel_unarchive",
2746
+ "bot_message",
2747
+ "thread_broadcast_join"
2748
+ ]);
2749
+ }
2750
+ });
2751
+
2752
+ // ../../packages/ingest-core/src/slack-client.ts
2753
+ async function call(method, params, options, attempt = 0) {
2754
+ const doFetch = options.fetchImpl ?? fetch;
2755
+ const sleep = options.sleep ?? wait;
2756
+ const query = new URLSearchParams(params).toString();
2757
+ const response = options.post ? await doFetch(`${API}/${method}`, {
2758
+ method: "POST",
2759
+ headers: {
2760
+ Authorization: `Bearer ${options.token}`,
2761
+ "Content-Type": "application/x-www-form-urlencoded"
2762
+ },
2763
+ body: query
2764
+ }) : await doFetch(`${API}/${method}?${query}`, {
2765
+ headers: { Authorization: `Bearer ${options.token}` }
2766
+ });
2767
+ if (response.status === 429 && attempt < 5) {
2768
+ const header = response.headers.get("retry-after");
2769
+ const seconds = header ? Number(header) : NaN;
2770
+ await sleep((Number.isFinite(seconds) ? seconds : 30) * 1e3);
2771
+ return call(method, params, options, attempt + 1);
2772
+ }
2773
+ const body = await response.json();
2774
+ if (!body.ok) {
2775
+ const code = body.error ?? `http_${response.status}`;
2776
+ throw new SlackError(code, PERMANENT.has(code));
2777
+ }
2778
+ return body;
2779
+ }
2780
+ async function paginate(method, params, pick, options) {
2781
+ const out = [];
2782
+ let cursor;
2783
+ do {
2784
+ const body = await call(
2785
+ method,
2786
+ { ...params, limit: String(PAGE_SIZE), ...cursor ? { cursor } : {} },
2787
+ options
2788
+ );
2789
+ out.push(...pick(body) ?? []);
2790
+ cursor = body.response_metadata?.next_cursor || void 0;
2791
+ } while (cursor);
2792
+ return out;
2793
+ }
2794
+ async function listChannels(options) {
2795
+ const channels = await paginate(
2796
+ "conversations.list",
2797
+ { types: "public_channel,private_channel", exclude_archived: "true" },
2798
+ (body) => body.channels,
2799
+ options
2800
+ );
2801
+ return channels.filter(
2802
+ (channel) => !channel.is_private || channel.is_member !== false
2803
+ );
2804
+ }
2805
+ async function joinChannel(channel, options) {
2806
+ await call("conversations.join", { channel }, { ...options, post: true });
2807
+ }
2808
+ async function postMessage(channel, text, options) {
2809
+ await call(
2810
+ "chat.postMessage",
2811
+ { channel, text, unfurl_links: "false", unfurl_media: "false" },
2812
+ { ...options, post: true }
2813
+ );
2814
+ }
2815
+ async function listUsers(options) {
2816
+ return paginate("users.list", {}, (body) => body.members, options);
2817
+ }
2818
+ async function fetchChannelMessages(channel, options) {
2819
+ const top = await paginate(
2820
+ "conversations.history",
2821
+ { channel, ...options.oldest ? { oldest: options.oldest } : {} },
2822
+ (body) => body.messages,
2823
+ options
2824
+ );
2825
+ const all = [];
2826
+ for (const message of top) {
2827
+ all.push(message);
2828
+ const replyCount = message.reply_count ?? 0;
2829
+ if (replyCount > 0 && message.ts) {
2830
+ options.onProgress?.(`reading a thread with ${replyCount} replies`);
2831
+ const replies = await paginate(
2832
+ "conversations.replies",
2833
+ { channel, ts: message.ts },
2834
+ (body) => body.messages,
2835
+ options
2836
+ );
2837
+ all.push(...replies.filter((reply) => reply.ts !== message.ts));
2838
+ }
2839
+ }
2840
+ return all;
2841
+ }
2842
+ var API, PAGE_SIZE, PERMANENT, SlackError, wait;
2843
+ var init_slack_client = __esm({
2844
+ "../../packages/ingest-core/src/slack-client.ts"() {
2845
+ "use strict";
2846
+ API = "https://slack.com/api";
2847
+ PAGE_SIZE = 200;
2848
+ PERMANENT = /* @__PURE__ */ new Set([
2849
+ "invalid_auth",
2850
+ "account_inactive",
2851
+ "token_revoked",
2852
+ "missing_scope",
2853
+ "not_allowed_token_type"
2854
+ ]);
2855
+ SlackError = class extends Error {
2856
+ constructor(slackCode, permanent) {
2857
+ super(`Slack returned ${slackCode}`);
2858
+ this.slackCode = slackCode;
2859
+ this.permanent = permanent;
2860
+ this.name = "SlackError";
2861
+ }
2862
+ };
2863
+ wait = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
2864
+ }
2865
+ });
2866
+
2867
+ // ../../packages/ingest-core/src/line-survival.ts
2868
+ import { execFileSync as execFileSync4 } from "node:child_process";
2869
+ function git2(repoPath, args) {
2870
+ return execFileSync4("git", args, {
2871
+ cwd: repoPath,
2872
+ maxBuffer: 1024 * 1024 * 64,
2873
+ stdio: ["ignore", "pipe", "ignore"]
2874
+ }).toString("utf-8");
2875
+ }
2876
+ function addedByFile(repoPath, sha) {
2877
+ const out = /* @__PURE__ */ new Map();
2878
+ const raw = git2(repoPath, ["diff-tree", "--root", "--no-commit-id", "--numstat", "-r", "-M", sha]);
2879
+ for (const line of raw.split("\n")) {
2880
+ const [added, , path] = line.split(" ");
2881
+ if (!path || added === "-") continue;
2882
+ out.set(path, Number(added) || 0);
2883
+ }
2884
+ return out;
2885
+ }
2886
+ function survivingIn(repoPath, sha, path) {
2887
+ let raw;
2888
+ try {
2889
+ raw = git2(repoPath, ["blame", "-w", "-M", "--line-porcelain", "HEAD", "--", path]);
2890
+ } catch {
2891
+ return 0;
2892
+ }
2893
+ let n = 0;
2894
+ for (const line of raw.split("\n")) {
2895
+ if (line.length > 41 && line.charCodeAt(0) !== 9 && line.startsWith(sha.slice(0, 40)) && line[40] === " ") {
2896
+ n += 1;
2897
+ }
2898
+ }
2899
+ return n;
2900
+ }
2901
+ function measureLineSurvival(repoPath, sha, now = /* @__PURE__ */ new Date()) {
2902
+ const full = git2(repoPath, ["rev-parse", sha]).trim();
2903
+ const added = addedByFile(repoPath, full);
2904
+ let addedLines = 0;
2905
+ let survivingLines = 0;
2906
+ for (const [path, n] of added) {
2907
+ addedLines += n;
2908
+ if (n > 0) survivingLines += survivingIn(repoPath, full, path);
2909
+ }
2910
+ return { sha: full, addedLines, survivingLines: Math.min(survivingLines, addedLines), measuredAt: now.toISOString() };
2911
+ }
2912
+ function commitsOlderThan(repoPath, minAgeDays, limit, now = /* @__PURE__ */ new Date()) {
2913
+ const before = new Date(now.getTime() - minAgeDays * 864e5).toISOString();
2914
+ const raw = git2(repoPath, ["log", `--before=${before}`, `--max-count=${limit}`, "--format=%H%x09%cI", "HEAD"]);
2915
+ return raw.split("\n").filter(Boolean).map((line) => {
2916
+ const [sha, at] = line.split(" ");
2917
+ return { sha, at };
2918
+ });
2919
+ }
2920
+ var init_line_survival = __esm({
2921
+ "../../packages/ingest-core/src/line-survival.ts"() {
2922
+ "use strict";
2923
+ }
2924
+ });
2925
+
1467
2926
  // ../../packages/ingest-core/src/index.ts
2927
+ var src_exports = {};
2928
+ __export(src_exports, {
2929
+ CONVERSATION_KINDS: () => CONVERSATION_KINDS,
2930
+ EMPTY_USAGE: () => EMPTY_USAGE,
2931
+ EVREX_SESSION_TRAILER_KEY: () => EVREX_SESSION_TRAILER_KEY,
2932
+ JIRA_FIELDS: () => JIRA_FIELDS,
2933
+ JIRA_SEARCH_PATH: () => JIRA_SEARCH_PATH,
2934
+ LINEAR_AUTHORIZE_URL: () => LINEAR_AUTHORIZE_URL,
2935
+ LINEAR_GRAPHQL_URL: () => LINEAR_GRAPHQL_URL,
2936
+ LINEAR_ISSUES_QUERY: () => LINEAR_ISSUES_QUERY,
2937
+ LINEAR_READ_SCOPE: () => LINEAR_READ_SCOPE,
2938
+ LINEAR_TOKEN_URL: () => LINEAR_TOKEN_URL,
2939
+ MIN_MEANINGFUL_LINE_LENGTH: () => MIN_MEANINGFUL_LINE_LENGTH,
2940
+ PARSER_VERSION: () => PARSER_VERSION,
2941
+ PROVENANCE_TRAILERS: () => PROVENANCE_TRAILERS,
2942
+ REFERENCE_KINDS: () => REFERENCE_KINDS,
2943
+ SOURCE_KINDS: () => SOURCE_KINDS,
2944
+ STATED_TRAILERS: () => STATED_TRAILERS,
2945
+ SlackError: () => SlackError,
2946
+ advanceCursor: () => advanceCursor,
2947
+ agentTrailersOf: () => agentTrailersOf,
2948
+ buildCopilotSession: () => buildCopilotSession,
2949
+ buildOpenCodeSession: () => buildOpenCodeSession,
2950
+ changedSessions: () => changedSessions,
2951
+ codexSessionsDir: () => codexSessionsDir,
2952
+ collectRepoData: () => collectRepoData,
2953
+ commitsOlderThan: () => commitsOlderThan,
2954
+ committedSubjects: () => committedSubjects,
2955
+ copilotSessionsDir: () => copilotSessionsDir,
2956
+ cursorStateDbPath: () => cursorStateDbPath,
2957
+ deriveCodexTurnId: () => deriveCodexTurnId,
2958
+ deriveCursorSessionId: () => deriveCursorSessionId,
2959
+ deriveOpenCodeSessionId: () => deriveOpenCodeSessionId,
2960
+ deriveRepoId: () => deriveRepoId,
2961
+ deriveSlackSessionId: () => deriveSlackSessionId,
2962
+ deriveSlackTurnId: () => deriveSlackTurnId,
2963
+ deriveTicketId: () => deriveTicketId,
2964
+ deriveUuid: () => deriveUuid,
2965
+ detectTruncatedHistory: () => detectTruncatedHistory,
2966
+ fetchChannelMessages: () => fetchChannelMessages,
2967
+ fetchJiraIssues: () => fetchJiraIssues,
2968
+ fetchLinearIssues: () => fetchLinearIssues,
2969
+ findCodexSessionFiles: () => findCodexSessionFiles,
2970
+ findCopilotSessionDirs: () => findCopilotSessionDirs,
2971
+ findSessionFiles: () => findSessionFiles,
2972
+ getGitUserName: () => getGitUserName,
2973
+ groupIntoThreads: () => groupIntoThreads,
2974
+ isHumanMessage: () => isHumanMessage,
2975
+ isReferenceKind: () => isReferenceKind,
2976
+ jiraBasicAuth: () => jiraBasicAuth,
2977
+ jiraIssueToTicket: () => jiraIssueToTicket,
2978
+ joinChannel: () => joinChannel,
2979
+ lacksFileAttribution: () => lacksFileAttribution,
2980
+ linearAuthHeader: () => linearAuthHeader,
2981
+ linearIssueToTicket: () => linearIssueToTicket,
2982
+ listChannels: () => listChannels,
2983
+ listUsers: () => listUsers,
2984
+ loadComposers: () => loadComposers,
2985
+ matchCommitToSession: () => matchCommitToSession,
2986
+ measureLineSurvival: () => measureLineSurvival,
2987
+ normalizeRepoRemote: () => normalizeRepoRemote,
2988
+ opencodeDbPath: () => opencodeDbPath,
2989
+ parseAllCodexSessions: () => parseAllCodexSessions,
2990
+ parseAllCopilotSessions: () => parseAllCopilotSessions,
2991
+ parseAllCursorSessions: () => parseAllCursorSessions,
2992
+ parseAllOpenCodeSessions: () => parseAllOpenCodeSessions,
2993
+ parseAllSessions: () => parseAllSessions,
2994
+ parseCodexSessionFile: () => parseCodexSessionFile,
2995
+ parseCopilotSessionDir: () => parseCopilotSessionDir,
2996
+ parseCopilotWorkspace: () => parseCopilotWorkspace,
2997
+ parseCursorComposer: () => parseCursorComposer,
2998
+ parseCursorConversation: () => parseCursorConversation,
2999
+ parseGeminiSessionFile: () => parseGeminiSessionFile,
3000
+ parseGitLog: () => parseGitLog,
3001
+ parseLineLog: () => parseLineLog,
3002
+ parseSessionFile: () => parseSessionFile,
3003
+ parseSlackExport: () => parseSlackExport,
3004
+ parseTraceTarget: () => parseTraceTarget,
3005
+ parseTrailers: () => parseTrailers,
3006
+ parseTranscriptFile: () => parseTranscriptFile,
3007
+ parseUnifiedDiffHunks: () => parseUnifiedDiffHunks,
3008
+ planRead: () => planRead,
3009
+ postMessage: () => postMessage,
3010
+ proposeLinks: () => proposeLinks,
3011
+ provenanceTrailerFor: () => provenanceTrailerFor,
3012
+ readableText: () => readableText,
3013
+ redactJsonValue: () => redactJsonValue,
3014
+ redactRawTranscript: () => redactRawTranscript,
3015
+ redactSecrets: () => redactSecrets,
3016
+ repoIdFromPath: () => repoIdFromPath,
3017
+ repoIdFromRemote: () => repoIdFromRemote,
3018
+ repoIdFromRootCommit: () => repoIdFromRootCommit,
3019
+ repoNameFromId: () => repoNameFromId,
3020
+ sessionSignature: () => sessionSignature,
3021
+ slackTimestamp: () => slackTimestamp,
3022
+ splitCompleteLines: () => splitCompleteLines,
3023
+ statedInsightsOf: () => statedInsightsOf,
3024
+ stripNulls: () => stripNulls,
3025
+ stripNullsDeep: () => stripNullsDeep,
3026
+ ticketReferencesIn: () => ticketReferencesIn,
3027
+ traceLines: () => traceLines,
3028
+ trailersFromMessage: () => trailersFromMessage
3029
+ });
3030
+ import { userInfo } from "node:os";
1468
3031
  function resolveFallbackAuthor(repoPath, commits) {
1469
3032
  const configured = getGitUserName(repoPath);
1470
3033
  if (configured) return configured;
@@ -1489,7 +3052,9 @@ function collectRepoData(repoPath, options = {}) {
1489
3052
  const sessions = [
1490
3053
  ...claude.sessions,
1491
3054
  ...parseAllCursorSessions(repoPath, repoId),
1492
- ...parseAllCodexSessions(repoPath, repoId)
3055
+ ...parseAllCodexSessions(repoPath, repoId),
3056
+ ...parseAllOpenCodeSessions(repoPath, repoId),
3057
+ ...parseAllCopilotSessions(repoPath, repoId)
1493
3058
  ].map((s) => ({
1494
3059
  ...s,
1495
3060
  author: s.author ?? author
@@ -1503,6 +3068,292 @@ function collectRepoData(repoPath, options = {}) {
1503
3068
  skippedTranscripts: claude.skipped
1504
3069
  };
1505
3070
  }
3071
+ var init_src = __esm({
3072
+ "../../packages/ingest-core/src/index.ts"() {
3073
+ "use strict";
3074
+ init_git_history();
3075
+ init_claude_sessions();
3076
+ init_cursor_sessions();
3077
+ init_opencode_sessions();
3078
+ init_copilot_sessions();
3079
+ init_codex_sessions();
3080
+ init_sanitize();
3081
+ init_types();
3082
+ init_sanitize();
3083
+ init_incremental();
3084
+ init_redact();
3085
+ init_tickets();
3086
+ init_linear();
3087
+ init_jira();
3088
+ init_claude_sessions();
3089
+ init_copilot_sessions();
3090
+ init_opencode_sessions();
3091
+ init_cursor_sessions();
3092
+ init_codex_sessions();
3093
+ init_git_history();
3094
+ init_diff_parser();
3095
+ init_trace_lines();
3096
+ init_trace_target();
3097
+ init_transcript_parsers();
3098
+ init_gemini_sessions();
3099
+ init_slack_threads();
3100
+ init_slack_client();
3101
+ init_subject_linking();
3102
+ init_derive_uuid();
3103
+ init_line_survival();
3104
+ }
3105
+ });
3106
+
3107
+ // src/client.ts
3108
+ var client_exports = {};
3109
+ __export(client_exports, {
3110
+ evrexApi: () => evrexApi
3111
+ });
3112
+ function headers() {
3113
+ const base = { "Content-Type": "application/json" };
3114
+ if (EVREX_TOKEN) base.Authorization = `Bearer ${EVREX_TOKEN}`;
3115
+ return base;
3116
+ }
3117
+ function describeFailure(method, path, status, statusText) {
3118
+ if (status === 401 || status === 403) {
3119
+ 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.`;
3120
+ }
3121
+ return `${method} ${path} -> ${status} ${statusText}`;
3122
+ }
3123
+ async function request(method, path, { body, absentIsAnswer } = {}) {
3124
+ const res = await fetch(`${API_BASE_URL}${path}`, {
3125
+ method,
3126
+ headers: headers(),
3127
+ ...body === void 0 ? {} : { body: JSON.stringify(body) }
3128
+ });
3129
+ if (res.status === 404 && absentIsAnswer) return null;
3130
+ if (!res.ok) throw new Error(describeFailure(method, path, res.status, res.statusText));
3131
+ return await res.json();
3132
+ }
3133
+ var DEFAULT_API_BASE_URL, API_BASE_URL, EVREX_TOKEN, get, getOrNull, post, evrexApi;
3134
+ var init_client = __esm({
3135
+ "src/client.ts"() {
3136
+ "use strict";
3137
+ DEFAULT_API_BASE_URL = "https://api.evrex.ai";
3138
+ API_BASE_URL = (process.env.EVREX_API_BASE_URL ?? process.env.EVREX_API_URL ?? DEFAULT_API_BASE_URL).replace(/\/+$/, "");
3139
+ EVREX_TOKEN = process.env.EVREX_TOKEN ?? process.env.EVREX_API_TOKEN ?? null;
3140
+ get = (path) => request("GET", path);
3141
+ getOrNull = (path) => request("GET", path, { absentIsAnswer: true });
3142
+ post = (path, body) => request("POST", path, { body });
3143
+ evrexApi = {
3144
+ baseUrl: API_BASE_URL,
3145
+ repos: () => get("/repos"),
3146
+ commits: (repoPath) => get(`/commits?repoPath=${encodeURIComponent(repoPath)}`),
3147
+ // Abbreviated shas resolve server-side, so a value pasted from `git log`
3148
+ // works here (apps/backend/src/reads/reads.service.ts#resolveSha).
3149
+ commit: (sha) => getOrNull(`/commits/${encodeURIComponent(sha)}`),
3150
+ sessions: (repoPath) => get(`/sessions?repoPath=${encodeURIComponent(repoPath)}`),
3151
+ session: (id) => getOrNull(`/sessions/${encodeURIComponent(id)}`),
3152
+ // The paginated transcript — see reads.service.ts#getSessionTurns. `ts, id`
3153
+ // ordering server-side makes offsets stable across requests.
3154
+ sessionTurns: (id, offset, limit) => getOrNull(
3155
+ `/sessions/${encodeURIComponent(id)}/turns?offset=${offset}&limit=${limit}`
3156
+ ),
3157
+ ask: (repoPath, text, filePaths) => post("/ask", { repoPath, text, filePaths }),
3158
+ // Evidence-only retrieval (BM25 + embedding), no LLM synthesis call — see
3159
+ // apps/backend/src/query/query.service.ts#search. Used by evrex_search,
3160
+ // which wants ranked hits fast, not a synthesized paragraph.
3161
+ search: (repoPath, text, filePaths) => post("/search", { repoPath, text, filePaths }),
3162
+ // Everything that happened in a repo, newest first, bounded by days — the
3163
+ // same query the desktop Timeline screen makes. Sessions and commits
3164
+ // interleaved, each with the handle evrex_expand takes.
3165
+ feedback: (body) => post("/feedback", body),
3166
+ timeline: (repoPath, days) => get(
3167
+ `/timeline?repoPath=${encodeURIComponent(repoPath)}&days=${encodeURIComponent(String(days))}`
3168
+ )
3169
+ };
3170
+ }
3171
+ });
3172
+
3173
+ // src/credential-store.ts
3174
+ var credential_store_exports = {};
3175
+ __export(credential_store_exports, {
3176
+ CredentialStore: () => CredentialStore,
3177
+ NoKeychainError: () => NoKeychainError,
3178
+ systemRunner: () => systemRunner
3179
+ });
3180
+ import { spawn } from "node:child_process";
3181
+ var SERVICE, ACCOUNT, systemRunner, NoKeychainError, CredentialStore, WINDOWS_PATH, WINDOWS_STORE, WINDOWS_RETRIEVE, WINDOWS_REMOVE;
3182
+ var init_credential_store = __esm({
3183
+ "src/credential-store.ts"() {
3184
+ "use strict";
3185
+ SERVICE = "evrex-capture";
3186
+ ACCOUNT = "evrex";
3187
+ systemRunner = {
3188
+ platform: process.platform,
3189
+ run(command, args, stdin) {
3190
+ return new Promise((resolve2) => {
3191
+ const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"] });
3192
+ let stdout = "";
3193
+ let stderr = "";
3194
+ child.stdout.on("data", (d) => stdout += d.toString());
3195
+ child.stderr.on("data", (d) => stderr += d.toString());
3196
+ child.on("error", () => resolve2({ code: 127, stdout: "", stderr: "" }));
3197
+ child.on("close", (code) => resolve2({ code: code ?? 1, stdout, stderr }));
3198
+ if (stdin !== void 0) child.stdin.write(stdin);
3199
+ child.stdin.end();
3200
+ });
3201
+ }
3202
+ };
3203
+ NoKeychainError = class extends Error {
3204
+ constructor(platform) {
3205
+ super(
3206
+ `evrex could not find a credential store on this machine (${platform}).
3207
+ macOS needs \`security\`, which ships with the system.
3208
+ Linux needs \`secret-tool\` \u2014 install libsecret-tools (Debian/Ubuntu)
3209
+ or libsecret (Fedora/Arch), and make sure a keyring daemon is running.
3210
+ Windows needs PowerShell.
3211
+ evrex will not fall back to writing the credential in a plain file.`
3212
+ );
3213
+ this.name = "NoKeychainError";
3214
+ }
3215
+ };
3216
+ CredentialStore = class {
3217
+ constructor(runner = systemRunner) {
3218
+ this.runner = runner;
3219
+ }
3220
+ async available() {
3221
+ switch (this.runner.platform) {
3222
+ case "darwin":
3223
+ return (await this.runner.run("security", ["help"])).code !== 127;
3224
+ case "win32":
3225
+ return (await this.runner.run("powershell", ["-Command", "$PSVersionTable.PSVersion.Major"])).code !== 127;
3226
+ default:
3227
+ return (await this.runner.run("secret-tool", ["--version"])).code !== 127;
3228
+ }
3229
+ }
3230
+ /**
3231
+ * Replaces any existing credential rather than adding a second one. A
3232
+ * machine that re-enrols after expiry must end up with exactly one entry, or
3233
+ * the next read is a coin flip between the live credential and a dead one.
3234
+ */
3235
+ async store(secret) {
3236
+ if (!await this.available()) throw new NoKeychainError(this.runner.platform);
3237
+ switch (this.runner.platform) {
3238
+ case "darwin": {
3239
+ const result = await this.runner.run(
3240
+ "security",
3241
+ ["add-generic-password", "-a", ACCOUNT, "-s", SERVICE, "-U", "-w"],
3242
+ `${secret}
3243
+ ${secret}
3244
+ `
3245
+ );
3246
+ if (result.code !== 0) throw new Error(`Keychain write failed: ${result.stderr.trim()}`);
3247
+ return;
3248
+ }
3249
+ case "win32": {
3250
+ const result = await this.runner.run(
3251
+ "powershell",
3252
+ ["-NoProfile", "-Command", WINDOWS_STORE],
3253
+ secret
3254
+ );
3255
+ if (result.code !== 0) throw new Error(`DPAPI write failed: ${result.stderr.trim()}`);
3256
+ return;
3257
+ }
3258
+ default: {
3259
+ const result = await this.runner.run(
3260
+ "secret-tool",
3261
+ ["store", "--label=evrex capture credential", "service", SERVICE, "account", ACCOUNT],
3262
+ secret
3263
+ );
3264
+ if (result.code !== 0) throw new Error(`secret-tool write failed: ${result.stderr.trim()}`);
3265
+ return;
3266
+ }
3267
+ }
3268
+ }
3269
+ /** Null when there is nothing stored, which is the normal pre-enrolment state. */
3270
+ async retrieve() {
3271
+ if (!await this.available()) throw new NoKeychainError(this.runner.platform);
3272
+ switch (this.runner.platform) {
3273
+ case "darwin": {
3274
+ const r = await this.runner.run("security", [
3275
+ "find-generic-password",
3276
+ "-a",
3277
+ ACCOUNT,
3278
+ "-s",
3279
+ SERVICE,
3280
+ "-w"
3281
+ ]);
3282
+ return r.code === 0 ? r.stdout.trim() || null : null;
3283
+ }
3284
+ case "win32": {
3285
+ const r = await this.runner.run("powershell", ["-NoProfile", "-Command", WINDOWS_RETRIEVE]);
3286
+ return r.code === 0 ? r.stdout.trim() || null : null;
3287
+ }
3288
+ default: {
3289
+ const r = await this.runner.run("secret-tool", [
3290
+ "lookup",
3291
+ "service",
3292
+ SERVICE,
3293
+ "account",
3294
+ ACCOUNT
3295
+ ]);
3296
+ return r.code === 0 ? r.stdout.trim() || null : null;
3297
+ }
3298
+ }
3299
+ }
3300
+ /** Idempotent: removing a credential that is not there is not an error. */
3301
+ async remove() {
3302
+ if (!await this.available()) return;
3303
+ switch (this.runner.platform) {
3304
+ case "darwin":
3305
+ await this.runner.run("security", [
3306
+ "delete-generic-password",
3307
+ "-a",
3308
+ ACCOUNT,
3309
+ "-s",
3310
+ SERVICE
3311
+ ]);
3312
+ return;
3313
+ case "win32":
3314
+ await this.runner.run("powershell", ["-NoProfile", "-Command", WINDOWS_REMOVE]);
3315
+ return;
3316
+ default:
3317
+ await this.runner.run("secret-tool", [
3318
+ "clear",
3319
+ "service",
3320
+ SERVICE,
3321
+ "account",
3322
+ ACCOUNT
3323
+ ]);
3324
+ return;
3325
+ }
3326
+ }
3327
+ };
3328
+ WINDOWS_PATH = "$env:APPDATA\\evrex\\capture.cred";
3329
+ WINDOWS_STORE = `
3330
+ $ErrorActionPreference = 'Stop'
3331
+ $p = "${WINDOWS_PATH}"
3332
+ New-Item -ItemType Directory -Force -Path (Split-Path $p) | Out-Null
3333
+ $secret = [Console]::In.ReadToEnd().Trim()
3334
+ ConvertTo-SecureString $secret -AsPlainText -Force | ConvertFrom-SecureString | Set-Content -Path $p
3335
+ `.trim();
3336
+ WINDOWS_RETRIEVE = `
3337
+ $ErrorActionPreference = 'Stop'
3338
+ $p = "${WINDOWS_PATH}"
3339
+ if (-not (Test-Path $p)) { exit 1 }
3340
+ $sec = Get-Content $p | ConvertTo-SecureString
3341
+ [Runtime.InteropServices.Marshal]::PtrToStringAuto(
3342
+ [Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec))
3343
+ `.trim();
3344
+ WINDOWS_REMOVE = `
3345
+ $p = "${WINDOWS_PATH}"
3346
+ if (Test-Path $p) { Remove-Item $p -Force }
3347
+ `.trim();
3348
+ }
3349
+ });
3350
+
3351
+ // src/import.ts
3352
+ init_src();
3353
+ import { realpathSync } from "node:fs";
3354
+ import { homedir as homedir7 } from "node:os";
3355
+ import { fileURLToPath } from "node:url";
3356
+ import { resolve } from "node:path";
1506
3357
 
1507
3358
  // src/batch.ts
1508
3359
  var MAX_BATCH_BYTES = 24 * 1024 * 1024;
@@ -1530,15 +3381,15 @@ function batchByBytes(items, maxBytes = MAX_BATCH_BYTES) {
1530
3381
  }
1531
3382
 
1532
3383
  // src/import-state.ts
1533
- import { existsSync as existsSync4, mkdirSync, readFileSync as readFileSync3, renameSync, writeFileSync } from "node:fs";
1534
- import { dirname, join as join4 } from "node:path";
1535
- import { homedir as homedir4 } from "node:os";
1536
- function importStatePath(home = homedir4()) {
1537
- return join4(home, ".evrex", "import-state.json");
3384
+ import { existsSync as existsSync6, mkdirSync, readFileSync as readFileSync5, renameSync, writeFileSync } from "node:fs";
3385
+ import { dirname, join as join6 } from "node:path";
3386
+ import { homedir as homedir6 } from "node:os";
3387
+ function importStatePath(home = homedir6()) {
3388
+ return join6(home, ".evrex", "import-state.json");
1538
3389
  }
1539
3390
  function readImportState(path) {
1540
3391
  try {
1541
- const parsed = JSON.parse(readFileSync3(path, "utf8"));
3392
+ const parsed = JSON.parse(readFileSync5(path, "utf8"));
1542
3393
  if (!parsed || typeof parsed.repos !== "object") return { repos: {} };
1543
3394
  return { repos: parsed.repos ?? {} };
1544
3395
  } catch {
@@ -1588,17 +3439,59 @@ async function importRepo(repoPath, deps) {
1588
3439
  result.commits += batch.length;
1589
3440
  deps.log(` commits ${result.commits}/${data.commits.length}`);
1590
3441
  }
3442
+ const wireSessions = data.sessions.map(
3443
+ ({ rawContent: _raw, rawFormat: _fmt, ...rest }) => rest
3444
+ );
3445
+ const rawBySession = new Map(
3446
+ data.sessions.map((s) => [s.id, { rawContent: s.rawContent, rawFormat: s.rawFormat }])
3447
+ );
3448
+ async function archiveRaw(id) {
3449
+ const raw = rawBySession.get(id);
3450
+ if (!raw?.rawContent) return;
3451
+ await deps.post(`/ingest/sessions/${id}/transcript`, {
3452
+ rawContent: raw.rawContent,
3453
+ rawFormat: raw.rawFormat ?? "jsonl"
3454
+ });
3455
+ }
3456
+ async function postInPieces(d, session) {
3457
+ const turns = session.turns ?? [];
3458
+ if (turns.length <= 1) return false;
3459
+ const mid = Math.ceil(turns.length / 2);
3460
+ for (const slice of [turns.slice(0, mid), turns.slice(mid)]) {
3461
+ const part = { ...session, turns: slice };
3462
+ if (!await d.post("/ingest/sessions", { sessions: [part] })) {
3463
+ if (!await postInPieces(d, part)) return false;
3464
+ }
3465
+ }
3466
+ return true;
3467
+ }
1591
3468
  const deliveredCursors = {};
1592
3469
  if (!result.failed) {
1593
- for (const batch of batchByBytes(data.sessions)) {
3470
+ for (const batch of batchByBytes(wireSessions)) {
1594
3471
  result.batches += 1;
1595
3472
  if (!await deps.post("/ingest/sessions", { sessions: batch })) {
3473
+ const tooBig = (x) => JSON.stringify(x).length >= MAX_BATCH_BYTES / 4;
3474
+ let salvaged = true;
3475
+ for (const one of batch) {
3476
+ const landed = batch.length > 1 && await deps.post("/ingest/sessions", { sessions: [one] }) ? true : tooBig(one) && await postInPieces(deps, one);
3477
+ if (!landed) {
3478
+ salvaged = false;
3479
+ break;
3480
+ }
3481
+ result.sessions += 1;
3482
+ const cursor = data.cursors[one.sourceFile];
3483
+ if (cursor !== void 0) deliveredCursors[one.sourceFile] = cursor;
3484
+ await archiveRaw(one.id);
3485
+ deps.log(` sessions ${result.sessions}/${data.sessions.length} (split upload)`);
3486
+ }
3487
+ if (salvaged) continue;
1596
3488
  result.failed = true;
1597
3489
  break;
1598
3490
  }
1599
3491
  for (const session of batch) {
1600
3492
  const cursor = data.cursors[session.sourceFile];
1601
3493
  if (cursor !== void 0) deliveredCursors[session.sourceFile] = cursor;
3494
+ await archiveRaw(session.id);
1602
3495
  }
1603
3496
  result.sessions += batch.length;
1604
3497
  deps.log(` sessions ${result.sessions}/${data.sessions.length}`);
@@ -1612,7 +3505,50 @@ async function importRepo(repoPath, deps) {
1612
3505
  writeImportState(statePath, state);
1613
3506
  return result;
1614
3507
  }
3508
+ async function durability(target, argv) {
3509
+ const { measureLineSurvival: measureLineSurvival2, commitsOlderThan: commitsOlderThan2, deriveRepoId: deriveRepoId2 } = await Promise.resolve().then(() => (init_src(), src_exports));
3510
+ const { evrexApi: evrexApi2 } = await Promise.resolve().then(() => (init_client(), client_exports));
3511
+ const flag = (name, fallback) => {
3512
+ const i = argv.indexOf(name);
3513
+ return i === -1 ? fallback : Number(argv[i + 1]) || fallback;
3514
+ };
3515
+ const minAgeDays = flag("--min-age-days", 30);
3516
+ const limit = flag("--limit", 300);
3517
+ const token = process.env.EVREX_TOKEN ?? await new (await Promise.resolve().then(() => (init_credential_store(), credential_store_exports))).CredentialStore().retrieve().catch(() => null);
3518
+ if (!token) {
3519
+ console.error("evrex: this machine is not enrolled.\n Run `npx -y evrex-mcp enrol` first, or set EVREX_TOKEN.");
3520
+ process.exit(1);
3521
+ }
3522
+ const repoId = deriveRepoId2(target);
3523
+ const commits = commitsOlderThan2(target, minAgeDays, limit);
3524
+ console.error(`Measuring ${commits.length} commits at least ${minAgeDays} days old in ${target}`);
3525
+ const measurements = [];
3526
+ for (const [i, c] of commits.entries()) {
3527
+ try {
3528
+ measurements.push(measureLineSurvival2(target, c.sha));
3529
+ } catch {
3530
+ }
3531
+ if ((i + 1) % 25 === 0) console.error(` ${i + 1}/${commits.length}`);
3532
+ }
3533
+ const res = await fetch(`${evrexApi2.baseUrl}/ingest/commits/durability`, {
3534
+ method: "POST",
3535
+ headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
3536
+ body: JSON.stringify({ repoId, measurements })
3537
+ });
3538
+ if (!res.ok) {
3539
+ console.error(` /ingest/commits/durability -> ${res.status}`);
3540
+ process.exit(1);
3541
+ }
3542
+ const body = await res.json();
3543
+ const added = measurements.reduce((n, m) => n + m.addedLines, 0);
3544
+ const surviving = measurements.reduce((n, m) => n + m.survivingLines, 0);
3545
+ console.error(` ${body.updated} commits recorded \xB7 ${surviving}/${added} added lines still at HEAD (${added ? Math.round(100 * surviving / added) : 0}%)`);
3546
+ }
1615
3547
  async function main() {
3548
+ if (process.argv[2] === "durability") {
3549
+ await durability(resolve(process.argv[3] && !process.argv[3].startsWith("--") ? process.argv[3] : process.cwd()), process.argv.slice(3));
3550
+ return;
3551
+ }
1616
3552
  const target = resolve(process.argv[3] ?? process.cwd());
1617
3553
  const { evrexApi: evrexApi2 } = await Promise.resolve().then(() => (init_client(), client_exports));
1618
3554
  const { CredentialStore: CredentialStore2 } = await Promise.resolve().then(() => (init_credential_store(), credential_store_exports));
@@ -1627,7 +3563,7 @@ async function main() {
1627
3563
  const result = await importRepo(target, {
1628
3564
  collect: collectRepoData,
1629
3565
  deriveRepoId,
1630
- home: homedir5(),
3566
+ home: homedir7(),
1631
3567
  now: () => /* @__PURE__ */ new Date(),
1632
3568
  log: (line) => console.error(line),
1633
3569
  post: async (path, body) => {