evrex-mcp 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/capture.js +1012 -0
- package/dist/hook.js +134 -0
- package/dist/import.js +1667 -0
- package/dist/index.js +734 -109
- package/dist/tickets.js +636 -0
- package/package.json +11 -2
package/dist/capture.js
ADDED
|
@@ -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
|
+
};
|