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.
@@ -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
- 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)
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 _state_file(key: str) -> Path:
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 f.exists():
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, key: str) -> None:
603
+ def save_state(state: dict) -> None:
570
604
  try:
571
605
  STATE_DIR.mkdir(parents=True, exist_ok=True)
572
- f = _state_file(key)
573
- tmp = f.with_suffix(".tmp")
574
- tmp.write_text(json.dumps(state.get(key) or {}, sort_keys=True), encoding="utf-8")
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, key)
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
- # Day-stable trace id (like the Cursor hook): every fire for a session
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",
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",