octarin-cli 0.3.2 → 0.3.3

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.
@@ -290,6 +290,14 @@ def parse_claude_transcript( # noqa: PLR0915 - top-down jsonl parser; splitting
290
290
  :_OUTPUT_CAP
291
291
  ],
292
292
  "status": "ok",
293
+ # subagent turns bill against the session model;
294
+ # carry it so per-model analytics can attribute
295
+ # their tokens
296
+ **(
297
+ {"model": model}
298
+ if stype == "agent" and model
299
+ else {}
300
+ ),
293
301
  }
294
302
  )
295
303
  prev_ts = ts
@@ -27,7 +27,6 @@ import os
27
27
  import ssl
28
28
  import subprocess
29
29
  import sys
30
- import time
31
30
  import urllib.request
32
31
  import uuid
33
32
  from datetime import datetime, timezone
@@ -538,19 +537,42 @@ def post_event(event: dict) -> bool:
538
537
  return False
539
538
 
540
539
 
541
- def load_state() -> dict:
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)
542
553
  try:
543
- return json.loads(STATE_FILE.read_text(encoding="utf-8")) if STATE_FILE.exists() else {}
554
+ if f.exists():
555
+ return {key: json.loads(f.read_text(encoding="utf-8"))}
544
556
  except Exception:
545
557
  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 {}
546
567
 
547
568
 
548
- def save_state(state: dict) -> None:
569
+ def save_state(state: dict, key: str) -> None:
549
570
  try:
550
571
  STATE_DIR.mkdir(parents=True, exist_ok=True)
551
- tmp = STATE_FILE.with_suffix(".tmp")
552
- tmp.write_text(json.dumps(state, sort_keys=True), encoding="utf-8")
553
- os.replace(tmp, STATE_FILE)
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)
554
576
  except Exception:
555
577
  pass
556
578
 
@@ -561,10 +583,10 @@ def build_event(payload: dict) -> dict | None:
561
583
  if not session_id or path is None:
562
584
  return None
563
585
 
564
- state = load_state()
565
586
  key = hashlib.sha256(f"{session_id}::{path}".encode()).hexdigest()
587
+ state = load_state(key)
566
588
  entries = read_new_entries(path, state, key)
567
- save_state(state)
589
+ save_state(state, key)
568
590
  if not entries:
569
591
  return None
570
592
 
@@ -573,7 +595,12 @@ def build_event(payload: dict) -> dict | None:
573
595
  return None
574
596
 
575
597
  repo = Path(cwd).name if cwd else None
576
- src_trace = f"{session_id}:{int(time.time())}"
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}"
577
604
  trace_id = str(uuid.uuid5(_TRACE_NAMESPACE, f"{SOURCE}:{src_trace}"))
578
605
  times = [s["start_time"] for s in spans]
579
606
 
@@ -128,6 +128,9 @@ function toolSpanId(input) {
128
128
  function fromPostToolUse(input) {
129
129
  const tool = input.tool_name || "tool";
130
130
  const span = baseSpan(toolSpanId(input), tool, "tool");
131
+ // Cursor never exposes token usage, so the backend estimates tool spans from
132
+ // their I/O — stamp the session model so the estimate prices at its real rate.
133
+ span.model = input.model || null;
131
134
  span.input = truncate(asText(input.tool_input));
132
135
  span.output = truncate(asText(input.tool_output));
133
136
  span.attributes = {
@@ -144,6 +147,7 @@ function fromPostToolUse(input) {
144
147
  function fromPostToolUseFailure(input) {
145
148
  const tool = input.tool_name || "tool";
146
149
  const span = baseSpan(toolSpanId(input), tool, "tool");
150
+ span.model = input.model || null;
147
151
  span.status = "error";
148
152
  span.error_message = input.error_message || input.failure_type || "tool failed";
149
153
  span.input = truncate(asText(input.tool_input));
@@ -128,6 +128,9 @@ function toolSpanId(input) {
128
128
  function fromPostToolUse(input) {
129
129
  const tool = input.tool_name || "tool";
130
130
  const span = baseSpan(toolSpanId(input), tool, "tool");
131
+ // Cursor never exposes token usage, so the backend estimates tool spans from
132
+ // their I/O — stamp the session model so the estimate prices at its real rate.
133
+ span.model = input.model || null;
131
134
  span.input = truncate(asText(input.tool_input));
132
135
  span.output = truncate(asText(input.tool_output));
133
136
  span.attributes = {
@@ -144,6 +147,7 @@ function fromPostToolUse(input) {
144
147
  function fromPostToolUseFailure(input) {
145
148
  const tool = input.tool_name || "tool";
146
149
  const span = baseSpan(toolSpanId(input), tool, "tool");
150
+ span.model = input.model || null;
147
151
  span.status = "error";
148
152
  span.error_message = input.error_message || input.failure_type || "tool failed";
149
153
  span.input = truncate(asText(input.tool_input));
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.2",
3
+ "version": "0.3.3",
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",