evrex-mcp 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1012 @@
1
+ #!/usr/bin/env node
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __esm = (fn, res) => function __init() {
5
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
6
+ };
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+
12
+ // src/client.ts
13
+ var client_exports = {};
14
+ __export(client_exports, {
15
+ evrexApi: () => evrexApi
16
+ });
17
+ function headers() {
18
+ const base = { "Content-Type": "application/json" };
19
+ if (EVREX_TOKEN) base.Authorization = `Bearer ${EVREX_TOKEN}`;
20
+ return base;
21
+ }
22
+ function describeFailure(method, path, status, statusText) {
23
+ if (status === 401 || status === 403) {
24
+ return EVREX_TOKEN ? `${method} ${path} -> ${status}: the EVREX_TOKEN this server was started with was rejected. It may be revoked or expired.` : `${method} ${path} -> ${status}: this evrex backend requires a credential, but no EVREX_TOKEN is set for this MCP server.`;
25
+ }
26
+ return `${method} ${path} -> ${status} ${statusText}`;
27
+ }
28
+ async function request(method, path, { body, absentIsAnswer } = {}) {
29
+ const res = await fetch(`${API_BASE_URL}${path}`, {
30
+ method,
31
+ headers: headers(),
32
+ ...body === void 0 ? {} : { body: JSON.stringify(body) }
33
+ });
34
+ if (res.status === 404 && absentIsAnswer) return null;
35
+ if (!res.ok) throw new Error(describeFailure(method, path, res.status, res.statusText));
36
+ return await res.json();
37
+ }
38
+ var DEFAULT_API_BASE_URL, API_BASE_URL, EVREX_TOKEN, get, getOrNull, post, evrexApi;
39
+ var init_client = __esm({
40
+ "src/client.ts"() {
41
+ "use strict";
42
+ DEFAULT_API_BASE_URL = "https://api.evrex.ai";
43
+ API_BASE_URL = (process.env.EVREX_API_BASE_URL ?? process.env.EVREX_API_URL ?? DEFAULT_API_BASE_URL).replace(/\/+$/, "");
44
+ EVREX_TOKEN = process.env.EVREX_TOKEN ?? process.env.EVREX_API_TOKEN ?? null;
45
+ get = (path) => request("GET", path);
46
+ getOrNull = (path) => request("GET", path, { absentIsAnswer: true });
47
+ post = (path, body) => request("POST", path, { body });
48
+ evrexApi = {
49
+ baseUrl: API_BASE_URL,
50
+ repos: () => get("/repos"),
51
+ commits: (repoPath) => get(`/commits?repoPath=${encodeURIComponent(repoPath)}`),
52
+ // Abbreviated shas resolve server-side, so a value pasted from `git log`
53
+ // works here (apps/backend/src/reads/reads.service.ts#resolveSha).
54
+ commit: (sha) => getOrNull(`/commits/${encodeURIComponent(sha)}`),
55
+ sessions: (repoPath) => get(`/sessions?repoPath=${encodeURIComponent(repoPath)}`),
56
+ session: (id) => getOrNull(`/sessions/${encodeURIComponent(id)}`),
57
+ ask: (repoPath, text, filePaths) => post("/ask", { repoPath, text, filePaths }),
58
+ // Evidence-only retrieval (BM25 + embedding), no LLM synthesis call — see
59
+ // apps/backend/src/query/query.service.ts#search. Used by evrex_search,
60
+ // which wants ranked hits fast, not a synthesized paragraph.
61
+ search: (repoPath, text, filePaths) => post("/search", { repoPath, text, filePaths })
62
+ };
63
+ }
64
+ });
65
+
66
+ // src/capture.ts
67
+ import { statSync as statSync3 } from "node:fs";
68
+ import { homedir as homedir2, hostname } from "node:os";
69
+ import { join as join2 } from "node:path";
70
+
71
+ // ../../packages/ingest-core/src/git-history.ts
72
+ import { execFileSync } from "node:child_process";
73
+ function gitQuiet(repoPath, args) {
74
+ return execFileSync("git", args, {
75
+ cwd: repoPath,
76
+ maxBuffer: 1024 * 1024 * 8,
77
+ stdio: ["ignore", "pipe", "ignore"]
78
+ }).toString("utf-8");
79
+ }
80
+ function normalizeRepoRemote(url) {
81
+ const trimmed = url.trim();
82
+ if (!trimmed) return null;
83
+ let host;
84
+ let path;
85
+ const scpLike = /^(?:[^/@]+@)?([^/:@]+\.[^/:@]+):(?!\/)(.+)$/.exec(trimmed);
86
+ if (scpLike?.[1] && scpLike[2]) {
87
+ host = scpLike[1];
88
+ path = scpLike[2];
89
+ } else {
90
+ let parsed;
91
+ try {
92
+ parsed = new URL(trimmed);
93
+ } catch {
94
+ return null;
95
+ }
96
+ if (parsed.protocol === "file:" || !parsed.hostname) return null;
97
+ host = parsed.hostname;
98
+ path = parsed.pathname;
99
+ }
100
+ const cleanedPath = path.replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/i, "");
101
+ if (!cleanedPath) return null;
102
+ return `${host}/${cleanedPath}`.toLowerCase();
103
+ }
104
+ function repoIdFromRemote(url) {
105
+ const normalized = normalizeRepoRemote(url);
106
+ return normalized ? `remote:${normalized}` : null;
107
+ }
108
+ function repoIdFromRootCommit(sha) {
109
+ return `root:${sha}`;
110
+ }
111
+ function repoIdFromPath(repoPath) {
112
+ return `path:${repoPath}`;
113
+ }
114
+ function firstRemoteUrl(repoPath) {
115
+ try {
116
+ const origin = gitQuiet(repoPath, ["remote", "get-url", "origin"]).trim();
117
+ if (origin) return origin;
118
+ } catch {
119
+ }
120
+ try {
121
+ const names = gitQuiet(repoPath, ["remote"]).split("\n").map((n) => n.trim()).filter(Boolean).sort();
122
+ for (const name of names) {
123
+ const url = gitQuiet(repoPath, ["remote", "get-url", name]).trim();
124
+ if (url) return url;
125
+ }
126
+ } catch {
127
+ }
128
+ return null;
129
+ }
130
+ function rootCommitSha(repoPath) {
131
+ try {
132
+ const shas = gitQuiet(repoPath, ["rev-list", "--max-parents=0", "HEAD"]).split("\n").map((s) => s.trim()).filter(Boolean).sort();
133
+ return shas[0] ?? null;
134
+ } catch {
135
+ return null;
136
+ }
137
+ }
138
+ function deriveRepoId(repoPath) {
139
+ const remote = firstRemoteUrl(repoPath);
140
+ if (remote) {
141
+ const fromRemote = repoIdFromRemote(remote);
142
+ if (fromRemote) return fromRemote;
143
+ }
144
+ const root = rootCommitSha(repoPath);
145
+ if (root) return repoIdFromRootCommit(root);
146
+ return repoIdFromPath(repoPath);
147
+ }
148
+
149
+ // ../../packages/ingest-core/src/claude-sessions.ts
150
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
151
+
152
+ // ../../packages/ingest-core/src/redact.ts
153
+ var PATTERNS = [
154
+ { type: "private_key", regex: /-----BEGIN(?: [A-Z]+)? PRIVATE KEY-----[\s\S]*?-----END(?: [A-Z]+)? PRIVATE KEY-----/g },
155
+ { type: "aws_access_key", regex: /AKIA[0-9A-Z]{16}/g },
156
+ { type: "github_token", regex: /gh[pousr]_[A-Za-z0-9]{36,}/g },
157
+ { type: "slack_token", regex: /xox[baprs]-[A-Za-z0-9-]{10,}/g },
158
+ { type: "stripe_key", regex: /sk_(live|test)_[A-Za-z0-9]{24,}/g },
159
+ { type: "openai_key", regex: /sk-[A-Za-z0-9]{20,}/g },
160
+ { type: "anthropic_key", regex: /sk-ant-[A-Za-z0-9-_]{20,}/g },
161
+ { type: "jwt", regex: /eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g },
162
+ { type: "bearer_token", regex: /Bearer\s+[A-Za-z0-9\-_.]{20,}/g },
163
+ {
164
+ type: "env_secret",
165
+ regex: /\b([A-Z0-9_]*(?:_KEY|_SECRET|_TOKEN|_PASSWORD|PASSWD)[A-Z0-9_]*)\s*[:=]\s*["']?[^\s"'\n]{6,}["']?/g
166
+ }
167
+ ];
168
+ function redactJsonValue(value) {
169
+ let count = 0;
170
+ const walk = (v) => {
171
+ if (typeof v === "string") {
172
+ const r = redactSecrets(v);
173
+ count += r.count;
174
+ return r.text;
175
+ }
176
+ if (Array.isArray(v)) return v.map(walk);
177
+ if (v && typeof v === "object") {
178
+ const out = {};
179
+ for (const [k, val] of Object.entries(v)) {
180
+ out[k] = walk(val);
181
+ }
182
+ return out;
183
+ }
184
+ return v;
185
+ };
186
+ return { value: walk(value), count };
187
+ }
188
+ function redactSecrets(input) {
189
+ let text = input;
190
+ let count = 0;
191
+ for (const { type, regex } of PATTERNS) {
192
+ text = text.replace(regex, (match, group1) => {
193
+ count += 1;
194
+ if (type === "env_secret" && group1) {
195
+ return `${group1}=[REDACTED:${type}]`;
196
+ }
197
+ return `[REDACTED:${type}]`;
198
+ });
199
+ }
200
+ return { text, count };
201
+ }
202
+
203
+ // ../../packages/ingest-core/src/subject-linking.ts
204
+ function committedSubjects(command) {
205
+ const out = [];
206
+ const invocation = /\bgit\s+(?:-C\s+\S+\s+)?commit\b/g;
207
+ for (let found = invocation.exec(command); found !== null; found = invocation.exec(command)) {
208
+ const rest = command.slice(found.index + found[0].length);
209
+ const heredoc = /^[^\n]*<<-?\s*(['"]?)(\w+)\1[^\n]*\r?\n([^\n]*)/.exec(rest);
210
+ if (heredoc) {
211
+ const subject = heredoc[3].trim();
212
+ if (subject) out.push(subject);
213
+ continue;
214
+ }
215
+ const inline = /^[^\n]*?-m\s+(['"])([\s\S]*?)\1/.exec(rest);
216
+ if (inline) {
217
+ const subject = inline[2].split("\n")[0].trim();
218
+ if (subject) out.push(subject);
219
+ }
220
+ }
221
+ return out;
222
+ }
223
+
224
+ // ../../packages/ingest-core/src/types.ts
225
+ var CONVERSATION_KINDS = [
226
+ "claude-code",
227
+ "cursor",
228
+ "codex",
229
+ "gemini",
230
+ "slack"
231
+ ];
232
+ var REFERENCE_KINDS = ["linear", "jira", "confluence"];
233
+ var SOURCE_KINDS = [
234
+ ...CONVERSATION_KINDS,
235
+ ...REFERENCE_KINDS
236
+ ];
237
+ var EMPTY_USAGE = {
238
+ inputTokens: null,
239
+ outputTokens: null,
240
+ cacheReadTokens: null,
241
+ cacheWriteTokens: null,
242
+ model: null
243
+ };
244
+
245
+ // ../../packages/ingest-core/src/claude-sessions.ts
246
+ var MIN_MEANINGFUL_LINE_LENGTH = 6;
247
+ function meaningfulLines(lines) {
248
+ return lines.map((l) => l.trim()).filter((l) => l.length >= MIN_MEANINGFUL_LINE_LENGTH);
249
+ }
250
+ function extractEditedLines(toolUseResult) {
251
+ if (!toolUseResult || typeof toolUseResult !== "object") return null;
252
+ const r = toolUseResult;
253
+ if (!r.filePath) return null;
254
+ const added = [];
255
+ const removed = [];
256
+ if (Array.isArray(r.structuredPatch) && r.structuredPatch.length > 0) {
257
+ for (const hunk of r.structuredPatch) {
258
+ for (const line of hunk.lines ?? []) {
259
+ if (line.startsWith("+")) added.push(line.slice(1));
260
+ else if (line.startsWith("-")) removed.push(line.slice(1));
261
+ }
262
+ }
263
+ } else if (r.type === "create" && typeof r.content === "string") {
264
+ added.push(...r.content.split("\n"));
265
+ } else {
266
+ return null;
267
+ }
268
+ const meaningfulAdded = meaningfulLines(added);
269
+ const meaningfulRemoved = meaningfulLines(removed);
270
+ if (meaningfulAdded.length === 0 && meaningfulRemoved.length === 0) return null;
271
+ return { path: r.filePath, added: meaningfulAdded, removed: meaningfulRemoved };
272
+ }
273
+ var MAX_TURN_TEXT_LENGTH = 4e3;
274
+ var FILE_PATH_TOOLS = /* @__PURE__ */ new Set(["Read", "Edit", "Write", "NotebookEdit"]);
275
+ var PATH_TOKEN_RE = /(?:[.~]?\/)?(?:[\w-]+\/)+[\w.-]+/g;
276
+ function extractPathsFromText(text) {
277
+ const matches = text.match(PATH_TOKEN_RE) ?? [];
278
+ return [...new Set(matches)].filter((p) => p.length > 3 && p.length < 300);
279
+ }
280
+ function blockText(content) {
281
+ if (typeof content === "string") return content;
282
+ if (Array.isArray(content)) {
283
+ return content.map((c) => typeof c === "string" ? c : c?.text ?? "").filter(Boolean).join("\n");
284
+ }
285
+ return "";
286
+ }
287
+ function extractFromAssistantContent(content) {
288
+ const textParts = [];
289
+ const filesTouched = [];
290
+ for (const block of content) {
291
+ if (block.type === "text" && block.text) {
292
+ textParts.push(block.text);
293
+ for (const p of extractPathsFromText(block.text)) {
294
+ filesTouched.push({ path: p, source: "prose" });
295
+ }
296
+ } else if (block.type === "tool_use") {
297
+ const name = block.name ?? "tool";
298
+ const input = block.input ?? {};
299
+ if (FILE_PATH_TOOLS.has(name) && typeof input.file_path === "string") {
300
+ textParts.push(`[tool_call: ${name}] ${input.file_path}`);
301
+ filesTouched.push({ path: input.file_path, source: "tool_path", tool: name });
302
+ } else if (name === "Bash" && typeof input.command === "string") {
303
+ const desc = typeof input.description === "string" ? input.description : input.command;
304
+ textParts.push(`[tool_call: Bash] ${desc}`);
305
+ for (const p of extractPathsFromText(input.command)) {
306
+ filesTouched.push({ path: p, source: "tool_bash", tool: "Bash" });
307
+ }
308
+ } else {
309
+ textParts.push(`[tool_call: ${name}]`);
310
+ }
311
+ }
312
+ }
313
+ return { text: textParts.join("\n"), filesTouched };
314
+ }
315
+ var SYNTHETIC_CONTENT_RE = /^\s*(<task-notification>|<system-reminder>|\[SYSTEM NOTIFICATION)/;
316
+ function extractFromUserContent(content) {
317
+ if (typeof content === "string") {
318
+ return {
319
+ text: content,
320
+ filesTouched: extractPathsFromText(content).map((path) => ({ path, source: "prose" })),
321
+ isSyntheticInput: SYNTHETIC_CONTENT_RE.test(content)
322
+ };
323
+ }
324
+ if (Array.isArray(content)) {
325
+ const toolParts = [];
326
+ const textParts = [];
327
+ for (const block of content) {
328
+ if (block.type === "tool_result") {
329
+ toolParts.push(blockText(block.content));
330
+ } else if (block.type === "text" && typeof block.text === "string") {
331
+ textParts.push(block.text);
332
+ }
333
+ }
334
+ if (toolParts.length > 0) {
335
+ return {
336
+ text: toolParts.join("\n"),
337
+ filesTouched: [],
338
+ isSyntheticInput: true
339
+ };
340
+ }
341
+ const text = textParts.join("\n");
342
+ return {
343
+ text,
344
+ // Same treatment the string branch gives prose, so a path a person
345
+ // names in a block-formatted message is found too.
346
+ filesTouched: extractPathsFromText(text).map((path) => ({
347
+ path,
348
+ source: "prose"
349
+ })),
350
+ isSyntheticInput: SYNTHETIC_CONTENT_RE.test(text)
351
+ };
352
+ }
353
+ return { text: "", filesTouched: [], isSyntheticInput: false };
354
+ }
355
+ function usageOnce(record, billed) {
356
+ const message = record.message;
357
+ const raw = message?.usage;
358
+ if (!raw || typeof raw !== "object") return { ...EMPTY_USAGE };
359
+ const messageId = message?.id;
360
+ if (!messageId || billed.has(messageId)) return { ...EMPTY_USAGE };
361
+ billed.add(messageId);
362
+ const num = (key) => {
363
+ const value = raw[key];
364
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
365
+ };
366
+ return {
367
+ inputTokens: num("input_tokens"),
368
+ outputTokens: num("output_tokens"),
369
+ cacheReadTokens: num("cache_read_input_tokens"),
370
+ cacheWriteTokens: num("cache_creation_input_tokens"),
371
+ model: typeof message?.model === "string" ? message.model : null
372
+ };
373
+ }
374
+ function parseSessionFile(filePath, repoPath, repoId = deriveRepoId(repoPath)) {
375
+ const raw = readFileSync(filePath, "utf-8");
376
+ const lines = raw.split("\n").filter((l) => l.trim().length > 0);
377
+ const turns = [];
378
+ const billedMessages = /* @__PURE__ */ new Set();
379
+ let sessionId = null;
380
+ const cwd = repoPath;
381
+ let aiTitle = null;
382
+ let totalRedactions = 0;
383
+ for (const line of lines) {
384
+ let record;
385
+ try {
386
+ record = JSON.parse(line);
387
+ } catch {
388
+ continue;
389
+ }
390
+ if (record.type === "ai-title" && typeof record.aiTitle === "string") {
391
+ aiTitle = record.aiTitle;
392
+ continue;
393
+ }
394
+ if (record.type !== "user" && record.type !== "assistant") continue;
395
+ const id = record.uuid;
396
+ const ts = record.timestamp;
397
+ if (!id || !ts) continue;
398
+ sessionId ??= record.sessionId ?? record.session_id ?? null;
399
+ let text = "";
400
+ let filesTouched = [];
401
+ let editedLines = [];
402
+ let isSyntheticInput = false;
403
+ if (record.type === "assistant") {
404
+ const content = record.message?.content;
405
+ if (Array.isArray(content)) {
406
+ const extracted = extractFromAssistantContent(content);
407
+ text = extracted.text;
408
+ filesTouched = extracted.filesTouched;
409
+ }
410
+ } else {
411
+ const extracted = extractFromUserContent(record.message?.content);
412
+ text = extracted.text;
413
+ filesTouched = extracted.filesTouched;
414
+ isSyntheticInput = extracted.isSyntheticInput;
415
+ const edited = extractEditedLines(record.toolUseResult);
416
+ if (edited) editedLines = [edited];
417
+ }
418
+ const redacted = redactSecrets(text);
419
+ totalRedactions += redacted.count;
420
+ turns.push({
421
+ id,
422
+ sessionId: sessionId ?? "unknown",
423
+ role: record.type,
424
+ ts,
425
+ text: redacted.text.slice(0, MAX_TURN_TEXT_LENGTH),
426
+ filesTouched,
427
+ editedLines,
428
+ parentUuid: record.parentUuid ?? null,
429
+ isSidechain: Boolean(record.isSidechain),
430
+ redacted: redacted.count > 0,
431
+ isSyntheticInput,
432
+ usage: usageOnce(record, billedMessages)
433
+ });
434
+ }
435
+ if (!sessionId || turns.length === 0) return null;
436
+ const sortedTs = turns.map((t) => t.ts).sort();
437
+ const rawContent = lines.map((line) => {
438
+ try {
439
+ return JSON.stringify(redactJsonValue(JSON.parse(line)).value);
440
+ } catch {
441
+ return redactSecrets(line).text;
442
+ }
443
+ }).join("\n");
444
+ if (totalRedactions > 0) {
445
+ console.log(`[ingest-core] redacted ${totalRedactions} potential secret(s) in session ${sessionId}`);
446
+ }
447
+ return {
448
+ id: sessionId,
449
+ agentKind: "claude-code",
450
+ repoId,
451
+ cwd,
452
+ startedAt: sortedTs[0] ?? null,
453
+ endedAt: sortedTs[sortedTs.length - 1] ?? null,
454
+ turnCount: turns.length,
455
+ aiTitle,
456
+ author: null,
457
+ // filled in by the caller — see getGitUserName in git-history.ts
458
+ sourceFile: filePath,
459
+ redactionCount: totalRedactions,
460
+ committedSubjects: collectCommittedSubjects(lines),
461
+ rawContent,
462
+ rawFormat: "jsonl",
463
+ turns
464
+ };
465
+ }
466
+ function collectCommittedSubjects(lines) {
467
+ const subjects = /* @__PURE__ */ new Set();
468
+ for (const line of lines) {
469
+ if (!line.includes("git commit")) continue;
470
+ let record;
471
+ try {
472
+ record = JSON.parse(line);
473
+ } catch {
474
+ continue;
475
+ }
476
+ const content = record?.message?.content;
477
+ if (!Array.isArray(content)) continue;
478
+ for (const block of content) {
479
+ if (block?.type !== "tool_use") continue;
480
+ const command = block?.input?.command;
481
+ if (typeof command !== "string") continue;
482
+ for (const subject of committedSubjects(command)) subjects.add(subject);
483
+ }
484
+ }
485
+ return [...subjects];
486
+ }
487
+
488
+ // ../../packages/ingest-core/src/derive-uuid.ts
489
+ import { createHash } from "node:crypto";
490
+ function deriveUuid(name) {
491
+ const h = createHash("sha1").update(name).digest("hex");
492
+ const variant = (parseInt(h.slice(16, 17) || "0", 16) & 3 | 8).toString(16);
493
+ const s = h.slice(0, 12) + // time-low + time-mid
494
+ "5" + // version 5 (name-based, SHA-1)
495
+ h.slice(13, 16) + variant + h.slice(17, 32);
496
+ return `${s.slice(0, 8)}-${s.slice(8, 12)}-${s.slice(12, 16)}-${s.slice(16, 20)}-${s.slice(20, 32)}`;
497
+ }
498
+
499
+ // ../../packages/ingest-core/src/codex-sessions.ts
500
+ import { createHash as createHash2 } from "node:crypto";
501
+ import { existsSync as existsSync2, readFileSync as readFileSync2, readdirSync as readdirSync2, statSync as statSync2 } from "node:fs";
502
+ function deriveUuid2(name) {
503
+ const h = createHash2("sha1").update(name).digest("hex");
504
+ const variant = (parseInt(h.slice(16, 17) || "0", 16) & 3 | 8).toString(16);
505
+ const s = h.slice(0, 12) + "5" + h.slice(13, 16) + variant + h.slice(17, 32);
506
+ return `${s.slice(0, 8)}-${s.slice(8, 12)}-${s.slice(12, 16)}-${s.slice(16, 20)}-${s.slice(20, 32)}`;
507
+ }
508
+ function deriveCodexTurnId(sessionId, ordinal) {
509
+ return deriveUuid2(`evrex-codex-turn\0${sessionId}\0${ordinal}`);
510
+ }
511
+ function parseLines(raw) {
512
+ const out = [];
513
+ for (const line of raw.split("\n")) {
514
+ if (!line.trim()) continue;
515
+ try {
516
+ out.push(JSON.parse(line));
517
+ } catch {
518
+ continue;
519
+ }
520
+ }
521
+ return out;
522
+ }
523
+ function payloadType(line) {
524
+ const t = line.payload?.type;
525
+ return typeof t === "string" ? t : void 0;
526
+ }
527
+ function asString(value) {
528
+ return typeof value === "string" && value.length > 0 ? value : null;
529
+ }
530
+ function conversationEvents(lines) {
531
+ const out = [];
532
+ let pending = null;
533
+ let model = null;
534
+ lines.forEach((line, index) => {
535
+ if (line.type === "turn_context") {
536
+ model = asString(line.payload?.model) ?? model;
537
+ return;
538
+ }
539
+ if (line.type !== "event_msg") return;
540
+ const kind = payloadType(line);
541
+ if (kind === "token_count") {
542
+ const delta = lastUsage(line.payload);
543
+ if (delta) pending = addUsage(pending, delta);
544
+ return;
545
+ }
546
+ if (kind !== "user_message" && kind !== "agent_message") return;
547
+ const text = asString(line.payload?.message);
548
+ if (!text) return;
549
+ const role = kind === "user_message" ? "user" : "assistant";
550
+ const usage = role === "assistant" && pending ? { ...pending, model: pending.model ?? model } : { ...EMPTY_USAGE };
551
+ if (role === "assistant") pending = null;
552
+ out.push({ role, text, ts: asString(line.timestamp), ordinal: index, usage });
553
+ });
554
+ return out;
555
+ }
556
+ function lastUsage(payload) {
557
+ const info = payload?.info;
558
+ const last = info?.last_token_usage;
559
+ if (!last) return null;
560
+ const num = (key) => {
561
+ const value = last[key];
562
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
563
+ };
564
+ const cached = num("cached_input_tokens");
565
+ return {
566
+ inputTokens: Math.max(0, num("input_tokens") - cached),
567
+ outputTokens: num("output_tokens"),
568
+ cacheReadTokens: cached,
569
+ // Codex reports no cache-write figure; unknown rather than zero.
570
+ cacheWriteTokens: null,
571
+ model: null
572
+ };
573
+ }
574
+ function addUsage(into, next) {
575
+ if (!into) return { ...next };
576
+ const sum = (a, b) => a === null && b === null ? null : (a ?? 0) + (b ?? 0);
577
+ return {
578
+ inputTokens: sum(into.inputTokens, next.inputTokens),
579
+ outputTokens: sum(into.outputTokens, next.outputTokens),
580
+ cacheReadTokens: sum(into.cacheReadTokens, next.cacheReadTokens),
581
+ cacheWriteTokens: sum(into.cacheWriteTokens, next.cacheWriteTokens),
582
+ model: into.model ?? next.model
583
+ };
584
+ }
585
+ function committedSubjectsFromToolCalls(lines) {
586
+ const subjects = /* @__PURE__ */ new Set();
587
+ for (const line of lines) {
588
+ if (line.type !== "response_item") continue;
589
+ if (payloadType(line) !== "custom_tool_call") continue;
590
+ const input = asString(line.payload?.input);
591
+ if (!input) continue;
592
+ for (const subject of committedSubjects(input)) subjects.add(subject);
593
+ }
594
+ return [...subjects];
595
+ }
596
+ function filesFromToolCalls(lines) {
597
+ const byPath = /* @__PURE__ */ new Map();
598
+ for (const line of lines) {
599
+ if (line.type !== "response_item") continue;
600
+ if (payloadType(line) !== "custom_tool_call") continue;
601
+ const input = asString(line.payload?.input);
602
+ if (!input) continue;
603
+ const tool = asString(line.payload?.name) ?? "exec";
604
+ for (const path of extractPathsFromText(input)) {
605
+ if (!byPath.has(path)) byPath.set(path, { path, source: "tool_bash", tool });
606
+ }
607
+ }
608
+ return [...byPath.values()];
609
+ }
610
+ function parseCodexSessionFile(filePath, repoPath, repoId) {
611
+ let raw;
612
+ try {
613
+ raw = readFileSync2(filePath, "utf-8");
614
+ } catch {
615
+ return null;
616
+ }
617
+ const lines = parseLines(raw);
618
+ const meta = lines.find((l) => l.type === "session_meta")?.payload;
619
+ if (!meta) return null;
620
+ const cwd = asString(meta.cwd);
621
+ if (!cwd) return null;
622
+ if (cwd.replace(/\/+$/, "") !== repoPath.replace(/\/+$/, "")) return null;
623
+ const sessionId = asString(meta.session_id) ?? asString(meta.id);
624
+ if (!sessionId) return null;
625
+ const events = conversationEvents(lines);
626
+ if (events.length === 0) return null;
627
+ const filesTouched = filesFromToolCalls(lines);
628
+ let redactionCount = 0;
629
+ const turns = events.map((event, i) => {
630
+ const { text, count } = redactSecrets(event.text);
631
+ redactionCount += count;
632
+ return {
633
+ id: deriveCodexTurnId(sessionId, event.ordinal),
634
+ sessionId,
635
+ role: event.role,
636
+ ts: event.ts ?? asString(meta.timestamp) ?? (/* @__PURE__ */ new Date()).toISOString(),
637
+ text,
638
+ // Attributed to the assistant turns, which are the ones that ran the
639
+ // tools; a user turn touched no files.
640
+ filesTouched: event.role === "assistant" && i === events.length - 1 ? filesTouched : [],
641
+ editedLines: [],
642
+ parentUuid: null,
643
+ isSidechain: false,
644
+ redacted: count > 0,
645
+ isSyntheticInput: false,
646
+ usage: event.usage
647
+ };
648
+ });
649
+ const { content: rawContent, count: rawRedactions } = redactRollout(raw);
650
+ redactionCount += rawRedactions;
651
+ const firstUser = events.find((e) => e.role === "user");
652
+ return {
653
+ id: sessionId,
654
+ agentKind: "codex",
655
+ // Codex records the remote itself, so identity survives a moved or deleted
656
+ // checkout. Falls back to the caller's derivation when it is absent.
657
+ repoId: repoIdFromMeta(meta) ?? repoId,
658
+ cwd,
659
+ startedAt: asString(meta.timestamp) ?? events[0]?.ts ?? null,
660
+ endedAt: events[events.length - 1]?.ts ?? null,
661
+ turnCount: turns.length,
662
+ committedSubjects: committedSubjectsFromToolCalls(lines),
663
+ aiTitle: firstUser ? firstUser.text.slice(0, 120).trim() : null,
664
+ author: null,
665
+ sourceFile: filePath,
666
+ redactionCount,
667
+ turns,
668
+ rawContent,
669
+ rawFormat: "jsonl"
670
+ };
671
+ }
672
+ function repoIdFromMeta(meta) {
673
+ const git = meta.git;
674
+ if (typeof git !== "object" || git === null) return null;
675
+ const url = asString(git.repository_url);
676
+ if (!url) return null;
677
+ const normalized = normalizeRepoRemote(url);
678
+ return normalized ? `remote:${normalized}` : null;
679
+ }
680
+ function redactRollout(raw) {
681
+ let count = 0;
682
+ const lines = raw.split("\n").map((line) => {
683
+ if (!line.trim()) return line;
684
+ const { text, count: n } = redactSecrets(line);
685
+ count += n;
686
+ return text;
687
+ });
688
+ return { content: lines.join("\n"), count };
689
+ }
690
+
691
+ // ../../packages/ingest-core/src/sanitize.ts
692
+ var NUL = String.fromCharCode(0);
693
+
694
+ // ../../packages/ingest-core/src/gemini-sessions.ts
695
+ import { readFileSync as readFileSync3 } from "node:fs";
696
+ var SYNTHETIC = /^\s*<session_context>/;
697
+ function textOf(content) {
698
+ if (typeof content === "string") return content;
699
+ if (!Array.isArray(content)) return "";
700
+ return content.map(
701
+ (part) => part && typeof part === "object" && typeof part.text === "string" ? part.text : ""
702
+ ).filter(Boolean).join("\n");
703
+ }
704
+ function parseGeminiSessionFile(filePath, repoPath, repoId) {
705
+ let raw;
706
+ try {
707
+ raw = readFileSync3(filePath, "utf-8");
708
+ } catch {
709
+ return null;
710
+ }
711
+ const records = [];
712
+ for (const line of raw.split("\n")) {
713
+ if (!line.trim()) continue;
714
+ try {
715
+ records.push(JSON.parse(line));
716
+ } catch {
717
+ }
718
+ }
719
+ const meta = records.find((r) => r.sessionId && r.startTime);
720
+ if (!meta?.sessionId) return null;
721
+ let messages = [];
722
+ for (const record of records) {
723
+ const next = record.$set?.messages ?? record.messages;
724
+ if (Array.isArray(next)) messages = next;
725
+ }
726
+ let redactionCount = 0;
727
+ const turns = [];
728
+ messages.forEach((record, index) => {
729
+ const role = record.type === "user" ? "user" : record.type === "gemini" || record.type === "model" ? "assistant" : null;
730
+ if (!role) return;
731
+ const text = textOf(record.content ?? record.displayContent);
732
+ if (!text.trim()) return;
733
+ const { text: safe, count } = redactSecrets(text);
734
+ redactionCount += count;
735
+ turns.push({
736
+ // Gemini's own record id when it has one, so a re-read lands on the same
737
+ // row; a derived id keyed on position otherwise, which is stable for an
738
+ // append-only file.
739
+ id: record.id ? deriveUuid(`evrex-gemini-turn ${meta.sessionId} ${record.id}`) : deriveUuid(`evrex-gemini-turn ${meta.sessionId} ${index}`),
740
+ sessionId: deriveUuid(`evrex-gemini-session ${meta.sessionId}`),
741
+ role,
742
+ ts: record.timestamp ?? meta.startTime ?? (/* @__PURE__ */ new Date()).toISOString(),
743
+ text: safe,
744
+ filesTouched: [],
745
+ editedLines: [],
746
+ parentUuid: null,
747
+ isSidechain: false,
748
+ redacted: count > 0,
749
+ // Gemini opens every session with a `<session_context>` block delivered
750
+ // as a user message. Attributing that to the person is the same bug this
751
+ // repo already fixed once for Claude Code's tool results.
752
+ isSyntheticInput: role === "user" && SYNTHETIC.test(text),
753
+ // Gemini records a model per message but no token counts in the
754
+ // transcript; unknown rather than zero.
755
+ usage: { ...EMPTY_USAGE, model: record.model ?? null }
756
+ });
757
+ });
758
+ if (turns.length === 0) return null;
759
+ const { text: rawContent, count: rawRedactions } = redactSecrets(raw);
760
+ const firstUser = turns.find((t) => t.role === "user" && !t.isSyntheticInput);
761
+ return {
762
+ id: deriveUuid(`evrex-gemini-session ${meta.sessionId}`),
763
+ agentKind: "gemini",
764
+ repoId,
765
+ cwd: meta.directories?.[0] ?? repoPath,
766
+ startedAt: meta.startTime ?? turns[0]?.ts ?? null,
767
+ endedAt: turns[turns.length - 1]?.ts ?? null,
768
+ turnCount: turns.length,
769
+ committedSubjects: [],
770
+ aiTitle: firstUser ? firstUser.text.slice(0, 120).trim() : null,
771
+ author: null,
772
+ sourceFile: filePath,
773
+ redactionCount: redactionCount + rawRedactions,
774
+ turns,
775
+ rawContent,
776
+ rawFormat: "jsonl"
777
+ };
778
+ }
779
+
780
+ // ../../packages/ingest-core/src/transcript-parsers.ts
781
+ var PARSERS = [parseSessionFile, parseCodexSessionFile, parseGeminiSessionFile];
782
+ function parseTranscriptFile(path, repoPath, repoId) {
783
+ for (const parse of PARSERS) {
784
+ const parsed = parse(path, repoPath, repoId);
785
+ if (parsed) return parsed;
786
+ }
787
+ return null;
788
+ }
789
+
790
+ // src/capture-state.ts
791
+ import { existsSync as existsSync3, mkdirSync, readFileSync as readFileSync4, renameSync, writeFileSync } from "node:fs";
792
+ import { homedir } from "node:os";
793
+ import { dirname, join } from "node:path";
794
+ var EMPTY = { sessions: {} };
795
+ function stateDir(home = homedir()) {
796
+ return join(home, ".evrex");
797
+ }
798
+ function statePath(home = homedir()) {
799
+ return join(stateDir(home), "capture-state.json");
800
+ }
801
+ function readState(path) {
802
+ try {
803
+ const parsed = JSON.parse(readFileSync4(path, "utf8"));
804
+ if (!parsed || typeof parsed !== "object" || typeof parsed.sessions !== "object") {
805
+ return { sessions: {} };
806
+ }
807
+ return { sessions: parsed.sessions ?? {} };
808
+ } catch {
809
+ return { sessions: {} };
810
+ }
811
+ }
812
+ function writeState(path, state) {
813
+ try {
814
+ mkdirSync(dirname(path), { recursive: true });
815
+ const tmp = `${path}.${process.pid}.tmp`;
816
+ writeFileSync(tmp, JSON.stringify(state), { mode: 384 });
817
+ renameSync(tmp, path);
818
+ } catch {
819
+ }
820
+ }
821
+ function orphaned(state, exceptSessionId) {
822
+ return Object.entries(state.sessions).filter(([id, s]) => !s.archived && id !== exceptSessionId).map(([, s]) => s).filter((s) => existsSync3(s.transcriptPath));
823
+ }
824
+
825
+ // src/spool.ts
826
+ import { appendFileSync, existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync5, unlinkSync, writeFileSync as writeFileSync2 } from "node:fs";
827
+ import { dirname as dirname2 } from "node:path";
828
+ var MAX_SPOOL_BYTES = 32 * 1024 * 1024;
829
+ function append(spoolPath, post2) {
830
+ try {
831
+ mkdirSync2(dirname2(spoolPath), { recursive: true });
832
+ if (existsSync4(spoolPath) && sizeOf(spoolPath) >= MAX_SPOOL_BYTES) return;
833
+ appendFileSync(spoolPath, `${JSON.stringify(post2)}
834
+ `, { mode: 384 });
835
+ } catch {
836
+ }
837
+ }
838
+ function read(spoolPath) {
839
+ try {
840
+ return readFileSync5(spoolPath, "utf8").split("\n").filter((line) => line.trim().length > 0).flatMap((line) => {
841
+ try {
842
+ return [JSON.parse(line)];
843
+ } catch {
844
+ return [];
845
+ }
846
+ });
847
+ } catch {
848
+ return [];
849
+ }
850
+ }
851
+ function clear(spoolPath) {
852
+ try {
853
+ unlinkSync(spoolPath);
854
+ } catch {
855
+ }
856
+ }
857
+ function keep(spoolPath, remaining) {
858
+ try {
859
+ if (remaining.length === 0) return clear(spoolPath);
860
+ writeFileSync2(spoolPath, remaining.map((p) => `${JSON.stringify(p)}
861
+ `).join(""), {
862
+ mode: 384
863
+ });
864
+ } catch {
865
+ }
866
+ }
867
+ function sizeOf(path) {
868
+ try {
869
+ return readFileSync5(path).byteLength;
870
+ } catch {
871
+ return 0;
872
+ }
873
+ }
874
+
875
+ // src/capture.ts
876
+ var DEADLINE_MS = 6e3;
877
+ var MAX_TRANSCRIPT_BYTES = 256 * 1024 * 1024;
878
+ function readStdin() {
879
+ return new Promise((resolve) => {
880
+ let raw = "";
881
+ process.stdin.setEncoding("utf8");
882
+ process.stdin.on("data", (c) => raw += c);
883
+ process.stdin.on("end", () => resolve(raw));
884
+ process.stdin.on("error", () => resolve(""));
885
+ });
886
+ }
887
+ function withDeadline(work, ms) {
888
+ return Promise.race([
889
+ work.catch(() => null),
890
+ new Promise((r) => setTimeout(() => r(null), ms))
891
+ ]);
892
+ }
893
+ function parseTranscript(path, cwd) {
894
+ try {
895
+ const stat = statSync3(path);
896
+ if (!stat.isFile() || stat.size > MAX_TRANSCRIPT_BYTES) return null;
897
+ return parseTranscriptFile(path, cwd, deriveRepoId(cwd));
898
+ } catch {
899
+ return null;
900
+ }
901
+ }
902
+ async function drainSpool(spoolPath, deps) {
903
+ const queued = read(spoolPath);
904
+ if (queued.length === 0) return { sent: 0, kept: 0 };
905
+ let sent = 0;
906
+ for (const post2 of queued) {
907
+ if (!await deps.post(post2.path, post2.body)) break;
908
+ sent += 1;
909
+ }
910
+ const remaining = queued.slice(sent);
911
+ keep(spoolPath, remaining);
912
+ return { sent, kept: remaining.length };
913
+ }
914
+ async function deliver(spoolPath, path, body, deps) {
915
+ const ok = await deps.post(path, body);
916
+ if (!ok) {
917
+ append(spoolPath, { path, body, queuedAt: deps.now().toISOString() });
918
+ }
919
+ return ok;
920
+ }
921
+ async function handleEvent(event, deps) {
922
+ const name = event.hook_event_name;
923
+ const sessionId = event.session_id;
924
+ if (!name || !sessionId) return null;
925
+ const spoolPath = join2(stateDir(deps.home), "spool.jsonl");
926
+ const drained = await drainSpool(spoolPath, deps);
927
+ const path = statePath(deps.home);
928
+ const state = readState(path) ?? EMPTY;
929
+ const prior = state.sessions[sessionId];
930
+ const transcriptPath = event.transcript_path ?? prior?.transcriptPath;
931
+ if (name === "SessionStart") {
932
+ for (const open of orphaned(state, sessionId)) {
933
+ const parsed2 = parseTranscript(open.transcriptPath, event.cwd ?? process.cwd());
934
+ if (!parsed2) continue;
935
+ await deliver(spoolPath, "/ingest/sessions", { sessions: [parsed2] }, deps);
936
+ }
937
+ }
938
+ if (!transcriptPath) {
939
+ return { event: name, turnsSent: 0, archived: false, spoolDrained: drained.sent };
940
+ }
941
+ const parsed = parseTranscript(transcriptPath, event.cwd ?? process.cwd());
942
+ const sentThrough = prior?.sentThrough ?? 0;
943
+ let turnsSent = 0;
944
+ if (parsed && parsed.turnCount > sentThrough) {
945
+ const ok = await deliver(
946
+ spoolPath,
947
+ "/ingest/sessions",
948
+ { sessions: [parsed] },
949
+ deps
950
+ );
951
+ turnsSent = ok ? parsed.turnCount - sentThrough : 0;
952
+ }
953
+ const ending = name === "SessionEnd";
954
+ let archived = prior?.archived ?? false;
955
+ if (ending && !archived && parsed?.rawContent) {
956
+ archived = await deliver(
957
+ spoolPath,
958
+ `/ingest/sessions/${sessionId}/transcript`,
959
+ { rawContent: parsed.rawContent, rawFormat: parsed.rawFormat ?? "jsonl" },
960
+ deps
961
+ );
962
+ }
963
+ state.sessions[sessionId] = {
964
+ transcriptPath,
965
+ sentThrough: parsed?.turnCount ?? sentThrough,
966
+ startedAt: prior?.startedAt ?? deps.now().toISOString(),
967
+ archived
968
+ };
969
+ writeState(path, state);
970
+ return { event: name, turnsSent, archived, spoolDrained: drained.sent };
971
+ }
972
+ async function main() {
973
+ const raw = await withDeadline(readStdin(), DEADLINE_MS);
974
+ if (!raw) return;
975
+ let event;
976
+ try {
977
+ event = JSON.parse(raw);
978
+ } catch {
979
+ return;
980
+ }
981
+ const { evrexApi: evrexApi2 } = await Promise.resolve().then(() => (init_client(), client_exports));
982
+ const deps = {
983
+ now: () => /* @__PURE__ */ new Date(),
984
+ home: homedir2(),
985
+ machine: hostname(),
986
+ post: async (path, body) => {
987
+ try {
988
+ const res = await fetch(`${evrexApi2.baseUrl}${path}`, {
989
+ method: "POST",
990
+ headers: {
991
+ "content-type": "application/json",
992
+ ...process.env.EVREX_TOKEN ? { authorization: `Bearer ${process.env.EVREX_TOKEN}` } : {}
993
+ },
994
+ body: JSON.stringify(body)
995
+ });
996
+ return res.ok;
997
+ } catch {
998
+ return false;
999
+ }
1000
+ }
1001
+ };
1002
+ await withDeadline(handleEvent(event, deps), DEADLINE_MS);
1003
+ }
1004
+ main().then(
1005
+ () => process.exit(0),
1006
+ () => process.exit(0)
1007
+ );
1008
+ export {
1009
+ drainSpool,
1010
+ handleEvent,
1011
+ parseTranscript
1012
+ };