octarin-cli 0.3.3 → 0.4.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.
@@ -27,6 +27,7 @@ import os
27
27
  import ssl
28
28
  import subprocess
29
29
  import sys
30
+ import urllib.parse
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
 
@@ -624,9 +680,70 @@ def build_event(payload: dict) -> dict | None:
624
680
  }
625
681
 
626
682
 
683
+ def _api_base() -> str:
684
+ """The API base URL (no trailing slash), derived from the capture env.
685
+
686
+ Prefers OCTARIN_API_BASE; otherwise strips ``/v1/ingest`` off OCTARIN_INGEST_URL.
687
+ """
688
+ base = (os.environ.get("OCTARIN_API_BASE") or "").rstrip("/")
689
+ if base:
690
+ return base
691
+ url = (os.environ.get("OCTARIN_INGEST_URL") or "").rstrip("/")
692
+ if url.endswith("/v1/ingest"):
693
+ return url[: -len("/v1/ingest")]
694
+ return ""
695
+
696
+
697
+ def inject_context(payload: dict) -> None:
698
+ """SessionStart: fetch the org's Memory pack and emit it as additionalContext.
699
+
700
+ Octarin Memory is the team's durable, shared knowledge (decisions, conventions,
701
+ gotchas). On a new session we pull the pack for this repo and hand it to Claude
702
+ Code as ``hookSpecificOutput.additionalContext`` so the agent starts with the
703
+ team's hard-won context instead of rediscovering it. Fail-open and silent: any
704
+ problem (no key, network, empty) prints nothing and the session proceeds.
705
+ """
706
+ base = _api_base()
707
+ api_key = os.environ.get("OCTARIN_API_KEY", "")
708
+ if not base or not api_key:
709
+ return
710
+ cwd = payload.get("cwd") or payload.get("workspace") or ""
711
+ repo = Path(cwd).name if cwd else ""
712
+ qs = urllib.parse.urlencode({"repo": repo, "limit": "8"})
713
+ req = urllib.request.Request(f"{base}/v1/memory/agent-context?{qs}", method="GET")
714
+ req.add_header("Authorization", f"Bearer {api_key}")
715
+ try:
716
+ with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT_S, context=_ssl_context()) as resp:
717
+ if not (200 <= resp.status < 300):
718
+ return
719
+ data = json.loads(resp.read().decode("utf-8"))
720
+ except Exception:
721
+ return
722
+ context = ((data or {}).get("context") or "").strip()
723
+ if not context:
724
+ return
725
+ header = (
726
+ "# Octarin Memory — durable decisions, conventions & gotchas your team has "
727
+ "recorded (shared across everyone). Treat as authoritative context:\n"
728
+ )
729
+ out = {
730
+ "hookSpecificOutput": {
731
+ "hookEventName": "SessionStart",
732
+ "additionalContext": header + context,
733
+ }
734
+ }
735
+ sys.stdout.write(json.dumps(out))
736
+
737
+
627
738
  def main() -> int:
628
739
  try:
629
740
  payload = read_payload()
741
+ # One script, two Claude Code hooks: SessionStart injects Memory context;
742
+ # Stop (the default) captures the finished turn.
743
+ event_name = str(payload.get("hook_event_name") or payload.get("hookEventName") or "")
744
+ if event_name == "SessionStart":
745
+ inject_context(payload)
746
+ return 0
630
747
  event = build_event(payload)
631
748
  if event is None:
632
749
  return 0
@@ -391,6 +391,11 @@ def build_spans( # noqa: PLR0915 - top-down transcript parser; splitting it
391
391
 
392
392
  pending_user_text = ""
393
393
  pending_user_attachments: list[dict] = []
394
+ # One API generation streams as SEVERAL transcript entries (one per content
395
+ # block — text, then each tool_use) that share the same message id and each
396
+ # repeat the generation's FULL usage. Merge them into ONE span keyed by that
397
+ # id: usage/cost counted once, outputs concatenated, tool children attached.
398
+ llm_span_by_id: dict[str, dict] = {}
394
399
  # ts of the previous transcript entry; the LLM call started when the user
395
400
  # prompt / tool result landed, finished when the assistant message appears.
396
401
  prev_ts: str | None = None
@@ -417,6 +422,32 @@ def build_spans( # noqa: PLR0915 - top-down transcript parser; splitting it
417
422
  span_id = _msg(entry).get("id") or uuid.uuid4().hex
418
423
  out_text = _truncate(_text(content))
419
424
 
425
+ existing = llm_span_by_id.get(str(span_id))
426
+ if existing is not None:
427
+ # Continuation entry of an already-seen generation: extend the span,
428
+ # never re-count its usage (each entry repeats the full totals).
429
+ existing["end_time"] = ts
430
+ if out_text:
431
+ joined = (
432
+ f"{existing['output']}\n{out_text}"
433
+ if existing["output"]
434
+ else out_text
435
+ )
436
+ existing["output"] = _truncate(joined)
437
+ for tu in _blocks(content, "tool_use"):
438
+ _append_tool_span(
439
+ spans,
440
+ totals,
441
+ tu,
442
+ parent_span_id=str(span_id),
443
+ ts=ts,
444
+ results_by_id=results_by_id,
445
+ result_ts_by_id=result_ts_by_id,
446
+ attachments_by_tool_id=attachments_by_tool_id,
447
+ )
448
+ prev_ts = ts
449
+ continue
450
+
420
451
  in_tok = usage.get("input", 0)
421
452
  out_tok = usage.get("output", 0)
422
453
  cache_r = usage.get("cache_read", 0)
@@ -443,6 +474,7 @@ def build_spans( # noqa: PLR0915 - top-down transcript parser; splitting it
443
474
  if pending_user_attachments:
444
475
  llm_span["attachments"] = pending_user_attachments
445
476
  spans.append(llm_span)
477
+ llm_span_by_id[str(span_id)] = llm_span
446
478
  pending_user_text = "" # consumed by this generation
447
479
  pending_user_attachments = [] # consumed by this generation
448
480
 
@@ -452,31 +484,16 @@ def build_spans( # noqa: PLR0915 - top-down transcript parser; splitting it
452
484
  totals["total_tokens"] += in_tok + out_tok
453
485
 
454
486
  for tu in _blocks(content, "tool_use"):
455
- tid = str(tu.get("id") or uuid.uuid4().hex)
456
- tname = tu.get("name") or "unknown"
457
- tu_input = tu.get("input")
458
- input_str = (
459
- tu_input
460
- if isinstance(tu_input, str)
461
- else json.dumps(tu_input, ensure_ascii=False)
487
+ _append_tool_span(
488
+ spans,
489
+ totals,
490
+ tu,
491
+ parent_span_id=str(span_id),
492
+ ts=ts,
493
+ results_by_id=results_by_id,
494
+ result_ts_by_id=result_ts_by_id,
495
+ attachments_by_tool_id=attachments_by_tool_id,
462
496
  )
463
- tool_span = {
464
- "span_id": tid,
465
- "parent_span_id": str(span_id),
466
- "name": f"Tool: {tname}",
467
- "span_type": "tool",
468
- "start_time": ts,
469
- "end_time": result_ts_by_id.get(tid, ts),
470
- "input": _truncate(input_str),
471
- "output": _truncate(results_by_id.get(tid, "")) or None,
472
- "status": "ok",
473
- "attributes": {"tool_name": tname, "tool_id": tid},
474
- }
475
- tool_atts = attachments_by_tool_id.get(tid)
476
- if tool_atts:
477
- tool_span["attachments"] = tool_atts
478
- spans.append(tool_span)
479
- totals["tool_call_count"] += 1
480
497
 
481
498
  prev_ts = ts
482
499
 
@@ -484,6 +501,43 @@ def build_spans( # noqa: PLR0915 - top-down transcript parser; splitting it
484
501
  return spans, totals, models, None
485
502
 
486
503
 
504
+ def _append_tool_span(
505
+ spans: list[dict],
506
+ totals: dict,
507
+ tu: dict,
508
+ *,
509
+ parent_span_id: str,
510
+ ts: str,
511
+ results_by_id: dict[str, str],
512
+ result_ts_by_id: dict[str, str],
513
+ attachments_by_tool_id: dict[str, list[dict]],
514
+ ) -> None:
515
+ """Append one ``tool`` child span for a ``tool_use`` block to ``spans``."""
516
+ tid = str(tu.get("id") or uuid.uuid4().hex)
517
+ tname = tu.get("name") or "unknown"
518
+ tu_input = tu.get("input")
519
+ input_str = (
520
+ tu_input if isinstance(tu_input, str) else json.dumps(tu_input, ensure_ascii=False)
521
+ )
522
+ tool_span = {
523
+ "span_id": tid,
524
+ "parent_span_id": parent_span_id,
525
+ "name": f"Tool: {tname}",
526
+ "span_type": "tool",
527
+ "start_time": ts,
528
+ "end_time": result_ts_by_id.get(tid, ts),
529
+ "input": _truncate(input_str),
530
+ "output": _truncate(results_by_id.get(tid, "")) or None,
531
+ "status": "ok",
532
+ "attributes": {"tool_name": tname, "tool_id": tid},
533
+ }
534
+ tool_atts = attachments_by_tool_id.get(tid)
535
+ if tool_atts:
536
+ tool_span["attachments"] = tool_atts
537
+ spans.append(tool_span)
538
+ totals["tool_call_count"] += 1
539
+
540
+
487
541
  def user_ref() -> str:
488
542
  """Resolve the engineer's real identity for attribution.
489
543
 
package/dist/init.js CHANGED
@@ -129,13 +129,22 @@ async function readJson(path) {
129
129
  function hasOctarin(value) {
130
130
  return JSON.stringify(value ?? "").toLowerCase().includes("octarin");
131
131
  }
132
- /** Merge the Stop hook into ~/.claude/settings.json, preserving other settings. */
132
+ /**
133
+ * Merge Octarin's Claude Code hooks into ~/.claude/settings.json, preserving
134
+ * other settings. Two events, ONE wrapper command (hook.py branches on the
135
+ * hook event): **Stop** captures the finished turn; **SessionStart** injects the
136
+ * team's shared Octarin Memory pack as context so a new session starts with the
137
+ * org's durable decisions/conventions/gotchas. Idempotent (deduped by the
138
+ * "octarin" marker in the command).
139
+ */
133
140
  async function mergeClaudeSettings(path, command) {
134
141
  const json = await readJson(path);
135
142
  const hooks = (json.hooks ??= {});
136
- const stop = (Array.isArray(hooks.Stop) ? hooks.Stop : (hooks.Stop = []));
137
- if (!stop.some(hasOctarin)) {
138
- stop.push({ hooks: [{ type: "command", command }] });
143
+ for (const event of ["Stop", "SessionStart"]) {
144
+ const arr = (Array.isArray(hooks[event]) ? hooks[event] : (hooks[event] = []));
145
+ if (!arr.some(hasOctarin)) {
146
+ arr.push({ hooks: [{ type: "command", command }] });
147
+ }
139
148
  }
140
149
  await fs.mkdir(dirname(path), { recursive: true });
141
150
  await fs.writeFile(path, JSON.stringify(json, null, 2) + "\n");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "octarin-cli",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
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",