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,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* canonical.js — map Cursor hook payloads to Octarin canonical IngestEvents.
|
|
3
|
+
*
|
|
4
|
+
* Cursor fires a separate process per hook event (beforeSubmitPrompt,
|
|
5
|
+
* afterAgentResponse, afterFileEdit, stop, ...), so each event becomes its own
|
|
6
|
+
* IngestEvent carrying one span. All events for a conversation share a
|
|
7
|
+
* deterministic trace_id (derived from `conversation_id`) so the backend rolls
|
|
8
|
+
* them into a single trace. Shape: backend/app/schema/canonical.py::IngestEvent.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { SOURCE, truncate, nowIso, userRef, deterministicTraceId } from "./utils.js";
|
|
12
|
+
|
|
13
|
+
function repoFromRoots(roots) {
|
|
14
|
+
if (!Array.isArray(roots) || roots.length === 0) return null;
|
|
15
|
+
const r = String(roots[0]);
|
|
16
|
+
return r.split("/").filter(Boolean).pop() || null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Wrap one span into a full IngestEvent for the given conversation. */
|
|
20
|
+
function envelope(input, span, model) {
|
|
21
|
+
const conv = input.conversation_id || input.generation_id || "cursor-session";
|
|
22
|
+
return {
|
|
23
|
+
trace_id: deterministicTraceId(conv),
|
|
24
|
+
source: SOURCE,
|
|
25
|
+
session_id: input.conversation_id || null,
|
|
26
|
+
// Prefer the signed-in email Cursor puts on the event; else resolve a real
|
|
27
|
+
// identity (git / OS user) rather than an opaque machine hash.
|
|
28
|
+
user_ref: (input.user_email || "").trim() || userRef(),
|
|
29
|
+
repo: repoFromRoots(input.workspace_roots),
|
|
30
|
+
model: model || input.model || null,
|
|
31
|
+
spans: [span],
|
|
32
|
+
start_time: span.start_time,
|
|
33
|
+
end_time: span.end_time,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function baseSpan(spanId, name, spanType) {
|
|
38
|
+
const ts = nowIso();
|
|
39
|
+
return {
|
|
40
|
+
span_id: spanId,
|
|
41
|
+
parent_span_id: null,
|
|
42
|
+
name,
|
|
43
|
+
span_type: spanType,
|
|
44
|
+
start_time: ts,
|
|
45
|
+
end_time: ts,
|
|
46
|
+
status: "ok",
|
|
47
|
+
attributes: {},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function spanId(input, suffix) {
|
|
52
|
+
const conv = input.conversation_id || "conv";
|
|
53
|
+
const gen = input.generation_id || Date.now();
|
|
54
|
+
return `${conv}:${gen}:${suffix}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Cursor `afterAgentResponse` carries the model output.
|
|
59
|
+
*
|
|
60
|
+
* TOKEN USAGE: as of Cursor 1.7 NO hook event exposes per-turn token usage —
|
|
61
|
+
* the documented `afterAgentResponse` fields are only `text` (+ the shared
|
|
62
|
+
* envelope: conversation_id, generation_id, model, workspace_roots,
|
|
63
|
+
* transcript_path, ...). There is no `usage`, `input_tokens`, or `output_tokens`
|
|
64
|
+
* anywhere in the hook payload, so these spans legitimately carry 0 tokens (not
|
|
65
|
+
* a capture bug). The lookup below stays defensive against several possible
|
|
66
|
+
* field shapes so we transparently pick usage up IF a future Cursor version
|
|
67
|
+
* starts emitting it — but we never fabricate counts when it is absent.
|
|
68
|
+
* (`preCompact.context_tokens` is context-window utilisation, not billable
|
|
69
|
+
* usage, so we deliberately do not treat it as tokens.)
|
|
70
|
+
*/
|
|
71
|
+
function fromAfterAgentResponse(input) {
|
|
72
|
+
const span = baseSpan(spanId(input, "gen"), "Cursor agent response", "llm");
|
|
73
|
+
span.model = input.model || null;
|
|
74
|
+
span.input = truncate(input.prompt || "");
|
|
75
|
+
span.output = truncate(input.text || "");
|
|
76
|
+
const usage = input.usage || input.token_usage || input.tokens || {};
|
|
77
|
+
span.input_tokens = Number(usage.input_tokens || usage.prompt_tokens || 0) || 0;
|
|
78
|
+
span.output_tokens = Number(usage.output_tokens || usage.completion_tokens || 0) || 0;
|
|
79
|
+
span.cache_read_tokens =
|
|
80
|
+
Number(usage.cache_read_input_tokens || usage.cached_input_tokens || 0) || 0;
|
|
81
|
+
span.cache_write_tokens =
|
|
82
|
+
Number(usage.cache_creation_input_tokens || usage.cache_write_tokens || 0) || 0;
|
|
83
|
+
span.total_tokens =
|
|
84
|
+
Number(usage.total_tokens || 0) || span.input_tokens + span.output_tokens;
|
|
85
|
+
span.attributes = {
|
|
86
|
+
generation_id: input.generation_id,
|
|
87
|
+
hook: "afterAgentResponse",
|
|
88
|
+
// Flag when Cursor supplied no usage so the gap is visible downstream
|
|
89
|
+
// rather than looking like a silently-dropped count.
|
|
90
|
+
usage_available: Object.keys(usage).length > 0,
|
|
91
|
+
};
|
|
92
|
+
return envelope(input, span, input.model);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** `beforeSubmitPrompt` records the user turn (no tokens yet). */
|
|
96
|
+
function fromBeforeSubmitPrompt(input) {
|
|
97
|
+
const span = baseSpan(spanId(input, "prompt"), "Cursor user prompt", "agent");
|
|
98
|
+
span.input = truncate(input.prompt || "");
|
|
99
|
+
span.attributes = {
|
|
100
|
+
generation_id: input.generation_id,
|
|
101
|
+
attachment_count: (input.attachments || []).length,
|
|
102
|
+
hook: "beforeSubmitPrompt",
|
|
103
|
+
};
|
|
104
|
+
return envelope(input, span, input.model);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function asText(v) {
|
|
108
|
+
if (v == null) return "";
|
|
109
|
+
return typeof v === "string" ? v : JSON.stringify(v);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Tool span id keyed on Cursor's `tool_use_id` (unique per call) so multiple
|
|
114
|
+
* tools in ONE turn don't collide on the same id and get deduped to one (the
|
|
115
|
+
* old `afterFileEdit` keyed on conversation:generation, so 3 edits in a turn
|
|
116
|
+
* overwrote each other). Falls back to a per-call unique when absent.
|
|
117
|
+
*/
|
|
118
|
+
function toolSpanId(input) {
|
|
119
|
+
const conv = input.conversation_id || "conv";
|
|
120
|
+
return `${conv}:tool:${input.tool_use_id || input.generation_id || Date.now()}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* `postToolUse` — the GENERIC post-tool hook; fires for EVERY tool (edit, shell,
|
|
125
|
+
* read, MCP, ...) with its result. Replaces the per-tool afterFileEdit /
|
|
126
|
+
* afterShellExecution / afterMCPExecution (which would double-count).
|
|
127
|
+
*/
|
|
128
|
+
function fromPostToolUse(input) {
|
|
129
|
+
const tool = input.tool_name || "tool";
|
|
130
|
+
const span = baseSpan(toolSpanId(input), tool, "tool");
|
|
131
|
+
span.input = truncate(asText(input.tool_input));
|
|
132
|
+
span.output = truncate(asText(input.tool_output));
|
|
133
|
+
span.attributes = {
|
|
134
|
+
tool_name: tool,
|
|
135
|
+
tool_use_id: input.tool_use_id,
|
|
136
|
+
duration_ms: input.duration,
|
|
137
|
+
hook: "postToolUse",
|
|
138
|
+
};
|
|
139
|
+
return envelope(input, span, input.model);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** `postToolUseFailure` — a tool that errored; this is what powers a real
|
|
143
|
+
* Cursor error rate (status=error, failure_type, is_interrupt). */
|
|
144
|
+
function fromPostToolUseFailure(input) {
|
|
145
|
+
const tool = input.tool_name || "tool";
|
|
146
|
+
const span = baseSpan(toolSpanId(input), tool, "tool");
|
|
147
|
+
span.status = "error";
|
|
148
|
+
span.error_message = input.error_message || input.failure_type || "tool failed";
|
|
149
|
+
span.input = truncate(asText(input.tool_input));
|
|
150
|
+
span.attributes = {
|
|
151
|
+
tool_name: tool,
|
|
152
|
+
tool_use_id: input.tool_use_id,
|
|
153
|
+
failure_type: input.failure_type,
|
|
154
|
+
is_interrupt: input.is_interrupt,
|
|
155
|
+
duration_ms: input.duration,
|
|
156
|
+
hook: "postToolUseFailure",
|
|
157
|
+
};
|
|
158
|
+
return envelope(input, span, input.model);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** `sessionStart` — explicit session boundary (identity rides the envelope). */
|
|
162
|
+
function fromSessionStart(input) {
|
|
163
|
+
const span = baseSpan(spanId(input, "session-start"), "Cursor session start", "agent");
|
|
164
|
+
span.attributes = {
|
|
165
|
+
session_id: input.session_id,
|
|
166
|
+
is_background_agent: input.is_background_agent,
|
|
167
|
+
composer_mode: input.composer_mode,
|
|
168
|
+
hook: "sessionStart",
|
|
169
|
+
};
|
|
170
|
+
return envelope(input, span, input.model);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** `sessionEnd` — richer close than `stop`: final status + reason + duration. */
|
|
174
|
+
function fromSessionEnd(input) {
|
|
175
|
+
const span = baseSpan(spanId(input, "session-end"), "Cursor session end", "agent");
|
|
176
|
+
span.status = input.final_status === "error" ? "error" : "ok";
|
|
177
|
+
if (span.status === "error") span.error_message = input.error_message || "session error";
|
|
178
|
+
span.attributes = {
|
|
179
|
+
session_id: input.session_id,
|
|
180
|
+
reason: input.reason,
|
|
181
|
+
final_status: input.final_status,
|
|
182
|
+
duration_ms: input.duration_ms,
|
|
183
|
+
hook: "sessionEnd",
|
|
184
|
+
};
|
|
185
|
+
return envelope(input, span, input.model);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* `preCompact` — context-window compaction signal. NOTE: `context_tokens` here
|
|
190
|
+
* is context UTILISATION, not billable usage, so it is deliberately NOT mapped
|
|
191
|
+
* to span tokens (no Cursor hook exposes real usage).
|
|
192
|
+
*/
|
|
193
|
+
function fromPreCompact(input) {
|
|
194
|
+
const span = baseSpan(
|
|
195
|
+
spanId(input, `compact-${input.message_count || 0}`),
|
|
196
|
+
"Context compaction",
|
|
197
|
+
"agent",
|
|
198
|
+
);
|
|
199
|
+
span.attributes = {
|
|
200
|
+
hook: "preCompact",
|
|
201
|
+
trigger: input.trigger,
|
|
202
|
+
context_usage_percent: input.context_usage_percent,
|
|
203
|
+
context_tokens: input.context_tokens,
|
|
204
|
+
context_window_size: input.context_window_size,
|
|
205
|
+
message_count: input.message_count,
|
|
206
|
+
messages_to_compact: input.messages_to_compact,
|
|
207
|
+
is_first_compaction: input.is_first_compaction,
|
|
208
|
+
};
|
|
209
|
+
return envelope(input, span, input.model);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** `stop` records task completion + status. */
|
|
213
|
+
function fromStop(input) {
|
|
214
|
+
const span = baseSpan(spanId(input, "stop"), "Cursor agent stopped", "agent");
|
|
215
|
+
span.status = input.status === "error" ? "error" : "ok";
|
|
216
|
+
if (input.status === "error") span.error_message = "agent error";
|
|
217
|
+
span.attributes = { status: input.status, loop_count: input.loop_count, hook: "stop" };
|
|
218
|
+
return envelope(input, span, input.model);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const BUILDERS = {
|
|
222
|
+
sessionStart: fromSessionStart,
|
|
223
|
+
beforeSubmitPrompt: fromBeforeSubmitPrompt,
|
|
224
|
+
afterAgentResponse: fromAfterAgentResponse,
|
|
225
|
+
postToolUse: fromPostToolUse,
|
|
226
|
+
postToolUseFailure: fromPostToolUseFailure,
|
|
227
|
+
preCompact: fromPreCompact,
|
|
228
|
+
stop: fromStop,
|
|
229
|
+
sessionEnd: fromSessionEnd,
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Build an IngestEvent for a Cursor hook event, or null if this event carries
|
|
234
|
+
* nothing worth sending. `hookName` falls back to `input.hook_event_name`.
|
|
235
|
+
*/
|
|
236
|
+
export function buildEvent(hookName, input) {
|
|
237
|
+
const name = hookName || input.hook_event_name;
|
|
238
|
+
const builder = BUILDERS[name];
|
|
239
|
+
return builder ? builder(input) : null;
|
|
240
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* utils.js — tiny stdlib helpers for the Cursor -> Octarin capture hook.
|
|
3
|
+
*
|
|
4
|
+
* Zero npm dependencies: stdin reading, a raw `https`/`http` POST with a hard
|
|
5
|
+
* timeout, a real-identity user_ref (git email / OS user), and a deterministic UUID5 (matching the
|
|
6
|
+
* backend's trace-id namespace). Everything here is fail-open friendly — the
|
|
7
|
+
* caller decides what to do on rejection.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import https from "node:https";
|
|
11
|
+
import http from "node:http";
|
|
12
|
+
import crypto from "node:crypto";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import { execFileSync } from "node:child_process";
|
|
15
|
+
|
|
16
|
+
export const SOURCE = "cursor";
|
|
17
|
+
export const MAX_TEXT = 20000;
|
|
18
|
+
export const HTTP_TIMEOUT_MS = 5000;
|
|
19
|
+
// Same namespace as backend deterministic_trace_id so retries de-duplicate.
|
|
20
|
+
const TRACE_NAMESPACE = "6f8d2c1e-9a3b-4f5e-8c7d-1a2b3c4d5e6f";
|
|
21
|
+
|
|
22
|
+
export function readStdin() {
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
let data = "";
|
|
25
|
+
process.stdin.setEncoding("utf8");
|
|
26
|
+
process.stdin.on("data", (chunk) => (data += chunk));
|
|
27
|
+
process.stdin.on("end", () => {
|
|
28
|
+
try {
|
|
29
|
+
resolve(data.trim() ? JSON.parse(data) : {});
|
|
30
|
+
} catch {
|
|
31
|
+
resolve({});
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
process.stdin.on("error", () => resolve({}));
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function truncate(text) {
|
|
39
|
+
if (typeof text !== "string") return text == null ? "" : String(text);
|
|
40
|
+
return text.length <= MAX_TEXT ? text : text.slice(0, MAX_TEXT);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function nowIso() {
|
|
44
|
+
return new Date().toISOString();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The committing git identity, or "" if git isn't configured here. */
|
|
48
|
+
function gitEmail() {
|
|
49
|
+
try {
|
|
50
|
+
return execFileSync("git", ["config", "user.email"], {
|
|
51
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
52
|
+
})
|
|
53
|
+
.toString()
|
|
54
|
+
.trim();
|
|
55
|
+
} catch {
|
|
56
|
+
return "";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resolve the engineer's real identity for attribution.
|
|
62
|
+
*
|
|
63
|
+
* Priority: an explicit OCTARIN_USER override → the git user.email → the OS
|
|
64
|
+
* username. We attribute to a real person (matching backfill.py + the per-user
|
|
65
|
+
* ingest key) rather than an opaque per-machine hash. When a per-user key is
|
|
66
|
+
* present the server overrides this with the key owner anyway; a real identity
|
|
67
|
+
* here is what ANONYMOUS (slug-only) sends rely on.
|
|
68
|
+
*/
|
|
69
|
+
export function userRef() {
|
|
70
|
+
const env = (process.env.OCTARIN_USER || "").trim();
|
|
71
|
+
if (env) return env;
|
|
72
|
+
const email = gitEmail();
|
|
73
|
+
if (email) return email;
|
|
74
|
+
return os.userInfo().username || "unknown";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** RFC-4122 v5 UUID from (namespace, name) — matches Python's uuid.uuid5. */
|
|
78
|
+
export function uuid5(name) {
|
|
79
|
+
const ns = Buffer.from(TRACE_NAMESPACE.replace(/-/g, ""), "hex");
|
|
80
|
+
const hash = crypto.createHash("sha1").update(Buffer.concat([ns, Buffer.from(name, "utf8")])).digest();
|
|
81
|
+
const bytes = hash.subarray(0, 16);
|
|
82
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x50; // version 5
|
|
83
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant
|
|
84
|
+
const hex = bytes.toString("hex");
|
|
85
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function deterministicTraceId(sourceTraceId) {
|
|
89
|
+
return uuid5(`${SOURCE}:${sourceTraceId}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Fire-and-forget POST of an IngestEvent. Resolves true on 2xx, false otherwise.
|
|
94
|
+
* Never throws — the hook must stay fail-open.
|
|
95
|
+
*/
|
|
96
|
+
export function postEvent(event) {
|
|
97
|
+
return new Promise((resolve) => {
|
|
98
|
+
let url = process.env.OCTARIN_INGEST_URL;
|
|
99
|
+
if (!url) {
|
|
100
|
+
const base = (process.env.OCTARIN_API_BASE || "").replace(/\/+$/, "");
|
|
101
|
+
if (!base) return resolve(false);
|
|
102
|
+
url = `${base}/v1/ingest`;
|
|
103
|
+
}
|
|
104
|
+
let parsed;
|
|
105
|
+
try {
|
|
106
|
+
parsed = new URL(url);
|
|
107
|
+
} catch {
|
|
108
|
+
return resolve(false);
|
|
109
|
+
}
|
|
110
|
+
const body = Buffer.from(JSON.stringify(event), "utf8");
|
|
111
|
+
const headers = { "Content-Type": "application/json", "Content-Length": body.length };
|
|
112
|
+
if (process.env.OCTARIN_API_KEY) {
|
|
113
|
+
headers.Authorization = `Bearer ${process.env.OCTARIN_API_KEY}`;
|
|
114
|
+
}
|
|
115
|
+
const lib = parsed.protocol === "http:" ? http : https;
|
|
116
|
+
const req = lib.request(
|
|
117
|
+
{
|
|
118
|
+
method: "POST",
|
|
119
|
+
hostname: parsed.hostname,
|
|
120
|
+
port: parsed.port || (parsed.protocol === "http:" ? 80 : 443),
|
|
121
|
+
path: parsed.pathname + parsed.search,
|
|
122
|
+
headers,
|
|
123
|
+
timeout: HTTP_TIMEOUT_MS,
|
|
124
|
+
},
|
|
125
|
+
(res) => {
|
|
126
|
+
res.on("data", () => {});
|
|
127
|
+
res.on("end", () => resolve(res.statusCode >= 200 && res.statusCode < 300));
|
|
128
|
+
},
|
|
129
|
+
);
|
|
130
|
+
req.on("error", () => resolve(false));
|
|
131
|
+
req.on("timeout", () => {
|
|
132
|
+
req.destroy();
|
|
133
|
+
resolve(false);
|
|
134
|
+
});
|
|
135
|
+
req.write(body);
|
|
136
|
+
req.end();
|
|
137
|
+
});
|
|
138
|
+
}
|