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,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())