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.
- package/assets/claude_code/hook.py +139 -22
- package/assets/repo-template/dot-claude/octarin/hook.py +78 -24
- package/dist/init.js +13 -4
- 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 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
|
-
|
|
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
|
|
|
@@ -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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
+
"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",
|