octarin-cli 0.3.3 → 0.3.4
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/assets/claude_code/hook.py +87 -59
- package/dist/index.js +0 -0
- package/package.json +1 -1
|
@@ -27,6 +27,7 @@ import os
|
|
|
27
27
|
import ssl
|
|
28
28
|
import subprocess
|
|
29
29
|
import sys
|
|
30
|
+
import time
|
|
30
31
|
import urllib.request
|
|
31
32
|
import uuid
|
|
32
33
|
from datetime import datetime, timezone
|
|
@@ -346,6 +347,11 @@ def build_spans(entries: list[dict]) -> tuple[list[dict], dict, list[str], str |
|
|
|
346
347
|
|
|
347
348
|
pending_user_text = ""
|
|
348
349
|
pending_user_attachments: list[dict] = []
|
|
350
|
+
# One API generation streams as SEVERAL transcript entries (one per content
|
|
351
|
+
# block — text, then each tool_use) that share the same message id and each
|
|
352
|
+
# repeat the generation's FULL usage. Merge them into ONE span keyed by that
|
|
353
|
+
# id: usage/cost counted once, outputs concatenated, tool children attached.
|
|
354
|
+
llm_span_by_id: dict[str, dict] = {}
|
|
349
355
|
# ts of the previous transcript entry; the LLM call started when the user
|
|
350
356
|
# prompt / tool result landed, finished when the assistant message appears.
|
|
351
357
|
prev_ts: str | None = None
|
|
@@ -372,6 +378,31 @@ def build_spans(entries: list[dict]) -> tuple[list[dict], dict, list[str], str |
|
|
|
372
378
|
span_id = _msg(entry).get("id") or uuid.uuid4().hex
|
|
373
379
|
out_text = _truncate(_text(content))
|
|
374
380
|
|
|
381
|
+
existing = llm_span_by_id.get(str(span_id))
|
|
382
|
+
if existing is not None:
|
|
383
|
+
# Continuation entry of an already-seen generation: extend the span,
|
|
384
|
+
# never re-count its usage (each entry repeats the full totals).
|
|
385
|
+
existing["end_time"] = ts
|
|
386
|
+
if out_text:
|
|
387
|
+
joined = (
|
|
388
|
+
f"{existing['output']}\n{out_text}" if existing["output"] else out_text
|
|
389
|
+
)
|
|
390
|
+
existing["output"] = _truncate(joined)
|
|
391
|
+
for tu in _blocks(content, "tool_use"):
|
|
392
|
+
_append_tool_span(
|
|
393
|
+
spans,
|
|
394
|
+
totals,
|
|
395
|
+
tu,
|
|
396
|
+
parent_span_id=str(span_id),
|
|
397
|
+
ts=ts,
|
|
398
|
+
results_by_id=results_by_id,
|
|
399
|
+
errors_by_id=errors_by_id,
|
|
400
|
+
result_ts_by_id=result_ts_by_id,
|
|
401
|
+
attachments_by_tool_id=attachments_by_tool_id,
|
|
402
|
+
)
|
|
403
|
+
prev_ts = ts
|
|
404
|
+
continue
|
|
405
|
+
|
|
375
406
|
in_tok = usage.get("input", 0)
|
|
376
407
|
out_tok = usage.get("output", 0)
|
|
377
408
|
cache_r = usage.get("cache_read", 0)
|
|
@@ -398,6 +429,7 @@ def build_spans(entries: list[dict]) -> tuple[list[dict], dict, list[str], str |
|
|
|
398
429
|
if pending_user_attachments:
|
|
399
430
|
llm_span["attachments"] = pending_user_attachments
|
|
400
431
|
spans.append(llm_span)
|
|
432
|
+
llm_span_by_id[str(span_id)] = llm_span
|
|
401
433
|
pending_user_text = "" # consumed by this generation
|
|
402
434
|
pending_user_attachments = [] # consumed by this generation
|
|
403
435
|
|
|
@@ -407,29 +439,17 @@ def build_spans(entries: list[dict]) -> tuple[list[dict], dict, list[str], str |
|
|
|
407
439
|
totals["total_tokens"] += in_tok + out_tok
|
|
408
440
|
|
|
409
441
|
for tu in _blocks(content, "tool_use"):
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
442
|
+
_append_tool_span(
|
|
443
|
+
spans,
|
|
444
|
+
totals,
|
|
445
|
+
tu,
|
|
446
|
+
parent_span_id=str(span_id),
|
|
447
|
+
ts=ts,
|
|
448
|
+
results_by_id=results_by_id,
|
|
449
|
+
errors_by_id=errors_by_id,
|
|
450
|
+
result_ts_by_id=result_ts_by_id,
|
|
451
|
+
attachments_by_tool_id=attachments_by_tool_id,
|
|
415
452
|
)
|
|
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
453
|
|
|
434
454
|
prev_ts = ts
|
|
435
455
|
|
|
@@ -437,6 +457,42 @@ def build_spans(entries: list[dict]) -> tuple[list[dict], dict, list[str], str |
|
|
|
437
457
|
return spans, totals, models, None
|
|
438
458
|
|
|
439
459
|
|
|
460
|
+
def _append_tool_span(
|
|
461
|
+
spans: list[dict],
|
|
462
|
+
totals: dict,
|
|
463
|
+
tu: dict,
|
|
464
|
+
*,
|
|
465
|
+
parent_span_id: str,
|
|
466
|
+
ts: str,
|
|
467
|
+
results_by_id: dict[str, str],
|
|
468
|
+
errors_by_id: dict[str, bool],
|
|
469
|
+
result_ts_by_id: dict[str, str],
|
|
470
|
+
attachments_by_tool_id: dict[str, list[dict]],
|
|
471
|
+
) -> None:
|
|
472
|
+
"""Append one ``tool`` child span for a ``tool_use`` block to ``spans``."""
|
|
473
|
+
tid = str(tu.get("id") or uuid.uuid4().hex)
|
|
474
|
+
tname = tu.get("name") or "unknown"
|
|
475
|
+
tu_input = tu.get("input")
|
|
476
|
+
input_str = tu_input if isinstance(tu_input, str) else json.dumps(tu_input, ensure_ascii=False)
|
|
477
|
+
tool_span = {
|
|
478
|
+
"span_id": tid,
|
|
479
|
+
"parent_span_id": parent_span_id,
|
|
480
|
+
"name": f"Tool: {tname}",
|
|
481
|
+
"span_type": "tool",
|
|
482
|
+
"start_time": ts,
|
|
483
|
+
"end_time": result_ts_by_id.get(tid, ts),
|
|
484
|
+
"input": _truncate(input_str),
|
|
485
|
+
"output": _truncate(results_by_id.get(tid, "")) or None,
|
|
486
|
+
"status": "error" if errors_by_id.get(tid) else "ok",
|
|
487
|
+
"attributes": {"tool_name": tname, "tool_id": tid},
|
|
488
|
+
}
|
|
489
|
+
tool_atts = attachments_by_tool_id.get(tid)
|
|
490
|
+
if tool_atts:
|
|
491
|
+
tool_span["attachments"] = tool_atts
|
|
492
|
+
spans.append(tool_span)
|
|
493
|
+
totals["tool_call_count"] += 1
|
|
494
|
+
|
|
495
|
+
|
|
440
496
|
def user_ref() -> str:
|
|
441
497
|
"""Resolve the engineer's real identity for attribution.
|
|
442
498
|
|
|
@@ -537,42 +593,19 @@ def post_event(event: dict) -> bool:
|
|
|
537
593
|
return False
|
|
538
594
|
|
|
539
595
|
|
|
540
|
-
def
|
|
541
|
-
"""Per-session state file.
|
|
542
|
-
|
|
543
|
-
One file per session key, NOT one shared JSON: with the shared file two
|
|
544
|
-
concurrent sessions raced load→save and the last writer clobbered the other
|
|
545
|
-
session's offset back, so its next fire re-read (and re-sent) transcript
|
|
546
|
-
chunks it had already shipped.
|
|
547
|
-
"""
|
|
548
|
-
return STATE_DIR / f"claude_code_state.{key[:32]}.json"
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
def load_state(key: str) -> dict:
|
|
552
|
-
f = _state_file(key)
|
|
596
|
+
def load_state() -> dict:
|
|
553
597
|
try:
|
|
554
|
-
if
|
|
555
|
-
return {key: json.loads(f.read_text(encoding="utf-8"))}
|
|
598
|
+
return json.loads(STATE_FILE.read_text(encoding="utf-8")) if STATE_FILE.exists() else {}
|
|
556
599
|
except Exception:
|
|
557
600
|
return {}
|
|
558
|
-
# One-time migration: pick this session's entry out of the legacy shared file.
|
|
559
|
-
try:
|
|
560
|
-
if STATE_FILE.exists():
|
|
561
|
-
legacy = json.loads(STATE_FILE.read_text(encoding="utf-8"))
|
|
562
|
-
if key in legacy:
|
|
563
|
-
return {key: legacy[key]}
|
|
564
|
-
except Exception:
|
|
565
|
-
pass
|
|
566
|
-
return {}
|
|
567
601
|
|
|
568
602
|
|
|
569
|
-
def save_state(state: dict
|
|
603
|
+
def save_state(state: dict) -> None:
|
|
570
604
|
try:
|
|
571
605
|
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
572
|
-
|
|
573
|
-
tmp =
|
|
574
|
-
|
|
575
|
-
os.replace(tmp, f)
|
|
606
|
+
tmp = STATE_FILE.with_suffix(".tmp")
|
|
607
|
+
tmp.write_text(json.dumps(state, sort_keys=True), encoding="utf-8")
|
|
608
|
+
os.replace(tmp, STATE_FILE)
|
|
576
609
|
except Exception:
|
|
577
610
|
pass
|
|
578
611
|
|
|
@@ -583,10 +616,10 @@ def build_event(payload: dict) -> dict | None:
|
|
|
583
616
|
if not session_id or path is None:
|
|
584
617
|
return None
|
|
585
618
|
|
|
619
|
+
state = load_state()
|
|
586
620
|
key = hashlib.sha256(f"{session_id}::{path}".encode()).hexdigest()
|
|
587
|
-
state = load_state(key)
|
|
588
621
|
entries = read_new_entries(path, state, key)
|
|
589
|
-
save_state(state
|
|
622
|
+
save_state(state)
|
|
590
623
|
if not entries:
|
|
591
624
|
return None
|
|
592
625
|
|
|
@@ -595,12 +628,7 @@ def build_event(payload: dict) -> dict | None:
|
|
|
595
628
|
return None
|
|
596
629
|
|
|
597
630
|
repo = Path(cwd).name if cwd else None
|
|
598
|
-
|
|
599
|
-
# upserts the SAME trace, so a re-sent chunk (offset race, retry) replaces
|
|
600
|
-
# its span rows in the ReplacingMergeTree instead of minting a new trace
|
|
601
|
-
# per second and double-counting. The date suffix splits multi-day sessions.
|
|
602
|
-
day = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
603
|
-
src_trace = f"{session_id}:{day}"
|
|
631
|
+
src_trace = f"{session_id}:{int(time.time())}"
|
|
604
632
|
trace_id = str(uuid.uuid5(_TRACE_NAMESPACE, f"{SOURCE}:{src_trace}"))
|
|
605
633
|
times = [s["start_time"] for s in spans]
|
|
606
634
|
|
package/dist/index.js
CHANGED
|
File without changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "octarin-cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
4
4
|
"description": "Octarin's per-user CLI: install AI-coding capture (`octarin init` / `init-repo`) and authorize a machine (`octarin login`). Streams your Claude Code / Cursor / Codex usage to your Octarin workspace.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"octarin",
|