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,41 @@
1
+ #!/usr/bin/env bash
2
+ # Wrapper that runs the Octarin Claude Code capture hook.
3
+ #
4
+ # Zero-setup: uses the system python3 (the hook is pure stdlib, no venv/deps).
5
+ # Sources OCTARIN_* config from the project-root .env (or .octarin.env) if
6
+ # present, then exec's the hook so stdin (Claude Code's payload) passes through.
7
+ # Fails open: any problem exits 0 so Claude Code is never blocked.
8
+
9
+ set +e
10
+
11
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
12
+
13
+ # Source the committed team config (subdir path, never matches the *.env rule
14
+ # in common .gitignore patterns, so the file is reliably checked in).
15
+ for envfile in "$CLAUDE_PROJECT_DIR/.octarin/project" "$PWD/.octarin/project"; do
16
+ if [ -n "$envfile" ] && [ -f "$envfile" ]; then
17
+ set -a
18
+ # shellcheck disable=SC1090
19
+ . "$envfile"
20
+ set +a
21
+ break
22
+ fi
23
+ done
24
+
25
+ # Also load the per-user key if it isn't already in env (~/.octarin/octarin.env
26
+ # is where `octarin login` writes the per-user, per-device ingest key). Sourcing
27
+ # this AFTER the team file means any team-set OCTARIN_API_KEY would win, but
28
+ # the device-code flow never sets one in .octarin/project — that's the whole
29
+ # point — so in practice this is where the key comes from.
30
+ if [ -z "${OCTARIN_API_KEY:-}" ] && [ -f "$HOME/.octarin/octarin.env" ]; then
31
+ set -a
32
+ # shellcheck disable=SC1090,SC1091
33
+ . "$HOME/.octarin/octarin.env"
34
+ set +a
35
+ fi
36
+
37
+ if command -v python3 >/dev/null 2>&1; then
38
+ exec python3 "$SCRIPT_DIR/hook.py"
39
+ fi
40
+
41
+ exit 0
@@ -0,0 +1,15 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/claude-code-settings.json",
3
+ "hooks": {
4
+ "Stop": [
5
+ {
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/octarin/run.sh\""
10
+ }
11
+ ]
12
+ }
13
+ ]
14
+ }
15
+ }
@@ -0,0 +1,6 @@
1
+ # Octarin capture — committed Codex config.
2
+ #
3
+ # Codex invokes `notify` at turn/session end; the wrapper sources OCTARIN_*
4
+ # from the environment (or the repo .env) and POSTs a canonical IngestEvent.
5
+ # Anyone who opens this repo and has OCTARIN_API_KEY in their env auto-sends.
6
+ notify = ["bash", ".codex/hooks/run.sh"]
@@ -0,0 +1,531 @@
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
+ /**
127
+ * Print the one-time stderr hint when the project requires per-user auth.
128
+ * Marker file at ~/.octarin/auth_hint.<sha12> so we don't re-print on every event.
129
+ */
130
+ function notifyAuthRequiredOnce(project) {
131
+ try {
132
+ const home = process.env.HOME || process.env.USERPROFILE || "/tmp";
133
+ const dir = `${home}/.octarin`;
134
+ fs.mkdirSync(dir, { recursive: true });
135
+ const sha = crypto.createHash("sha256").update(project).digest("hex").slice(0, 12);
136
+ const marker = `${dir}/auth_hint.${sha}`;
137
+ if (fs.existsSync(marker)) return;
138
+ fs.writeFileSync(marker, "");
139
+ } catch {
140
+ // fall through — fail-open, better to nag once a session than spam
141
+ }
142
+ process.stderr.write(
143
+ `[octarin] project '${project}' now requires per-user auth. Run once to authorize:\n` +
144
+ "[octarin] curl -fsSL https://octarin.ai/hooks/login.sh | bash\n",
145
+ );
146
+ }
147
+
148
+ function postEvent(event) {
149
+ return new Promise((resolve) => {
150
+ let url = process.env.OCTARIN_INGEST_URL;
151
+ if (!url) {
152
+ const base = (process.env.OCTARIN_API_BASE || "").replace(/\/+$/, "");
153
+ if (!base) return resolve(false);
154
+ url = `${base}/v1/ingest`;
155
+ }
156
+ let parsed;
157
+ try {
158
+ parsed = new URL(url);
159
+ } catch {
160
+ return resolve(false);
161
+ }
162
+ const apiKey = process.env.OCTARIN_API_KEY || "";
163
+ const project = (process.env.OCTARIN_PROJECT || "").trim();
164
+
165
+ const payload = { ...event };
166
+ if (project && payload.project == null) payload.project = project;
167
+
168
+ const body = Buffer.from(JSON.stringify(payload), "utf8");
169
+ const headers = { "Content-Type": "application/json", "Content-Length": body.length };
170
+ if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
171
+ else if (project) headers["X-Octarin-Project"] = project;
172
+
173
+ const lib = parsed.protocol === "http:" ? http : https;
174
+ const req = lib.request(
175
+ {
176
+ method: "POST",
177
+ hostname: parsed.hostname,
178
+ port: parsed.port || (parsed.protocol === "http:" ? 80 : 443),
179
+ path: parsed.pathname + parsed.search,
180
+ headers,
181
+ timeout: HTTP_TIMEOUT_MS,
182
+ },
183
+ (res) => {
184
+ const chunks = [];
185
+ res.on("data", (c) => chunks.push(c));
186
+ res.on("end", () => {
187
+ const ok = res.statusCode >= 200 && res.statusCode < 300;
188
+ if (!ok && res.statusCode === 401 && project && !apiKey) {
189
+ try {
190
+ const body = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
191
+ if (body && body.error && body.error.code === "auth_required") {
192
+ notifyAuthRequiredOnce(project);
193
+ }
194
+ } catch {
195
+ // ignore parse errors — hook stays fail-open
196
+ }
197
+ }
198
+ resolve(ok);
199
+ });
200
+ },
201
+ );
202
+ req.on("error", () => resolve(false));
203
+ req.on("timeout", () => {
204
+ req.destroy();
205
+ resolve(false);
206
+ });
207
+ req.write(body);
208
+ req.end();
209
+ });
210
+ }
211
+
212
+ /** The Codex sessions root, honouring CODEX_HOME (defaults to ~/.codex). */
213
+ function codexSessionsRoot() {
214
+ const home = process.env.CODEX_HOME
215
+ ? process.env.CODEX_HOME
216
+ : path.join(os.homedir() || "", ".codex");
217
+ return path.join(home, "sessions");
218
+ }
219
+
220
+ /**
221
+ * Recursively collect rollout-*.jsonl files under `dir`. Bounded + fail-open:
222
+ * returns `[]` on any error and stops once `limit` files are gathered. The
223
+ * sessions tree is shallow (YYYY/MM/DD), so this stays cheap.
224
+ */
225
+ function collectRolloutFiles(dir, out, limit) {
226
+ if (out.length >= limit) return;
227
+ let entries;
228
+ try {
229
+ entries = fs.readdirSync(dir, { withFileTypes: true });
230
+ } catch {
231
+ return;
232
+ }
233
+ for (const ent of entries) {
234
+ if (out.length >= limit) return;
235
+ const full = path.join(dir, ent.name);
236
+ if (ent.isDirectory()) {
237
+ collectRolloutFiles(full, out, limit);
238
+ } else if (ent.isFile() && ent.name.startsWith("rollout-") && ent.name.endsWith(".jsonl")) {
239
+ out.push(full);
240
+ }
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Locate the rollout file for the active session.
246
+ *
247
+ * 1. If a session id is known, prefer the file whose name embeds it
248
+ * (rollout files are named `rollout-<iso>-<session-id>.jsonl`).
249
+ * 2. Otherwise (the common case — notify omits the id), pick the most
250
+ * recently modified rollout file. If `cwd` is known, restrict to files
251
+ * whose `session_meta.cwd` matches before falling back to plain recency.
252
+ *
253
+ * Returns an absolute path or null. Never throws.
254
+ */
255
+ function findRolloutFile(sessionId, cwd) {
256
+ const root = codexSessionsRoot();
257
+ const files = [];
258
+ collectRolloutFiles(root, files, 5000);
259
+ if (files.length === 0) return null;
260
+
261
+ if (sessionId) {
262
+ const byId = files.find((f) => path.basename(f).includes(sessionId));
263
+ if (byId) return byId;
264
+ }
265
+
266
+ // Sort newest-first by mtime so the active session (written seconds ago) wins.
267
+ const stamped = [];
268
+ for (const f of files) {
269
+ try {
270
+ stamped.push({ f, mtime: fs.statSync(f).mtimeMs });
271
+ } catch {
272
+ /* skip unreadable */
273
+ }
274
+ }
275
+ stamped.sort((a, b) => b.mtime - a.mtime);
276
+
277
+ // Ignore anything that hasn't been touched recently — guards against picking
278
+ // up an old session when the current one wrote no rollout (no usage anyway).
279
+ const fresh = stamped.filter((s) => Date.now() - s.mtime <= ROLLOUT_RECENT_MS);
280
+ const pool = fresh.length ? fresh : stamped;
281
+
282
+ if (cwd) {
283
+ for (const s of pool) {
284
+ if (rolloutCwd(s.f) === cwd) return s.f;
285
+ }
286
+ }
287
+ return pool.length ? pool[0].f : null;
288
+ }
289
+
290
+ /** Read just the `session_meta.cwd` from a rollout file (first line). Null on miss. */
291
+ function rolloutCwd(file) {
292
+ try {
293
+ const head = fs.readFileSync(file, "utf8").split("\n", 1)[0];
294
+ if (!head) return null;
295
+ const obj = JSON.parse(head);
296
+ if (obj && obj.type === "session_meta" && obj.payload && typeof obj.payload.cwd === "string") {
297
+ return obj.payload.cwd;
298
+ }
299
+ } catch {
300
+ /* fail-open */
301
+ }
302
+ return null;
303
+ }
304
+
305
+ /**
306
+ * Pull a TokenUsage-shaped object out of a Codex `token_count` payload,
307
+ * tolerating the shape drift across Codex versions:
308
+ * - newer: payload.info.total_token_usage
309
+ * - older: payload.total_token_usage / payload.info (flat) / payload (flat)
310
+ * Returns the cumulative usage object, or null.
311
+ */
312
+ function tokenUsageFromPayload(payload) {
313
+ if (!payload || typeof payload !== "object") return null;
314
+ const info = payload.info && typeof payload.info === "object" ? payload.info : payload;
315
+ const candidate =
316
+ (info && typeof info.total_token_usage === "object" && info.total_token_usage) ||
317
+ (typeof payload.total_token_usage === "object" && payload.total_token_usage) ||
318
+ info ||
319
+ payload;
320
+ if (!candidate || typeof candidate !== "object") return null;
321
+ // Only accept it if it actually looks like a usage object.
322
+ if (
323
+ "input_tokens" in candidate ||
324
+ "output_tokens" in candidate ||
325
+ "total_tokens" in candidate ||
326
+ "cached_input_tokens" in candidate
327
+ ) {
328
+ return candidate;
329
+ }
330
+ return null;
331
+ }
332
+
333
+ /**
334
+ * Parse a Codex rollout JSONL file and return the session's cumulative token
335
+ * usage from the LAST `token_count` event (these report running totals).
336
+ * Falls back to summing per-turn `last_token_usage` if no cumulative total is
337
+ * present. Also recovers the model when the notify payload omitted it.
338
+ *
339
+ * Returns `{ inputTokens, cachedTokens, outputTokens, totalTokens, model,
340
+ * sessionId }` (all numeric except model/sessionId which may be null). Never
341
+ * throws; returns all-zero on any failure so capture is preserved.
342
+ */
343
+ function usageFromRollout(file) {
344
+ const zero = {
345
+ inputTokens: 0,
346
+ cachedTokens: 0,
347
+ outputTokens: 0,
348
+ totalTokens: 0,
349
+ model: null,
350
+ sessionId: null,
351
+ };
352
+ let raw;
353
+ try {
354
+ raw = fs.readFileSync(file, "utf8");
355
+ } catch {
356
+ return zero;
357
+ }
358
+
359
+ let latestTotal = null; // cumulative usage from the most recent token_count
360
+ let summedLast = null; // fallback: sum of per-turn last_token_usage
361
+ let model = null;
362
+ let sessionId = null;
363
+
364
+ for (const line of raw.split("\n")) {
365
+ const trimmed = line.trim();
366
+ if (!trimmed) continue;
367
+ let obj;
368
+ try {
369
+ obj = JSON.parse(trimmed);
370
+ } catch {
371
+ continue;
372
+ }
373
+ if (!obj || typeof obj !== "object") continue;
374
+ const payload = obj.payload;
375
+
376
+ if (obj.type === "session_meta" && payload && typeof payload === "object") {
377
+ if (typeof payload.model === "string") model = payload.model;
378
+ if (typeof payload.id === "string") sessionId = payload.id;
379
+ continue;
380
+ }
381
+ if (payload && typeof payload === "object" && typeof payload.model === "string") {
382
+ model = payload.model;
383
+ }
384
+
385
+ // token_count events are wrapped as {type:"event_msg", payload:{type:"token_count", ...}}
386
+ const isTokenCount =
387
+ (payload && payload.type === "token_count") || obj.type === "token_count";
388
+ if (!isTokenCount) continue;
389
+
390
+ const tcPayload = payload && payload.type === "token_count" ? payload : obj;
391
+ const total = tokenUsageFromPayload(tcPayload);
392
+ if (total) latestTotal = total;
393
+
394
+ // Per-turn fallback: accumulate last_token_usage when present.
395
+ const info = tcPayload.info && typeof tcPayload.info === "object" ? tcPayload.info : tcPayload;
396
+ const last = info && typeof info.last_token_usage === "object" ? info.last_token_usage : null;
397
+ if (last) {
398
+ summedLast = summedLast || { input_tokens: 0, cached_input_tokens: 0, output_tokens: 0 };
399
+ summedLast.input_tokens += Number(last.input_tokens) || 0;
400
+ summedLast.cached_input_tokens += Number(last.cached_input_tokens) || 0;
401
+ summedLast.output_tokens += Number(last.output_tokens) || 0;
402
+ }
403
+ }
404
+
405
+ const usage = latestTotal || summedLast;
406
+ if (!usage) return { ...zero, model, sessionId };
407
+
408
+ const inputTotal = Number(usage.input_tokens) || 0;
409
+ const cachedTokens = Number(usage.cached_input_tokens || usage.cache_read_input_tokens || 0) || 0;
410
+ const outputTokens = Number(usage.output_tokens) || 0;
411
+ // Codex's input_tokens INCLUDES cached; bill the uncached remainder at the
412
+ // full input rate and the cached part at the cache_read rate.
413
+ const inputTokens = Math.max(0, inputTotal - cachedTokens);
414
+ const totalTokens = Number(usage.total_tokens) || inputTotal + outputTokens;
415
+
416
+ return { inputTokens, cachedTokens, outputTokens, totalTokens, model, sessionId };
417
+ }
418
+
419
+ /** Extract the session id embedded in a rollout filename, or null. */
420
+ function sessionIdFromRolloutPath(file) {
421
+ if (!file) return null;
422
+ // rollout-<iso>-<uuid>.jsonl → trailing uuid-ish segment.
423
+ const stem = path.basename(file).replace(/\.jsonl$/, "");
424
+ const m = stem.match(/([0-9a-fA-F-]{8,})$/);
425
+ return m ? m[1] : stem || null;
426
+ }
427
+
428
+ /** Best-effort pull of identity/usage from a Codex notify payload. */
429
+ function buildEvent(p) {
430
+ let sessionId =
431
+ p.conversation_id || p["conversation-id"] || p.session_id || p["session-id"] || p.thread_id || null;
432
+ const cwd = p.cwd || p.workspace || (Array.isArray(p["workspace-roots"]) ? p["workspace-roots"][0] : null);
433
+ const repo = cwd ? String(cwd).split("/").filter(Boolean).pop() : null;
434
+ const input = p["input-messages"] || p.input || p.prompt;
435
+ const inputText = Array.isArray(input) ? input.join("\n") : input;
436
+ const output = p["last-assistant-message"] || p["last-agent-message"] || p.response || p.text || "";
437
+
438
+ // 1) Usage straight from the payload, on the off chance a future Codex adds it.
439
+ const usage = p.usage || p.token_usage || {};
440
+ let inTok = Number(usage.input_tokens || usage.prompt_tokens || 0) || 0;
441
+ let outTok = Number(usage.output_tokens || usage.completion_tokens || 0) || 0;
442
+ let cacheRead = Number(usage.cached_input_tokens || usage.cache_read_input_tokens || 0) || 0;
443
+ let totalTok = Number(usage.total_tokens) || 0;
444
+ // OpenAI usage.input_tokens includes cached — normalise the same way as rollout.
445
+ if (cacheRead && inTok >= cacheRead) inTok = inTok - cacheRead;
446
+
447
+ // 2) Fallback (the real path): derive usage from the session's rollout JSONL.
448
+ // The rollout also yields a stable session id + the model when the notify
449
+ // payload omits them.
450
+ let model = p.model || p["last-agent-model"] || null;
451
+ if (inTok === 0 && outTok === 0 && cacheRead === 0) {
452
+ const rollout = findRolloutFile(sessionId, cwd);
453
+ if (rollout) {
454
+ const r = usageFromRollout(rollout);
455
+ inTok = r.inputTokens;
456
+ outTok = r.outputTokens;
457
+ cacheRead = r.cachedTokens;
458
+ totalTok = r.totalTokens;
459
+ if (!model && r.model) model = r.model;
460
+ // Prefer the session id Codex recorded; this stabilises the trace id so
461
+ // every per-turn notify for one session collapses onto a single trace
462
+ // (the backend dedups by deterministic trace id, ReplacingMergeTree).
463
+ if (!sessionId) sessionId = r.sessionId || sessionIdFromRolloutPath(rollout);
464
+ }
465
+ }
466
+ if (!totalTok) totalTok = inTok + outTok;
467
+
468
+ // Stable per-session identity. We DON'T mix in a timestamp here: the rollout
469
+ // token_count totals are cumulative for the whole session, so each turn's
470
+ // notify re-sends the same trace with a larger total and must REPLACE the
471
+ // prior one rather than create a new trace.
472
+ const conv = sessionId || "codex-session";
473
+
474
+ const ts = new Date().toISOString();
475
+ const span = {
476
+ span_id: `${conv}:gen`,
477
+ parent_span_id: null,
478
+ name: model ? `Codex session (${model})` : "Codex session",
479
+ span_type: "llm",
480
+ start_time: ts,
481
+ end_time: ts,
482
+ model,
483
+ provider: "openai",
484
+ input: truncate(inputText || "") || null,
485
+ output: truncate(output) || null,
486
+ input_tokens: inTok,
487
+ output_tokens: outTok,
488
+ total_tokens: totalTok,
489
+ cache_read_tokens: cacheRead,
490
+ cache_write_tokens: 0,
491
+ status: p.status === "error" ? "error" : "ok",
492
+ attributes: { type: p.type || "agent-turn-complete" },
493
+ };
494
+
495
+ return {
496
+ trace_id: uuid5(`${SOURCE}:${conv}`),
497
+ source: SOURCE,
498
+ session_id: String(conv),
499
+ user_ref: userRef(),
500
+ repo,
501
+ model,
502
+ spans: [span],
503
+ start_time: ts,
504
+ end_time: ts,
505
+ input_tokens: inTok,
506
+ output_tokens: outTok,
507
+ total_tokens: totalTok,
508
+ cache_read_tokens: cacheRead,
509
+ };
510
+ }
511
+
512
+ async function main() {
513
+ try {
514
+ const payload = await readPayload();
515
+ const event = buildEvent(payload);
516
+ await postEvent(event);
517
+ } catch {
518
+ /* fail-open */
519
+ }
520
+ process.exit(0);
521
+ }
522
+
523
+ // Run only when invoked directly (Codex). When imported by a test, the module
524
+ // just exposes its pure functions and does nothing.
525
+ const invokedDirectly =
526
+ process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
527
+ if (invokedDirectly) {
528
+ main();
529
+ }
530
+
531
+ export { buildEvent, usageFromRollout, findRolloutFile, tokenUsageFromPayload };
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env bash
2
+ # Wrapper that runs the Octarin Codex capture hook (hook.mjs).
3
+ #
4
+ # Sources OCTARIN_* config from a project-root .env / .octarin.env if present,
5
+ # then exec's node on the single-file hook so Codex's payload (argv or stdin)
6
+ # passes straight through. Fails open: any problem exits 0 so Codex is never
7
+ # blocked.
8
+
9
+ set +e
10
+
11
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
12
+ PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." 2>/dev/null && pwd)"
13
+
14
+ # Source the committed team config (subdir path, never matches the *.env
15
+ # rule in common .gitignore patterns).
16
+ for envfile in "$PROJECT_ROOT/.octarin/project" "$PWD/.octarin/project"; do
17
+ if [ -n "$envfile" ] && [ -f "$envfile" ]; then
18
+ set -a
19
+ # shellcheck disable=SC1090
20
+ . "$envfile"
21
+ set +a
22
+ break
23
+ fi
24
+ done
25
+
26
+ # Per-user key from `octarin login` (only if not already set by the env).
27
+ if [ -z "${OCTARIN_API_KEY:-}" ] && [ -f "$HOME/.octarin/octarin.env" ]; then
28
+ set -a
29
+ # shellcheck disable=SC1090,SC1091
30
+ . "$HOME/.octarin/octarin.env"
31
+ set +a
32
+ fi
33
+
34
+ if command -v node >/dev/null 2>&1; then
35
+ exec node "$SCRIPT_DIR/hook.mjs" "$@"
36
+ fi
37
+
38
+ exit 0
@@ -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();