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,573 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Claude Code -> Octarin capture hook (pure stdlib, fail-open).
|
|
3
|
+
|
|
4
|
+
Registered as a Claude Code ``Stop`` hook. On each turn-end Claude Code pipes a
|
|
5
|
+
small JSON payload on stdin (``session_id``, ``transcript_path``, ``cwd``, ...).
|
|
6
|
+
This hook:
|
|
7
|
+
|
|
8
|
+
1. reads that payload and locates the session transcript JSONL;
|
|
9
|
+
2. parses user/assistant turns, tool calls, token usage, and model;
|
|
10
|
+
3. builds a single canonical ``IngestEvent`` (full ``spans`` form) covering the
|
|
11
|
+
turns produced since the last run (tracked via a per-session offset file);
|
|
12
|
+
4. POSTs it to ``${OCTARIN_INGEST_URL:-$OCTARIN_API_BASE/v1/ingest}`` with
|
|
13
|
+
``Authorization: Bearer $OCTARIN_API_KEY``.
|
|
14
|
+
|
|
15
|
+
It is deliberately tiny and dependency-free (stdlib only). Every failure path
|
|
16
|
+
exits 0 so the host tool is never blocked, and the network call has a hard
|
|
17
|
+
timeout. The canonical shape is defined in ``backend/app/schema/canonical.py``.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import base64
|
|
23
|
+
import getpass
|
|
24
|
+
import hashlib
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import subprocess
|
|
28
|
+
import sys
|
|
29
|
+
import time
|
|
30
|
+
import urllib.request
|
|
31
|
+
import uuid
|
|
32
|
+
from datetime import datetime, timezone
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
SOURCE = "claude-code"
|
|
36
|
+
STATE_DIR = Path.home() / ".octarin"
|
|
37
|
+
STATE_FILE = STATE_DIR / "claude_code_state.json"
|
|
38
|
+
MAX_TEXT = 20_000 # cap stored input/output text so payloads stay small
|
|
39
|
+
HTTP_TIMEOUT_S = 5.0
|
|
40
|
+
# Cap per-attachment base64 payload we ship inline. Larger items are recorded
|
|
41
|
+
# metadata-only (no b64) so a giant paste never bloats the POST or the backend.
|
|
42
|
+
MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024 # ~5MB of raw bytes
|
|
43
|
+
# Map common file extensions -> mime for file refs that lack one.
|
|
44
|
+
_EXT_MIME = {
|
|
45
|
+
".png": "image/png",
|
|
46
|
+
".jpg": "image/jpeg",
|
|
47
|
+
".jpeg": "image/jpeg",
|
|
48
|
+
".gif": "image/gif",
|
|
49
|
+
".webp": "image/webp",
|
|
50
|
+
".svg": "image/svg+xml",
|
|
51
|
+
".pdf": "application/pdf",
|
|
52
|
+
".txt": "text/plain",
|
|
53
|
+
".md": "text/markdown",
|
|
54
|
+
".json": "application/json",
|
|
55
|
+
".csv": "text/csv",
|
|
56
|
+
}
|
|
57
|
+
# Same UUID5 namespace as backend deterministic_trace_id so retries de-dupe.
|
|
58
|
+
_TRACE_NAMESPACE = uuid.UUID("6f8d2c1e-9a3b-4f5e-8c7d-1a2b3c4d5e6f")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _now_iso() -> str:
|
|
62
|
+
return datetime.now(timezone.utc).isoformat()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _truncate(text: str) -> str:
|
|
66
|
+
if not text:
|
|
67
|
+
return ""
|
|
68
|
+
return text if len(text) <= MAX_TEXT else text[:MAX_TEXT]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def read_payload() -> dict:
|
|
72
|
+
"""Read and parse the hook JSON from stdin; ``{}`` on any problem."""
|
|
73
|
+
try:
|
|
74
|
+
raw = sys.stdin.read()
|
|
75
|
+
if not raw.strip():
|
|
76
|
+
return {}
|
|
77
|
+
parsed = json.loads(raw)
|
|
78
|
+
return parsed if isinstance(parsed, dict) else {}
|
|
79
|
+
except Exception:
|
|
80
|
+
return {}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def locate_transcript(payload: dict) -> tuple[str | None, Path | None, str | None]:
|
|
84
|
+
"""Pull ``(session_id, transcript_path, cwd)`` from the hook payload."""
|
|
85
|
+
session_id = (
|
|
86
|
+
payload.get("session_id")
|
|
87
|
+
or payload.get("sessionId")
|
|
88
|
+
or (payload.get("session") or {}).get("id")
|
|
89
|
+
)
|
|
90
|
+
raw_path = (
|
|
91
|
+
payload.get("transcript_path")
|
|
92
|
+
or payload.get("transcriptPath")
|
|
93
|
+
or (payload.get("transcript") or {}).get("path")
|
|
94
|
+
)
|
|
95
|
+
cwd = payload.get("cwd") or payload.get("workspace") or None
|
|
96
|
+
path: Path | None = None
|
|
97
|
+
if raw_path:
|
|
98
|
+
try:
|
|
99
|
+
path = Path(raw_path).expanduser()
|
|
100
|
+
except Exception:
|
|
101
|
+
path = None
|
|
102
|
+
return session_id, path, cwd
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ── transcript helpers (mirror Claude Code's JSONL shape) ──
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _msg(entry: dict) -> dict:
|
|
109
|
+
m = entry.get("message")
|
|
110
|
+
return m if isinstance(m, dict) else {}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _role(entry: dict) -> str | None:
|
|
114
|
+
t = entry.get("type")
|
|
115
|
+
if t in ("user", "assistant"):
|
|
116
|
+
return t
|
|
117
|
+
r = _msg(entry).get("role")
|
|
118
|
+
return r if r in ("user", "assistant") else None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _content(entry: dict):
|
|
122
|
+
m = _msg(entry)
|
|
123
|
+
return m.get("content") if "message" in entry else entry.get("content")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _text(content) -> str:
|
|
127
|
+
if isinstance(content, str):
|
|
128
|
+
return content
|
|
129
|
+
if isinstance(content, list):
|
|
130
|
+
parts = []
|
|
131
|
+
for x in content:
|
|
132
|
+
if isinstance(x, dict) and x.get("type") == "text":
|
|
133
|
+
parts.append(x.get("text", ""))
|
|
134
|
+
elif isinstance(x, str):
|
|
135
|
+
parts.append(x)
|
|
136
|
+
return "\n".join(p for p in parts if p)
|
|
137
|
+
return ""
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _blocks(content, block_type: str) -> list[dict]:
|
|
141
|
+
if not isinstance(content, list):
|
|
142
|
+
return []
|
|
143
|
+
return [x for x in content if isinstance(x, dict) and x.get("type") == block_type]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _attachment_from_image_block(block: dict) -> dict | None:
|
|
147
|
+
"""Build an attachment dict from a Claude ``image`` content block.
|
|
148
|
+
|
|
149
|
+
Claude carries pasted images as ``{"type":"image","source":{"type":"base64",
|
|
150
|
+
"media_type":"image/png","data":"..."}}``. We capture the base64 bytes inline
|
|
151
|
+
when within the size cap; larger images are recorded metadata-only (no b64).
|
|
152
|
+
Returns ``None`` if the block carries no usable image data.
|
|
153
|
+
"""
|
|
154
|
+
src = block.get("source")
|
|
155
|
+
if not isinstance(src, dict):
|
|
156
|
+
return None
|
|
157
|
+
mime = src.get("media_type") or "image/png"
|
|
158
|
+
name = block.get("name") or block.get("filename") or "pasted-image"
|
|
159
|
+
if src.get("type") == "base64":
|
|
160
|
+
data = src.get("data")
|
|
161
|
+
if not isinstance(data, str) or not data:
|
|
162
|
+
return None
|
|
163
|
+
# Authoritative size: decode once (cheap vs. the network cost we save).
|
|
164
|
+
try:
|
|
165
|
+
raw = base64.b64decode(data, validate=False)
|
|
166
|
+
except Exception:
|
|
167
|
+
return None
|
|
168
|
+
nbytes = len(raw)
|
|
169
|
+
att = {"kind": "image", "mime": mime, "name": str(name), "bytes": nbytes}
|
|
170
|
+
att["b64"] = data if nbytes <= MAX_ATTACHMENT_BYTES else None
|
|
171
|
+
return att
|
|
172
|
+
# URL-backed image (rare in transcripts): record metadata only.
|
|
173
|
+
if src.get("type") == "url" and src.get("url"):
|
|
174
|
+
return {"kind": "image", "mime": mime, "name": str(name), "bytes": 0, "b64": None}
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _mime_for_name(name: str) -> str:
|
|
179
|
+
"""Best-effort mime from a filename extension; generic when unknown."""
|
|
180
|
+
lower = name.lower()
|
|
181
|
+
for ext, mime in _EXT_MIME.items():
|
|
182
|
+
if lower.endswith(ext):
|
|
183
|
+
return mime
|
|
184
|
+
return "application/octet-stream"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _attachment_from_file_block(block: dict) -> dict | None:
|
|
188
|
+
"""Build a metadata attachment from a ``document``/file-ref content block.
|
|
189
|
+
|
|
190
|
+
Claude can carry document blocks (``{"type":"document","source":{...}}``) and
|
|
191
|
+
tool results sometimes reference files. We capture base64 ``document`` bytes
|
|
192
|
+
when present (within the cap); otherwise record the file name as metadata so
|
|
193
|
+
the trace at least shows that a file was attached.
|
|
194
|
+
"""
|
|
195
|
+
src = block.get("source")
|
|
196
|
+
name = block.get("name") or block.get("title") or block.get("filename") or "attached-file"
|
|
197
|
+
name = str(name)
|
|
198
|
+
if isinstance(src, dict) and src.get("type") == "base64" and isinstance(src.get("data"), str):
|
|
199
|
+
data = src["data"]
|
|
200
|
+
mime = src.get("media_type") or _mime_for_name(name)
|
|
201
|
+
try:
|
|
202
|
+
raw = base64.b64decode(data, validate=False)
|
|
203
|
+
except Exception:
|
|
204
|
+
return None
|
|
205
|
+
nbytes = len(raw)
|
|
206
|
+
return {
|
|
207
|
+
"kind": "file",
|
|
208
|
+
"mime": mime,
|
|
209
|
+
"name": name,
|
|
210
|
+
"bytes": nbytes,
|
|
211
|
+
"b64": data if nbytes <= MAX_ATTACHMENT_BYTES else None,
|
|
212
|
+
}
|
|
213
|
+
# Bare reference with a name/path but no inline bytes: metadata only.
|
|
214
|
+
if block.get("name") or block.get("title") or block.get("filename"):
|
|
215
|
+
return {"kind": "file", "mime": _mime_for_name(name), "name": name, "bytes": 0, "b64": None}
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _extract_attachments(content) -> list[dict]:
|
|
220
|
+
"""Pull image/file attachments from a message/tool-result content list.
|
|
221
|
+
|
|
222
|
+
Walks ``image`` and ``document`` content blocks (Claude's pasted-binary
|
|
223
|
+
shapes). Pure + fail-open: any malformed block is skipped, never raised, so
|
|
224
|
+
attachment capture can NEVER break the hook's core span extraction.
|
|
225
|
+
"""
|
|
226
|
+
out: list[dict] = []
|
|
227
|
+
if not isinstance(content, list):
|
|
228
|
+
return out
|
|
229
|
+
for block in content:
|
|
230
|
+
if not isinstance(block, dict):
|
|
231
|
+
continue
|
|
232
|
+
try:
|
|
233
|
+
btype = block.get("type")
|
|
234
|
+
if btype == "image":
|
|
235
|
+
att = _attachment_from_image_block(block)
|
|
236
|
+
elif btype in ("document", "file"):
|
|
237
|
+
att = _attachment_from_file_block(block)
|
|
238
|
+
else:
|
|
239
|
+
att = None
|
|
240
|
+
if att:
|
|
241
|
+
out.append(att)
|
|
242
|
+
except Exception:
|
|
243
|
+
continue
|
|
244
|
+
return out
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _is_tool_result(entry: dict) -> bool:
|
|
248
|
+
return _role(entry) == "user" and bool(_blocks(_content(entry), "tool_result"))
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _usage(entry: dict) -> dict:
|
|
252
|
+
u = _msg(entry).get("usage")
|
|
253
|
+
if not isinstance(u, dict):
|
|
254
|
+
return {}
|
|
255
|
+
return {
|
|
256
|
+
"input": int(u.get("input_tokens") or 0),
|
|
257
|
+
"output": int(u.get("output_tokens") or 0),
|
|
258
|
+
"cache_read": int(u.get("cache_read_input_tokens") or 0),
|
|
259
|
+
"cache_write": int(u.get("cache_creation_input_tokens") or 0),
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _ts(entry: dict) -> str | None:
|
|
264
|
+
v = entry.get("timestamp")
|
|
265
|
+
return v if isinstance(v, str) and v else None
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def read_new_entries(path: Path, state: dict, key: str) -> list[dict]:
|
|
269
|
+
"""Return transcript entries appended since the last processed byte offset."""
|
|
270
|
+
if not path.exists():
|
|
271
|
+
return []
|
|
272
|
+
sess = state.get(key) or {}
|
|
273
|
+
offset = int(sess.get("offset", 0))
|
|
274
|
+
try:
|
|
275
|
+
size = path.stat().st_size
|
|
276
|
+
if size < offset: # transcript rotated/truncated -> reprocess from start
|
|
277
|
+
offset = 0
|
|
278
|
+
with open(path, "rb") as fh:
|
|
279
|
+
fh.seek(offset)
|
|
280
|
+
chunk = fh.read()
|
|
281
|
+
new_offset = fh.tell()
|
|
282
|
+
except Exception:
|
|
283
|
+
return []
|
|
284
|
+
sess["offset"] = new_offset
|
|
285
|
+
state[key] = sess
|
|
286
|
+
out: list[dict] = []
|
|
287
|
+
for line in chunk.decode("utf-8", errors="replace").splitlines():
|
|
288
|
+
line = line.strip()
|
|
289
|
+
if not line:
|
|
290
|
+
continue
|
|
291
|
+
try:
|
|
292
|
+
obj = json.loads(line)
|
|
293
|
+
if isinstance(obj, dict):
|
|
294
|
+
out.append(obj)
|
|
295
|
+
except Exception:
|
|
296
|
+
continue
|
|
297
|
+
return out
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def build_spans(entries: list[dict]) -> tuple[list[dict], dict, list[str], str | None]:
|
|
301
|
+
"""Turn transcript entries into canonical spans + rolled-up totals.
|
|
302
|
+
|
|
303
|
+
Each assistant message becomes one ``llm`` span (model + token usage); each
|
|
304
|
+
``tool_use`` inside it becomes a child ``tool`` span. Returns
|
|
305
|
+
``(spans, totals, models, repo)``.
|
|
306
|
+
"""
|
|
307
|
+
# Map tool_use_id -> tool_result text for output enrichment, and
|
|
308
|
+
# tool_use_id -> attachments for any images a tool returned.
|
|
309
|
+
results_by_id: dict[str, str] = {}
|
|
310
|
+
attachments_by_tool_id: dict[str, list[dict]] = {}
|
|
311
|
+
# tool_use_id -> whether the tool_result reported a failure (Claude Code sets
|
|
312
|
+
# ``is_error: true`` on a failed tool result). Drives the tool span's status so
|
|
313
|
+
# the dashboard's error counts/rates reflect real failures instead of always 0.
|
|
314
|
+
errors_by_id: dict[str, bool] = {}
|
|
315
|
+
# tool_use_id -> ts of the message that returned the result. Used to give tool
|
|
316
|
+
# spans a real (assistant_ts -> result_ts) duration instead of zero.
|
|
317
|
+
result_ts_by_id: dict[str, str] = {}
|
|
318
|
+
for entry in entries:
|
|
319
|
+
if _is_tool_result(entry):
|
|
320
|
+
entry_ts = _ts(entry)
|
|
321
|
+
for tr in _blocks(_content(entry), "tool_result"):
|
|
322
|
+
tid = tr.get("tool_use_id")
|
|
323
|
+
if tid:
|
|
324
|
+
out = tr.get("content")
|
|
325
|
+
results_by_id[str(tid)] = (
|
|
326
|
+
out if isinstance(out, str) else json.dumps(out, ensure_ascii=False)
|
|
327
|
+
)
|
|
328
|
+
errors_by_id[str(tid)] = bool(tr.get("is_error"))
|
|
329
|
+
atts = _extract_attachments(out)
|
|
330
|
+
if atts:
|
|
331
|
+
attachments_by_tool_id[str(tid)] = atts
|
|
332
|
+
if entry_ts:
|
|
333
|
+
result_ts_by_id[str(tid)] = entry_ts
|
|
334
|
+
|
|
335
|
+
spans: list[dict] = []
|
|
336
|
+
models: list[str] = []
|
|
337
|
+
totals = {
|
|
338
|
+
"input_tokens": 0,
|
|
339
|
+
"output_tokens": 0,
|
|
340
|
+
"total_tokens": 0,
|
|
341
|
+
"cache_read_tokens": 0,
|
|
342
|
+
"cost_usd": 0.0,
|
|
343
|
+
"span_count": 0,
|
|
344
|
+
"tool_call_count": 0,
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
pending_user_text = ""
|
|
348
|
+
pending_user_attachments: list[dict] = []
|
|
349
|
+
# ts of the previous transcript entry; the LLM call started when the user
|
|
350
|
+
# prompt / tool result landed, finished when the assistant message appears.
|
|
351
|
+
prev_ts: str | None = None
|
|
352
|
+
for entry in entries:
|
|
353
|
+
role = _role(entry)
|
|
354
|
+
if role == "user" and not _is_tool_result(entry):
|
|
355
|
+
pending_user_text = _truncate(_text(_content(entry)))
|
|
356
|
+
# Images/files the user pasted into this turn ride along to the
|
|
357
|
+
# assistant span they prompted (accumulate across consecutive user
|
|
358
|
+
# messages until the next assistant generation consumes them).
|
|
359
|
+
pending_user_attachments.extend(_extract_attachments(_content(entry)))
|
|
360
|
+
prev_ts = _ts(entry) or prev_ts
|
|
361
|
+
continue
|
|
362
|
+
if role != "assistant":
|
|
363
|
+
prev_ts = _ts(entry) or prev_ts
|
|
364
|
+
continue
|
|
365
|
+
|
|
366
|
+
content = _content(entry)
|
|
367
|
+
usage = _usage(entry)
|
|
368
|
+
model = _msg(entry).get("model")
|
|
369
|
+
if model and model not in models:
|
|
370
|
+
models.append(model)
|
|
371
|
+
ts = _ts(entry) or _now_iso()
|
|
372
|
+
span_id = _msg(entry).get("id") or uuid.uuid4().hex
|
|
373
|
+
out_text = _truncate(_text(content))
|
|
374
|
+
|
|
375
|
+
in_tok = usage.get("input", 0)
|
|
376
|
+
out_tok = usage.get("output", 0)
|
|
377
|
+
cache_r = usage.get("cache_read", 0)
|
|
378
|
+
cache_w = usage.get("cache_write", 0)
|
|
379
|
+
llm_span = {
|
|
380
|
+
"span_id": str(span_id),
|
|
381
|
+
"parent_span_id": None,
|
|
382
|
+
"name": f"Claude generation ({model})" if model else "Claude generation",
|
|
383
|
+
"span_type": "llm",
|
|
384
|
+
"start_time": prev_ts or ts,
|
|
385
|
+
"end_time": ts,
|
|
386
|
+
"model": model,
|
|
387
|
+
"provider": "anthropic",
|
|
388
|
+
"input": pending_user_text or None,
|
|
389
|
+
"output": out_text or None,
|
|
390
|
+
"input_tokens": in_tok,
|
|
391
|
+
"output_tokens": out_tok,
|
|
392
|
+
"total_tokens": in_tok + out_tok,
|
|
393
|
+
"cache_read_tokens": cache_r,
|
|
394
|
+
"cache_write_tokens": cache_w,
|
|
395
|
+
"status": "ok",
|
|
396
|
+
"attributes": {"turn_role": "assistant"},
|
|
397
|
+
}
|
|
398
|
+
if pending_user_attachments:
|
|
399
|
+
llm_span["attachments"] = pending_user_attachments
|
|
400
|
+
spans.append(llm_span)
|
|
401
|
+
pending_user_text = "" # consumed by this generation
|
|
402
|
+
pending_user_attachments = [] # consumed by this generation
|
|
403
|
+
|
|
404
|
+
totals["input_tokens"] += in_tok
|
|
405
|
+
totals["output_tokens"] += out_tok
|
|
406
|
+
totals["cache_read_tokens"] += cache_r
|
|
407
|
+
totals["total_tokens"] += in_tok + out_tok
|
|
408
|
+
|
|
409
|
+
for tu in _blocks(content, "tool_use"):
|
|
410
|
+
tid = str(tu.get("id") or uuid.uuid4().hex)
|
|
411
|
+
tname = tu.get("name") or "unknown"
|
|
412
|
+
tu_input = tu.get("input")
|
|
413
|
+
input_str = (
|
|
414
|
+
tu_input if isinstance(tu_input, str) else json.dumps(tu_input, ensure_ascii=False)
|
|
415
|
+
)
|
|
416
|
+
tool_span = {
|
|
417
|
+
"span_id": tid,
|
|
418
|
+
"parent_span_id": str(span_id),
|
|
419
|
+
"name": f"Tool: {tname}",
|
|
420
|
+
"span_type": "tool",
|
|
421
|
+
"start_time": ts,
|
|
422
|
+
"end_time": result_ts_by_id.get(tid, ts),
|
|
423
|
+
"input": _truncate(input_str),
|
|
424
|
+
"output": _truncate(results_by_id.get(tid, "")) or None,
|
|
425
|
+
"status": "error" if errors_by_id.get(tid) else "ok",
|
|
426
|
+
"attributes": {"tool_name": tname, "tool_id": tid},
|
|
427
|
+
}
|
|
428
|
+
tool_atts = attachments_by_tool_id.get(tid)
|
|
429
|
+
if tool_atts:
|
|
430
|
+
tool_span["attachments"] = tool_atts
|
|
431
|
+
spans.append(tool_span)
|
|
432
|
+
totals["tool_call_count"] += 1
|
|
433
|
+
|
|
434
|
+
prev_ts = ts
|
|
435
|
+
|
|
436
|
+
totals["span_count"] = len(spans)
|
|
437
|
+
return spans, totals, models, None
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def user_ref() -> str:
|
|
441
|
+
"""Resolve the engineer's real identity for attribution.
|
|
442
|
+
|
|
443
|
+
Priority: an explicit ``OCTARIN_USER`` override → the Claude Code account
|
|
444
|
+
email (``~/.claude.json`` ``oauthAccount.emailAddress`` — the signed-in user)
|
|
445
|
+
→ the git ``user.email`` → the OS username. We attribute to a real person
|
|
446
|
+
(matching ``backfill.py`` and the per-user ingest key) rather than an opaque
|
|
447
|
+
per-machine hash, so the dashboard shows who actually did the work. When the
|
|
448
|
+
request carries a per-user key the server overrides this with the key owner
|
|
449
|
+
anyway; a real identity here is what ANONYMOUS (slug-only) sends rely on.
|
|
450
|
+
"""
|
|
451
|
+
ref = (os.environ.get("OCTARIN_USER") or "").strip()
|
|
452
|
+
if ref:
|
|
453
|
+
return ref
|
|
454
|
+
try:
|
|
455
|
+
with open(Path.home() / ".claude.json", encoding="utf-8") as fh:
|
|
456
|
+
account = json.load(fh).get("oauthAccount") or {}
|
|
457
|
+
email = (account.get("emailAddress") or "").strip()
|
|
458
|
+
if email:
|
|
459
|
+
return email
|
|
460
|
+
except Exception:
|
|
461
|
+
pass
|
|
462
|
+
try:
|
|
463
|
+
out = subprocess.check_output(
|
|
464
|
+
["git", "config", "user.email"],
|
|
465
|
+
cwd=os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd(),
|
|
466
|
+
stderr=subprocess.DEVNULL,
|
|
467
|
+
)
|
|
468
|
+
email = out.decode().strip()
|
|
469
|
+
if email:
|
|
470
|
+
return email
|
|
471
|
+
except Exception:
|
|
472
|
+
pass
|
|
473
|
+
try:
|
|
474
|
+
return getpass.getuser()
|
|
475
|
+
except Exception:
|
|
476
|
+
return "unknown"
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def post_event(event: dict) -> bool:
|
|
480
|
+
"""POST the IngestEvent. Returns True on 2xx, False otherwise (fail-open)."""
|
|
481
|
+
url = os.environ.get("OCTARIN_INGEST_URL")
|
|
482
|
+
if not url:
|
|
483
|
+
base = (os.environ.get("OCTARIN_API_BASE") or "").rstrip("/")
|
|
484
|
+
if not base:
|
|
485
|
+
return False
|
|
486
|
+
url = f"{base}/v1/ingest"
|
|
487
|
+
api_key = os.environ.get("OCTARIN_API_KEY", "")
|
|
488
|
+
body = json.dumps(event).encode("utf-8")
|
|
489
|
+
req = urllib.request.Request(url, data=body, method="POST")
|
|
490
|
+
req.add_header("Content-Type", "application/json")
|
|
491
|
+
if api_key:
|
|
492
|
+
req.add_header("Authorization", f"Bearer {api_key}")
|
|
493
|
+
try:
|
|
494
|
+
with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT_S) as resp:
|
|
495
|
+
return 200 <= resp.status < 300
|
|
496
|
+
except Exception:
|
|
497
|
+
return False
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def load_state() -> dict:
|
|
501
|
+
try:
|
|
502
|
+
return json.loads(STATE_FILE.read_text(encoding="utf-8")) if STATE_FILE.exists() else {}
|
|
503
|
+
except Exception:
|
|
504
|
+
return {}
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def save_state(state: dict) -> None:
|
|
508
|
+
try:
|
|
509
|
+
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
510
|
+
tmp = STATE_FILE.with_suffix(".tmp")
|
|
511
|
+
tmp.write_text(json.dumps(state, sort_keys=True), encoding="utf-8")
|
|
512
|
+
os.replace(tmp, STATE_FILE)
|
|
513
|
+
except Exception:
|
|
514
|
+
pass
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def build_event(payload: dict) -> dict | None:
|
|
518
|
+
"""Assemble the canonical IngestEvent from a hook payload (or None to skip)."""
|
|
519
|
+
session_id, path, cwd = locate_transcript(payload)
|
|
520
|
+
if not session_id or path is None:
|
|
521
|
+
return None
|
|
522
|
+
|
|
523
|
+
state = load_state()
|
|
524
|
+
key = hashlib.sha256(f"{session_id}::{path}".encode()).hexdigest()
|
|
525
|
+
entries = read_new_entries(path, state, key)
|
|
526
|
+
save_state(state)
|
|
527
|
+
if not entries:
|
|
528
|
+
return None
|
|
529
|
+
|
|
530
|
+
spans, totals, models, _ = build_spans(entries)
|
|
531
|
+
if not spans:
|
|
532
|
+
return None
|
|
533
|
+
|
|
534
|
+
repo = Path(cwd).name if cwd else None
|
|
535
|
+
src_trace = f"{session_id}:{int(time.time())}"
|
|
536
|
+
trace_id = str(uuid.uuid5(_TRACE_NAMESPACE, f"{SOURCE}:{src_trace}"))
|
|
537
|
+
times = [s["start_time"] for s in spans]
|
|
538
|
+
|
|
539
|
+
return {
|
|
540
|
+
"trace_id": trace_id,
|
|
541
|
+
"source": SOURCE,
|
|
542
|
+
"session_id": session_id,
|
|
543
|
+
"user_ref": user_ref(),
|
|
544
|
+
"repo": repo,
|
|
545
|
+
"model": models[0] if models else None,
|
|
546
|
+
"spans": spans,
|
|
547
|
+
"start_time": min(times),
|
|
548
|
+
"end_time": max(times),
|
|
549
|
+
"total_tokens": totals["total_tokens"],
|
|
550
|
+
"input_tokens": totals["input_tokens"],
|
|
551
|
+
"output_tokens": totals["output_tokens"],
|
|
552
|
+
"cache_read_tokens": totals["cache_read_tokens"],
|
|
553
|
+
# extra (extra="allow"): handy for the backend rollup/audit
|
|
554
|
+
"totals": totals,
|
|
555
|
+
"models": models,
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def main() -> int:
|
|
560
|
+
try:
|
|
561
|
+
payload = read_payload()
|
|
562
|
+
event = build_event(payload)
|
|
563
|
+
if event is None:
|
|
564
|
+
return 0
|
|
565
|
+
post_event(event)
|
|
566
|
+
except Exception:
|
|
567
|
+
# Absolutely never let the hook break the host tool.
|
|
568
|
+
return 0
|
|
569
|
+
return 0
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
if __name__ == "__main__":
|
|
573
|
+
sys.exit(main())
|