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.
- package/README.md +202 -0
- package/assets/backfill.py +1113 -0
- package/assets/claude_code/hook.py +573 -0
- package/assets/codex/hook.mjs +487 -0
- package/assets/cursor/hook-handler.js +41 -0
- package/assets/cursor/lib/canonical.js +240 -0
- package/assets/cursor/lib/utils.js +138 -0
- package/assets/repo-template/dot-claude/octarin/hook.py +685 -0
- package/assets/repo-template/dot-claude/octarin/run.sh +41 -0
- package/assets/repo-template/dot-claude/settings.json +15 -0
- package/assets/repo-template/dot-codex/config.toml +6 -0
- package/assets/repo-template/dot-codex/hooks/hook.mjs +531 -0
- package/assets/repo-template/dot-codex/hooks/run.sh +38 -0
- package/assets/repo-template/dot-cursor/hooks/hook-handler.js +41 -0
- package/assets/repo-template/dot-cursor/hooks/lib/canonical.js +240 -0
- package/assets/repo-template/dot-cursor/hooks/lib/utils.js +196 -0
- package/assets/repo-template/dot-cursor/hooks/run.sh +41 -0
- package/assets/repo-template/dot-cursor/hooks.json +13 -0
- package/dist/args.js +85 -0
- package/dist/assets.js +28 -0
- package/dist/client.js +105 -0
- package/dist/envfile.js +94 -0
- package/dist/index.js +192 -0
- package/dist/init.js +314 -0
- package/dist/init_repo.js +348 -0
- package/dist/login.js +209 -0
- package/dist/output.js +56 -0
- package/package.json +37 -0
|
@@ -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();
|