octarin-cli 0.3.1 → 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.
@@ -44,6 +44,7 @@ import getpass
44
44
  import json
45
45
  import os
46
46
  import re
47
+ import ssl
47
48
  import subprocess
48
49
  import sys
49
50
  import urllib.error
@@ -289,6 +290,14 @@ def parse_claude_transcript( # noqa: PLR0915 - top-down jsonl parser; splitting
289
290
  :_OUTPUT_CAP
290
291
  ],
291
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
+ ),
292
301
  }
293
302
  )
294
303
  prev_ts = ts
@@ -940,6 +949,49 @@ def _filter_spans_by_cutoff(event: dict, cutoff: datetime | None) -> dict | None
940
949
  return event
941
950
 
942
951
 
952
+ _SSL_CTX: ssl.SSLContext | None = None
953
+
954
+
955
+ def _ssl_context() -> ssl.SSLContext:
956
+ """A cert-verifying TLS context that also works on Pythons whose default
957
+ trust store is empty — the cause of ``CERTIFICATE_VERIFY_FAILED: unable to
958
+ get local issuer certificate`` on many macOS (python.org) installs. Resolves
959
+ a CA bundle, all options verifying: ``OCTARIN_CA_BUNDLE`` / ``SSL_CERT_FILE``
960
+ env, then ``certifi`` if importable, then known system bundles, then the
961
+ interpreter default. Built once and reused.
962
+ """
963
+ global _SSL_CTX
964
+ if _SSL_CTX is not None:
965
+ return _SSL_CTX
966
+ cafile: str | None = None
967
+ for env in ("OCTARIN_CA_BUNDLE", "SSL_CERT_FILE"):
968
+ p = os.environ.get(env)
969
+ if p and os.path.exists(p):
970
+ cafile = p
971
+ break
972
+ if cafile is None:
973
+ try:
974
+ import certifi # noqa: PLC0415 - optional, used only if present
975
+
976
+ cafile = certifi.where()
977
+ except Exception:
978
+ for p in (
979
+ "/etc/ssl/cert.pem", # macOS base system, many Linux
980
+ "/opt/homebrew/etc/openssl@3/cert.pem", # Apple-silicon Homebrew
981
+ "/usr/local/etc/openssl@3/cert.pem", # Intel Homebrew
982
+ "/etc/ssl/certs/ca-certificates.crt", # Debian/Ubuntu
983
+ "/etc/pki/tls/certs/ca-bundle.crt", # RHEL/CentOS
984
+ ):
985
+ if os.path.exists(p):
986
+ cafile = p
987
+ break
988
+ try:
989
+ _SSL_CTX = ssl.create_default_context(cafile=cafile)
990
+ except Exception:
991
+ _SSL_CTX = ssl.create_default_context()
992
+ return _SSL_CTX
993
+
994
+
943
995
  def post_event(
944
996
  url: str, key: str, event: dict, timeout: float = 20.0
945
997
  ) -> tuple[bool, str]:
@@ -955,7 +1007,7 @@ def post_event(
955
1007
  method="POST",
956
1008
  )
957
1009
  try:
958
- with urllib.request.urlopen(req, timeout=timeout) as resp:
1010
+ with urllib.request.urlopen(req, timeout=timeout, context=_ssl_context()) as resp:
959
1011
  raw = resp.read().decode("utf-8", "replace")
960
1012
  try:
961
1013
  n = json.loads(raw).get("span_count")
@@ -24,9 +24,9 @@ import getpass
24
24
  import hashlib
25
25
  import json
26
26
  import os
27
+ import ssl
27
28
  import subprocess
28
29
  import sys
29
- import time
30
30
  import urllib.request
31
31
  import uuid
32
32
  from datetime import datetime, timezone
@@ -476,6 +476,46 @@ def user_ref() -> str:
476
476
  return "unknown"
477
477
 
478
478
 
479
+ _SSL_CTX = None
480
+
481
+
482
+ def _ssl_context():
483
+ """Cert-verifying TLS context resilient to empty trust stores (the macOS
484
+ python.org ``CERTIFICATE_VERIFY_FAILED`` issue). Resolves a CA bundle —
485
+ OCTARIN_CA_BUNDLE / SSL_CERT_FILE env, certifi if importable, known system
486
+ bundles, else the interpreter default — and reuses it."""
487
+ global _SSL_CTX
488
+ if _SSL_CTX is not None:
489
+ return _SSL_CTX
490
+ cafile = None
491
+ for env in ("OCTARIN_CA_BUNDLE", "SSL_CERT_FILE"):
492
+ p = os.environ.get(env)
493
+ if p and os.path.exists(p):
494
+ cafile = p
495
+ break
496
+ if cafile is None:
497
+ try:
498
+ import certifi
499
+
500
+ cafile = certifi.where()
501
+ except Exception:
502
+ for p in (
503
+ "/etc/ssl/cert.pem",
504
+ "/opt/homebrew/etc/openssl@3/cert.pem",
505
+ "/usr/local/etc/openssl@3/cert.pem",
506
+ "/etc/ssl/certs/ca-certificates.crt",
507
+ "/etc/pki/tls/certs/ca-bundle.crt",
508
+ ):
509
+ if os.path.exists(p):
510
+ cafile = p
511
+ break
512
+ try:
513
+ _SSL_CTX = ssl.create_default_context(cafile=cafile)
514
+ except Exception:
515
+ _SSL_CTX = ssl.create_default_context()
516
+ return _SSL_CTX
517
+
518
+
479
519
  def post_event(event: dict) -> bool:
480
520
  """POST the IngestEvent. Returns True on 2xx, False otherwise (fail-open)."""
481
521
  url = os.environ.get("OCTARIN_INGEST_URL")
@@ -491,25 +531,48 @@ def post_event(event: dict) -> bool:
491
531
  if api_key:
492
532
  req.add_header("Authorization", f"Bearer {api_key}")
493
533
  try:
494
- with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT_S) as resp:
534
+ with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT_S, context=_ssl_context()) as resp:
495
535
  return 200 <= resp.status < 300
496
536
  except Exception:
497
537
  return False
498
538
 
499
539
 
500
- 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)
501
553
  try:
502
- 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"))}
503
556
  except Exception:
504
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 {}
505
567
 
506
568
 
507
- def save_state(state: dict) -> None:
569
+ def save_state(state: dict, key: str) -> None:
508
570
  try:
509
571
  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)
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)
513
576
  except Exception:
514
577
  pass
515
578
 
@@ -520,10 +583,10 @@ def build_event(payload: dict) -> dict | None:
520
583
  if not session_id or path is None:
521
584
  return None
522
585
 
523
- state = load_state()
524
586
  key = hashlib.sha256(f"{session_id}::{path}".encode()).hexdigest()
587
+ state = load_state(key)
525
588
  entries = read_new_entries(path, state, key)
526
- save_state(state)
589
+ save_state(state, key)
527
590
  if not entries:
528
591
  return None
529
592
 
@@ -532,7 +595,12 @@ def build_event(payload: dict) -> dict | None:
532
595
  return None
533
596
 
534
597
  repo = Path(cwd).name if cwd else None
535
- 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}"
536
604
  trace_id = str(uuid.uuid5(_TRACE_NAMESPACE, f"{SOURCE}:{src_trace}"))
537
605
  times = [s["start_time"] for s in spans]
538
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));
@@ -42,6 +42,7 @@ import getpass
42
42
  import hashlib
43
43
  import json
44
44
  import os
45
+ import ssl
45
46
  import subprocess
46
47
  import sys
47
48
  import time
@@ -547,6 +548,46 @@ def _notify_auth_required_once(project: str) -> None:
547
548
  )
548
549
 
549
550
 
551
+ _SSL_CTX = None
552
+
553
+
554
+ def _ssl_context():
555
+ """Cert-verifying TLS context resilient to empty trust stores (the macOS
556
+ python.org ``CERTIFICATE_VERIFY_FAILED`` issue). Resolves a CA bundle —
557
+ OCTARIN_CA_BUNDLE / SSL_CERT_FILE env, certifi if importable, known system
558
+ bundles, else the interpreter default — and reuses it."""
559
+ global _SSL_CTX
560
+ if _SSL_CTX is not None:
561
+ return _SSL_CTX
562
+ cafile = None
563
+ for env in ("OCTARIN_CA_BUNDLE", "SSL_CERT_FILE"):
564
+ p = os.environ.get(env)
565
+ if p and os.path.exists(p):
566
+ cafile = p
567
+ break
568
+ if cafile is None:
569
+ try:
570
+ import certifi
571
+
572
+ cafile = certifi.where()
573
+ except Exception:
574
+ for p in (
575
+ "/etc/ssl/cert.pem",
576
+ "/opt/homebrew/etc/openssl@3/cert.pem",
577
+ "/usr/local/etc/openssl@3/cert.pem",
578
+ "/etc/ssl/certs/ca-certificates.crt",
579
+ "/etc/pki/tls/certs/ca-bundle.crt",
580
+ ):
581
+ if os.path.exists(p):
582
+ cafile = p
583
+ break
584
+ try:
585
+ _SSL_CTX = ssl.create_default_context(cafile=cafile)
586
+ except Exception:
587
+ _SSL_CTX = ssl.create_default_context()
588
+ return _SSL_CTX
589
+
590
+
550
591
  def post_event(event: dict) -> bool:
551
592
  """POST the IngestEvent. Returns True on 2xx, False otherwise (fail-open).
552
593
 
@@ -585,7 +626,7 @@ def post_event(event: dict) -> bool:
585
626
  elif project:
586
627
  req.add_header("X-Octarin-Project", project)
587
628
  try:
588
- with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT_S) as resp:
629
+ with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT_S, context=_ssl_context()) as resp:
589
630
  return HTTP_OK <= resp.status < HTTP_MULTIPLE_CHOICES
590
631
  except urllib.error.HTTPError as exc:
591
632
  # Strict-auth signal from the server: print the login.sh hint, once.
@@ -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.1",
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",