octarin-cli 0.2.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,487 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * hook.mjs — Codex -> Octarin capture hook (single unbundled file, zero deps).
4
+ *
5
+ * Wired as a Codex `notify` / stop hook: Codex invokes it at turn/session end,
6
+ * passing a JSON payload either as argv[2] or on stdin. The payload carries the
7
+ * turn id, the last assistant message, and the cwd. This builds one canonical
8
+ * IngestEvent (full `spans` form) and POSTs it to
9
+ * `${OCTARIN_INGEST_URL:-$OCTARIN_API_BASE/v1/ingest}` with
10
+ * `Authorization: Bearer $OCTARIN_API_KEY`.
11
+ *
12
+ * TOKEN USAGE: Codex's `notify` payload does NOT carry token counts (it only
13
+ * has type / turn-id / input-messages / last-assistant-message / cwd). So we
14
+ * read usage from the session's rollout JSONL that Codex writes under
15
+ * `~/.codex/sessions/.../rollout-*.jsonl`. Those carry `event_msg` records of
16
+ * type `token_count`, whose `info.total_token_usage` holds the cumulative
17
+ * input / cached_input / output / total counts for the whole session. We locate
18
+ * the current session's rollout file (by session id if the payload exposes one,
19
+ * else the most-recently-modified file matching the payload `cwd`), read the
20
+ * last `token_count` event, and populate the span + event token fields from it.
21
+ *
22
+ * OpenAI's `input_tokens` is the FULL prompt count INCLUDING cached tokens, so
23
+ * billable (uncached) input = input_tokens - cached_input_tokens; the cached
24
+ * part rides in `cache_read_tokens` (the backend bills it at the cache rate and
25
+ * adds it into input cost — see backend/app/schema/prices.py::compute_cost).
26
+ *
27
+ * Pure Node stdlib (`node:https`, `node:fs`), hard 5s timeout, fail-open: any
28
+ * error exits 0 so Codex is never blocked, and a missing/unparseable rollout
29
+ * file just yields 0 tokens (capture is never broken).
30
+ * Shape: backend/app/schema/canonical.py::IngestEvent.
31
+ */
32
+
33
+ import https from "node:https";
34
+ import http from "node:http";
35
+ import crypto from "node:crypto";
36
+ import os from "node:os";
37
+ import fs from "node:fs";
38
+ import path from "node:path";
39
+ import { pathToFileURL } from "node:url";
40
+ import { execFileSync } from "node:child_process";
41
+
42
+ const SOURCE = "codex";
43
+ const MAX_TEXT = 20000;
44
+ const HTTP_TIMEOUT_MS = 5000;
45
+ const TRACE_NAMESPACE = "6f8d2c1e-9a3b-4f5e-8c7d-1a2b3c4d5e6f";
46
+ // Only consider rollout files this much newer/older than "now" when matching
47
+ // the active session by recency, so a stale file from another machine/day is
48
+ // never picked up. The active session's file is written to seconds ago.
49
+ const ROLLOUT_RECENT_MS = 6 * 60 * 60 * 1000; // 6h
50
+
51
+ function truncate(text) {
52
+ if (typeof text !== "string") return text == null ? "" : String(text);
53
+ return text.length <= MAX_TEXT ? text : text.slice(0, MAX_TEXT);
54
+ }
55
+
56
+ function gitEmail() {
57
+ try {
58
+ return execFileSync("git", ["config", "user.email"], {
59
+ stdio: ["ignore", "pipe", "ignore"],
60
+ })
61
+ .toString()
62
+ .trim();
63
+ } catch {
64
+ return "";
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Resolve the engineer's real identity for attribution.
70
+ *
71
+ * Priority: an explicit OCTARIN_USER override → the git user.email → the OS
72
+ * username. We attribute to a real person (matching backfill.py + the per-user
73
+ * ingest key) rather than an opaque per-machine hash. When a per-user key is
74
+ * present the server overrides this with the key owner anyway; a real identity
75
+ * here is what ANONYMOUS (slug-only) sends rely on.
76
+ */
77
+ function userRef() {
78
+ const env = (process.env.OCTARIN_USER || "").trim();
79
+ if (env) return env;
80
+ const email = gitEmail();
81
+ if (email) return email;
82
+ return os.userInfo().username || "unknown";
83
+ }
84
+
85
+ function uuid5(name) {
86
+ const ns = Buffer.from(TRACE_NAMESPACE.replace(/-/g, ""), "hex");
87
+ const hash = crypto.createHash("sha1").update(Buffer.concat([ns, Buffer.from(name, "utf8")])).digest();
88
+ const b = hash.subarray(0, 16);
89
+ b[6] = (b[6] & 0x0f) | 0x50;
90
+ b[8] = (b[8] & 0x3f) | 0x80;
91
+ const h = b.toString("hex");
92
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
93
+ }
94
+
95
+ function readStdin() {
96
+ return new Promise((resolve) => {
97
+ if (process.stdin.isTTY) return resolve("");
98
+ let data = "";
99
+ process.stdin.setEncoding("utf8");
100
+ process.stdin.on("data", (c) => (data += c));
101
+ process.stdin.on("end", () => resolve(data));
102
+ process.stdin.on("error", () => resolve(data));
103
+ // Guard against a stdin that never closes.
104
+ setTimeout(() => resolve(data), 1000).unref?.();
105
+ });
106
+ }
107
+
108
+ async function readPayload() {
109
+ // Codex passes the JSON either as the last argv or on stdin.
110
+ for (const arg of process.argv.slice(2)) {
111
+ try {
112
+ const parsed = JSON.parse(arg);
113
+ if (parsed && typeof parsed === "object") return parsed;
114
+ } catch {
115
+ /* not json arg */
116
+ }
117
+ }
118
+ const raw = await readStdin();
119
+ try {
120
+ return raw.trim() ? JSON.parse(raw) : {};
121
+ } catch {
122
+ return {};
123
+ }
124
+ }
125
+
126
+ function postEvent(event) {
127
+ return new Promise((resolve) => {
128
+ let url = process.env.OCTARIN_INGEST_URL;
129
+ if (!url) {
130
+ const base = (process.env.OCTARIN_API_BASE || "").replace(/\/+$/, "");
131
+ if (!base) return resolve(false);
132
+ url = `${base}/v1/ingest`;
133
+ }
134
+ let parsed;
135
+ try {
136
+ parsed = new URL(url);
137
+ } catch {
138
+ return resolve(false);
139
+ }
140
+ const body = Buffer.from(JSON.stringify(event), "utf8");
141
+ const headers = { "Content-Type": "application/json", "Content-Length": body.length };
142
+ if (process.env.OCTARIN_API_KEY) headers.Authorization = `Bearer ${process.env.OCTARIN_API_KEY}`;
143
+ const lib = parsed.protocol === "http:" ? http : https;
144
+ const req = lib.request(
145
+ {
146
+ method: "POST",
147
+ hostname: parsed.hostname,
148
+ port: parsed.port || (parsed.protocol === "http:" ? 80 : 443),
149
+ path: parsed.pathname + parsed.search,
150
+ headers,
151
+ timeout: HTTP_TIMEOUT_MS,
152
+ },
153
+ (res) => {
154
+ res.on("data", () => {});
155
+ res.on("end", () => resolve(res.statusCode >= 200 && res.statusCode < 300));
156
+ },
157
+ );
158
+ req.on("error", () => resolve(false));
159
+ req.on("timeout", () => {
160
+ req.destroy();
161
+ resolve(false);
162
+ });
163
+ req.write(body);
164
+ req.end();
165
+ });
166
+ }
167
+
168
+ /** The Codex sessions root, honouring CODEX_HOME (defaults to ~/.codex). */
169
+ function codexSessionsRoot() {
170
+ const home = process.env.CODEX_HOME
171
+ ? process.env.CODEX_HOME
172
+ : path.join(os.homedir() || "", ".codex");
173
+ return path.join(home, "sessions");
174
+ }
175
+
176
+ /**
177
+ * Recursively collect rollout-*.jsonl files under `dir`. Bounded + fail-open:
178
+ * returns `[]` on any error and stops once `limit` files are gathered. The
179
+ * sessions tree is shallow (YYYY/MM/DD), so this stays cheap.
180
+ */
181
+ function collectRolloutFiles(dir, out, limit) {
182
+ if (out.length >= limit) return;
183
+ let entries;
184
+ try {
185
+ entries = fs.readdirSync(dir, { withFileTypes: true });
186
+ } catch {
187
+ return;
188
+ }
189
+ for (const ent of entries) {
190
+ if (out.length >= limit) return;
191
+ const full = path.join(dir, ent.name);
192
+ if (ent.isDirectory()) {
193
+ collectRolloutFiles(full, out, limit);
194
+ } else if (ent.isFile() && ent.name.startsWith("rollout-") && ent.name.endsWith(".jsonl")) {
195
+ out.push(full);
196
+ }
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Locate the rollout file for the active session.
202
+ *
203
+ * 1. If a session id is known, prefer the file whose name embeds it
204
+ * (rollout files are named `rollout-<iso>-<session-id>.jsonl`).
205
+ * 2. Otherwise (the common case — notify omits the id), pick the most
206
+ * recently modified rollout file. If `cwd` is known, restrict to files
207
+ * whose `session_meta.cwd` matches before falling back to plain recency.
208
+ *
209
+ * Returns an absolute path or null. Never throws.
210
+ */
211
+ function findRolloutFile(sessionId, cwd) {
212
+ const root = codexSessionsRoot();
213
+ const files = [];
214
+ collectRolloutFiles(root, files, 5000);
215
+ if (files.length === 0) return null;
216
+
217
+ if (sessionId) {
218
+ const byId = files.find((f) => path.basename(f).includes(sessionId));
219
+ if (byId) return byId;
220
+ }
221
+
222
+ // Sort newest-first by mtime so the active session (written seconds ago) wins.
223
+ const stamped = [];
224
+ for (const f of files) {
225
+ try {
226
+ stamped.push({ f, mtime: fs.statSync(f).mtimeMs });
227
+ } catch {
228
+ /* skip unreadable */
229
+ }
230
+ }
231
+ stamped.sort((a, b) => b.mtime - a.mtime);
232
+
233
+ // Ignore anything that hasn't been touched recently — guards against picking
234
+ // up an old session when the current one wrote no rollout (no usage anyway).
235
+ const fresh = stamped.filter((s) => Date.now() - s.mtime <= ROLLOUT_RECENT_MS);
236
+ const pool = fresh.length ? fresh : stamped;
237
+
238
+ if (cwd) {
239
+ for (const s of pool) {
240
+ if (rolloutCwd(s.f) === cwd) return s.f;
241
+ }
242
+ }
243
+ return pool.length ? pool[0].f : null;
244
+ }
245
+
246
+ /** Read just the `session_meta.cwd` from a rollout file (first line). Null on miss. */
247
+ function rolloutCwd(file) {
248
+ try {
249
+ const head = fs.readFileSync(file, "utf8").split("\n", 1)[0];
250
+ if (!head) return null;
251
+ const obj = JSON.parse(head);
252
+ if (obj && obj.type === "session_meta" && obj.payload && typeof obj.payload.cwd === "string") {
253
+ return obj.payload.cwd;
254
+ }
255
+ } catch {
256
+ /* fail-open */
257
+ }
258
+ return null;
259
+ }
260
+
261
+ /**
262
+ * Pull a TokenUsage-shaped object out of a Codex `token_count` payload,
263
+ * tolerating the shape drift across Codex versions:
264
+ * - newer: payload.info.total_token_usage
265
+ * - older: payload.total_token_usage / payload.info (flat) / payload (flat)
266
+ * Returns the cumulative usage object, or null.
267
+ */
268
+ function tokenUsageFromPayload(payload) {
269
+ if (!payload || typeof payload !== "object") return null;
270
+ const info = payload.info && typeof payload.info === "object" ? payload.info : payload;
271
+ const candidate =
272
+ (info && typeof info.total_token_usage === "object" && info.total_token_usage) ||
273
+ (typeof payload.total_token_usage === "object" && payload.total_token_usage) ||
274
+ info ||
275
+ payload;
276
+ if (!candidate || typeof candidate !== "object") return null;
277
+ // Only accept it if it actually looks like a usage object.
278
+ if (
279
+ "input_tokens" in candidate ||
280
+ "output_tokens" in candidate ||
281
+ "total_tokens" in candidate ||
282
+ "cached_input_tokens" in candidate
283
+ ) {
284
+ return candidate;
285
+ }
286
+ return null;
287
+ }
288
+
289
+ /**
290
+ * Parse a Codex rollout JSONL file and return the session's cumulative token
291
+ * usage from the LAST `token_count` event (these report running totals).
292
+ * Falls back to summing per-turn `last_token_usage` if no cumulative total is
293
+ * present. Also recovers the model when the notify payload omitted it.
294
+ *
295
+ * Returns `{ inputTokens, cachedTokens, outputTokens, totalTokens, model,
296
+ * sessionId }` (all numeric except model/sessionId which may be null). Never
297
+ * throws; returns all-zero on any failure so capture is preserved.
298
+ */
299
+ function usageFromRollout(file) {
300
+ const zero = {
301
+ inputTokens: 0,
302
+ cachedTokens: 0,
303
+ outputTokens: 0,
304
+ totalTokens: 0,
305
+ model: null,
306
+ sessionId: null,
307
+ };
308
+ let raw;
309
+ try {
310
+ raw = fs.readFileSync(file, "utf8");
311
+ } catch {
312
+ return zero;
313
+ }
314
+
315
+ let latestTotal = null; // cumulative usage from the most recent token_count
316
+ let summedLast = null; // fallback: sum of per-turn last_token_usage
317
+ let model = null;
318
+ let sessionId = null;
319
+
320
+ for (const line of raw.split("\n")) {
321
+ const trimmed = line.trim();
322
+ if (!trimmed) continue;
323
+ let obj;
324
+ try {
325
+ obj = JSON.parse(trimmed);
326
+ } catch {
327
+ continue;
328
+ }
329
+ if (!obj || typeof obj !== "object") continue;
330
+ const payload = obj.payload;
331
+
332
+ if (obj.type === "session_meta" && payload && typeof payload === "object") {
333
+ if (typeof payload.model === "string") model = payload.model;
334
+ if (typeof payload.id === "string") sessionId = payload.id;
335
+ continue;
336
+ }
337
+ if (payload && typeof payload === "object" && typeof payload.model === "string") {
338
+ model = payload.model;
339
+ }
340
+
341
+ // token_count events are wrapped as {type:"event_msg", payload:{type:"token_count", ...}}
342
+ const isTokenCount =
343
+ (payload && payload.type === "token_count") || obj.type === "token_count";
344
+ if (!isTokenCount) continue;
345
+
346
+ const tcPayload = payload && payload.type === "token_count" ? payload : obj;
347
+ const total = tokenUsageFromPayload(tcPayload);
348
+ if (total) latestTotal = total;
349
+
350
+ // Per-turn fallback: accumulate last_token_usage when present.
351
+ const info = tcPayload.info && typeof tcPayload.info === "object" ? tcPayload.info : tcPayload;
352
+ const last = info && typeof info.last_token_usage === "object" ? info.last_token_usage : null;
353
+ if (last) {
354
+ summedLast = summedLast || { input_tokens: 0, cached_input_tokens: 0, output_tokens: 0 };
355
+ summedLast.input_tokens += Number(last.input_tokens) || 0;
356
+ summedLast.cached_input_tokens += Number(last.cached_input_tokens) || 0;
357
+ summedLast.output_tokens += Number(last.output_tokens) || 0;
358
+ }
359
+ }
360
+
361
+ const usage = latestTotal || summedLast;
362
+ if (!usage) return { ...zero, model, sessionId };
363
+
364
+ const inputTotal = Number(usage.input_tokens) || 0;
365
+ const cachedTokens = Number(usage.cached_input_tokens || usage.cache_read_input_tokens || 0) || 0;
366
+ const outputTokens = Number(usage.output_tokens) || 0;
367
+ // Codex's input_tokens INCLUDES cached; bill the uncached remainder at the
368
+ // full input rate and the cached part at the cache_read rate.
369
+ const inputTokens = Math.max(0, inputTotal - cachedTokens);
370
+ const totalTokens = Number(usage.total_tokens) || inputTotal + outputTokens;
371
+
372
+ return { inputTokens, cachedTokens, outputTokens, totalTokens, model, sessionId };
373
+ }
374
+
375
+ /** Extract the session id embedded in a rollout filename, or null. */
376
+ function sessionIdFromRolloutPath(file) {
377
+ if (!file) return null;
378
+ // rollout-<iso>-<uuid>.jsonl → trailing uuid-ish segment.
379
+ const stem = path.basename(file).replace(/\.jsonl$/, "");
380
+ const m = stem.match(/([0-9a-fA-F-]{8,})$/);
381
+ return m ? m[1] : stem || null;
382
+ }
383
+
384
+ /** Best-effort pull of identity/usage from a Codex notify payload. */
385
+ function buildEvent(p) {
386
+ let sessionId =
387
+ p.conversation_id || p["conversation-id"] || p.session_id || p["session-id"] || p.thread_id || null;
388
+ const cwd = p.cwd || p.workspace || (Array.isArray(p["workspace-roots"]) ? p["workspace-roots"][0] : null);
389
+ const repo = cwd ? String(cwd).split("/").filter(Boolean).pop() : null;
390
+ const input = p["input-messages"] || p.input || p.prompt;
391
+ const inputText = Array.isArray(input) ? input.join("\n") : input;
392
+ const output = p["last-assistant-message"] || p["last-agent-message"] || p.response || p.text || "";
393
+
394
+ // 1) Usage straight from the payload, on the off chance a future Codex adds it.
395
+ const usage = p.usage || p.token_usage || {};
396
+ let inTok = Number(usage.input_tokens || usage.prompt_tokens || 0) || 0;
397
+ let outTok = Number(usage.output_tokens || usage.completion_tokens || 0) || 0;
398
+ let cacheRead = Number(usage.cached_input_tokens || usage.cache_read_input_tokens || 0) || 0;
399
+ let totalTok = Number(usage.total_tokens) || 0;
400
+ // OpenAI usage.input_tokens includes cached — normalise the same way as rollout.
401
+ if (cacheRead && inTok >= cacheRead) inTok = inTok - cacheRead;
402
+
403
+ // 2) Fallback (the real path): derive usage from the session's rollout JSONL.
404
+ // The rollout also yields a stable session id + the model when the notify
405
+ // payload omits them.
406
+ let model = p.model || p["last-agent-model"] || null;
407
+ if (inTok === 0 && outTok === 0 && cacheRead === 0) {
408
+ const rollout = findRolloutFile(sessionId, cwd);
409
+ if (rollout) {
410
+ const r = usageFromRollout(rollout);
411
+ inTok = r.inputTokens;
412
+ outTok = r.outputTokens;
413
+ cacheRead = r.cachedTokens;
414
+ totalTok = r.totalTokens;
415
+ if (!model && r.model) model = r.model;
416
+ // Prefer the session id Codex recorded; this stabilises the trace id so
417
+ // every per-turn notify for one session collapses onto a single trace
418
+ // (the backend dedups by deterministic trace id, ReplacingMergeTree).
419
+ if (!sessionId) sessionId = r.sessionId || sessionIdFromRolloutPath(rollout);
420
+ }
421
+ }
422
+ if (!totalTok) totalTok = inTok + outTok;
423
+
424
+ // Stable per-session identity. We DON'T mix in a timestamp here: the rollout
425
+ // token_count totals are cumulative for the whole session, so each turn's
426
+ // notify re-sends the same trace with a larger total and must REPLACE the
427
+ // prior one rather than create a new trace.
428
+ const conv = sessionId || "codex-session";
429
+
430
+ const ts = new Date().toISOString();
431
+ const span = {
432
+ span_id: `${conv}:gen`,
433
+ parent_span_id: null,
434
+ name: model ? `Codex session (${model})` : "Codex session",
435
+ span_type: "llm",
436
+ start_time: ts,
437
+ end_time: ts,
438
+ model,
439
+ provider: "openai",
440
+ input: truncate(inputText || "") || null,
441
+ output: truncate(output) || null,
442
+ input_tokens: inTok,
443
+ output_tokens: outTok,
444
+ total_tokens: totalTok,
445
+ cache_read_tokens: cacheRead,
446
+ cache_write_tokens: 0,
447
+ status: p.status === "error" ? "error" : "ok",
448
+ attributes: { type: p.type || "agent-turn-complete" },
449
+ };
450
+
451
+ return {
452
+ trace_id: uuid5(`${SOURCE}:${conv}`),
453
+ source: SOURCE,
454
+ session_id: String(conv),
455
+ user_ref: userRef(),
456
+ repo,
457
+ model,
458
+ spans: [span],
459
+ start_time: ts,
460
+ end_time: ts,
461
+ input_tokens: inTok,
462
+ output_tokens: outTok,
463
+ total_tokens: totalTok,
464
+ cache_read_tokens: cacheRead,
465
+ };
466
+ }
467
+
468
+ async function main() {
469
+ try {
470
+ const payload = await readPayload();
471
+ const event = buildEvent(payload);
472
+ await postEvent(event);
473
+ } catch {
474
+ /* fail-open */
475
+ }
476
+ process.exit(0);
477
+ }
478
+
479
+ // Run only when invoked directly (Codex). When imported by a test, the module
480
+ // just exposes its pure functions and does nothing.
481
+ const invokedDirectly =
482
+ process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
483
+ if (invokedDirectly) {
484
+ main();
485
+ }
486
+
487
+ export { buildEvent, usageFromRollout, findRolloutFile, tokenUsageFromPayload };
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * hook-handler.js — Cursor -> Octarin capture hook entry point.
4
+ *
5
+ * Registered for the key Cursor hook events in hooks.json. Cursor pipes one
6
+ * JSON payload on stdin and reads one JSON line on stdout (the hook's
7
+ * permission/continue response). This handler reads the payload, builds a
8
+ * canonical IngestEvent (lib/canonical.js), POSTs it to Octarin
9
+ * (`${OCTARIN_INGEST_URL:-$OCTARIN_API_BASE/v1/ingest}`, Bearer
10
+ * `$OCTARIN_API_KEY`), then emits a permissive `{ continue: true }` response.
11
+ *
12
+ * Fail-open: any error still prints an allow response and exits 0 so Cursor is
13
+ * never blocked. Zero npm dependencies — raw `node:https` POST.
14
+ */
15
+
16
+ import { readStdin, postEvent } from "./lib/utils.js";
17
+ import { buildEvent } from "./lib/canonical.js";
18
+
19
+ const ALLOW = { continue: true, permission: "allow" };
20
+
21
+ async function main() {
22
+ let response = ALLOW;
23
+ try {
24
+ const input = await readStdin();
25
+ const event = buildEvent(input.hook_event_name, input);
26
+ if (event) {
27
+ // Hard-capped by the 5s timeout inside postEvent; never throws.
28
+ await postEvent(event);
29
+ }
30
+ } catch {
31
+ response = ALLOW;
32
+ }
33
+ try {
34
+ process.stdout.write(JSON.stringify(response) + "\n");
35
+ } catch {
36
+ // ignore
37
+ }
38
+ process.exit(0);
39
+ }
40
+
41
+ main();