cctally 1.38.1 → 1.40.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/CHANGELOG.md +14 -0
- package/bin/_cctally_cache.py +19 -7
- package/bin/_cctally_dashboard.py +117 -0
- package/bin/_cctally_db.py +18 -0
- package/bin/_lib_conversation.py +120 -7
- package/bin/_lib_conversation_query.py +265 -8
- package/dashboard/static/assets/{index-BEpz2fLq.css → index-BXm_vBKA.css} +1 -1
- package/dashboard/static/assets/index-BtbVKnkm.js +68 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +1 -1
- package/dashboard/static/assets/index-CTfLoXHj.js +0 -66
|
@@ -11,10 +11,12 @@ deduped session_entries row (idx_entries_dedup), via the shared pricing helper
|
|
|
11
11
|
— never per physical fragment and never from cost_usd_raw (often NULL).
|
|
12
12
|
"""
|
|
13
13
|
from __future__ import annotations
|
|
14
|
+
import base64 as _base64
|
|
14
15
|
import json as _json
|
|
15
16
|
import os
|
|
16
17
|
import re
|
|
17
18
|
import sqlite3
|
|
19
|
+
from datetime import datetime as _datetime
|
|
18
20
|
|
|
19
21
|
# Public surface (Plan 2): shipped in the npm tarball + brew formula + public
|
|
20
22
|
# mirror — imported by the dashboard's conversation endpoints at runtime.
|
|
@@ -24,6 +26,9 @@ from _lib_pricing import _calculate_entry_cost
|
|
|
24
26
|
# content block the same way the parser does at ingest — reuse the parser's
|
|
25
27
|
# _stringify so the full (un-capped) result text matches the cached/capped one.
|
|
26
28
|
from _lib_conversation import _stringify
|
|
29
|
+
# #177 S4: the media-route reader walks a content array with the SAME ordinal
|
|
30
|
+
# generator the ingest placeholders used, so "media item N" addresses one item.
|
|
31
|
+
from _lib_conversation import iter_media_items
|
|
27
32
|
|
|
28
33
|
|
|
29
34
|
# Mirror of dashboard/web/src/conversations/systemMarkers.ts::MARKER_RE — anchored
|
|
@@ -389,14 +394,15 @@ def _turn_usage_map(conn, turn_keys):
|
|
|
389
394
|
return usage
|
|
390
395
|
|
|
391
396
|
|
|
392
|
-
def
|
|
393
|
-
"""
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
397
|
+
def _assemble_session(conn, session_id):
|
|
398
|
+
"""Shared assembly for get_conversation / get_conversation_outline (#177 S5).
|
|
399
|
+
|
|
400
|
+
Runs the full dedup → turn-grouping → fold → sweep → meta-classify →
|
|
401
|
+
cost/usage-stamp pipeline over the WHOLE session and returns the
|
|
402
|
+
pre-pagination state, so the outline's turns match the reader's items 1:1
|
|
403
|
+
BY CONSTRUCTION (Codex F8 — one grouping pass, never two implementations).
|
|
404
|
+
Returns None for an unknown session.
|
|
405
|
+
"""
|
|
400
406
|
exists = conn.execute(
|
|
401
407
|
"SELECT 1 FROM conversation_messages WHERE session_id=? LIMIT 1",
|
|
402
408
|
(session_id,)).fetchone()
|
|
@@ -489,6 +495,8 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
|
|
|
489
495
|
agent_link = {} # tool_use id -> (agent_id, raw_meta)
|
|
490
496
|
ask_link = {} # tool_use id -> (answers, annotations) (#177 S2)
|
|
491
497
|
bash_link = {} # tool_use id -> (stderr, interrupted) (#177 S3)
|
|
498
|
+
web_search_link = {} # tool_use id -> web_search payload (#177 S4)
|
|
499
|
+
web_fetch_link = {} # tool_use id -> web_fetch payload (#177 S4)
|
|
492
500
|
task_link = {} # tool_use id -> {"task_id", "task_list"} (Task* checklist)
|
|
493
501
|
for it in items:
|
|
494
502
|
for b in it["blocks"]:
|
|
@@ -510,6 +518,12 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
|
|
|
510
518
|
bintr = b.pop("bash_interrupted", None)
|
|
511
519
|
if b.get("tool_use_id") is not None and (bstderr is not None or bintr):
|
|
512
520
|
bash_link[b["tool_use_id"]] = (bstderr, bool(bintr))
|
|
521
|
+
ws = b.pop("web_search", None) # #177 S4
|
|
522
|
+
if ws is not None and b.get("tool_use_id") is not None:
|
|
523
|
+
web_search_link[b["tool_use_id"]] = ws
|
|
524
|
+
wf = b.pop("web_fetch", None) # #177 S4
|
|
525
|
+
if wf is not None and b.get("tool_use_id") is not None:
|
|
526
|
+
web_fetch_link[b["tool_use_id"]] = wf
|
|
513
527
|
tid_ = b.pop("task_id", None) # Task* checklist
|
|
514
528
|
tlist_ = b.pop("task_list", None)
|
|
515
529
|
if b.get("tool_use_id") is not None and (tid_ is not None or tlist_ is not None):
|
|
@@ -553,6 +567,9 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
|
|
|
553
567
|
"truncated": bool(res_block.get("truncated")),
|
|
554
568
|
"full_length": res_block.get("full_length"),
|
|
555
569
|
"is_error": bool(res_block.get("is_error"))}
|
|
570
|
+
res_media = res_block.get("media") # #177 S4: public render-ready key
|
|
571
|
+
if res_media:
|
|
572
|
+
use_block["result"]["media"] = res_media
|
|
556
573
|
owner["member_uuids"].append(tr["anchor"]["uuid"])
|
|
557
574
|
drop.add(id(tr))
|
|
558
575
|
# else: leave tr standalone (orphan / multi-owner / mixed) — a folded
|
|
@@ -583,6 +600,14 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
|
|
|
583
600
|
b["stderr"] = blink[0]
|
|
584
601
|
if blink[1]:
|
|
585
602
|
b["interrupted"] = True
|
|
603
|
+
if b.get("name") == "WebSearch": # #177 S4: name-keyed
|
|
604
|
+
wslink = web_search_link.get(b["tool_use_id"])
|
|
605
|
+
if wslink is not None:
|
|
606
|
+
b["web_search"] = wslink
|
|
607
|
+
if b.get("name") == "WebFetch": # #177 S4: name-keyed
|
|
608
|
+
wflink = web_fetch_link.get(b["tool_use_id"])
|
|
609
|
+
if wflink is not None:
|
|
610
|
+
b["web_fetch"] = wflink
|
|
586
611
|
|
|
587
612
|
# ---- Phase 3b: fold the Task* op stream into per-run checklist snapshots ----
|
|
588
613
|
_fold_task_runs(items, task_link)
|
|
@@ -670,6 +695,26 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
|
|
|
670
695
|
for it in items:
|
|
671
696
|
it.pop("_source_tool_use_id", None)
|
|
672
697
|
|
|
698
|
+
return {"items": items, "logical": logical,
|
|
699
|
+
"subagent_meta": subagent_meta, "header_cost": header_cost}
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def get_conversation(conn, session_id, *, after=None, limit=500):
|
|
703
|
+
"""Reader payload for one session (spec §3.2). Returns None for an unknown
|
|
704
|
+
session. Dedups logical messages by (session_id, uuid) (canonical = earliest
|
|
705
|
+
timestamp), groups assistant fragments into turn items by (msg_id, req_id),
|
|
706
|
+
joins cost once, anchors a turn on its prose-bearing fragment, and exposes
|
|
707
|
+
every member fragment uuid for jump resolution. Cursor over (timestamp_utc,
|
|
708
|
+
id); ~500 items/page."""
|
|
709
|
+
limit = max(1, min(int(limit), 1000))
|
|
710
|
+
asm = _assemble_session(conn, session_id)
|
|
711
|
+
if asm is None:
|
|
712
|
+
return None
|
|
713
|
+
items = asm["items"]
|
|
714
|
+
logical = asm["logical"]
|
|
715
|
+
subagent_meta = asm["subagent_meta"]
|
|
716
|
+
header_cost = asm["header_cost"]
|
|
717
|
+
|
|
673
718
|
# Cursor pagination over the item list (anchored to each item's canonical id).
|
|
674
719
|
# A non-None `after` that matches no item's anchor (stale/deleted cursor)
|
|
675
720
|
# yields an EMPTY page — never silently re-serves the head (M1).
|
|
@@ -720,6 +765,104 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
|
|
|
720
765
|
}
|
|
721
766
|
|
|
722
767
|
|
|
768
|
+
_OUTLINE_LABEL_CAP = 120
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def _outline_label(text):
|
|
772
|
+
"""First non-blank line, capped at _OUTLINE_LABEL_CAP chars ('' when none)."""
|
|
773
|
+
for ln in (text or "").splitlines():
|
|
774
|
+
s = ln.strip()
|
|
775
|
+
if s:
|
|
776
|
+
return s[:_OUTLINE_LABEL_CAP]
|
|
777
|
+
return ""
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def _parse_outline_ts(ts):
|
|
781
|
+
if not ts:
|
|
782
|
+
return None
|
|
783
|
+
try:
|
|
784
|
+
return _datetime.fromisoformat(str(ts).replace("Z", "+00:00"))
|
|
785
|
+
except ValueError:
|
|
786
|
+
return None
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def get_conversation_outline(conn, session_id):
|
|
790
|
+
"""Full-session per-turn skeleton + aggregates (#177 S5, spec §1).
|
|
791
|
+
|
|
792
|
+
No pagination — every grouped turn, but only skeleton fields (no inputs,
|
|
793
|
+
no result bodies, no full prose). `ts` is NULLABLE (Codex F6); stats and
|
|
794
|
+
every consumer tolerate it. Stats derive from the SAME assembled items the
|
|
795
|
+
reader pages (Codex F8). Returns None for an unknown session.
|
|
796
|
+
"""
|
|
797
|
+
asm = _assemble_session(conn, session_id)
|
|
798
|
+
if asm is None:
|
|
799
|
+
return None
|
|
800
|
+
items, logical = asm["items"], asm["logical"]
|
|
801
|
+
turns = []
|
|
802
|
+
turn_counts = {"total": 0, "human": 0, "assistant": 0, "tool_result": 0, "meta": 0}
|
|
803
|
+
tool_counts, models = {}, {}
|
|
804
|
+
error_count = 0
|
|
805
|
+
tokens = {"input": 0, "output": 0, "cache_creation": 0, "cache_read": 0}
|
|
806
|
+
for it in items:
|
|
807
|
+
kind = it["kind"]
|
|
808
|
+
turn_counts["total"] += 1
|
|
809
|
+
if kind in turn_counts:
|
|
810
|
+
turn_counts[kind] += 1
|
|
811
|
+
t = {"uuid": it["anchor"]["uuid"], "kind": kind, "ts": it["ts"],
|
|
812
|
+
"label": _outline_label(it.get("text", "")),
|
|
813
|
+
"member_uuids": list(it["member_uuids"]),
|
|
814
|
+
"subagent_key": it["subagent_key"], "parent_uuid": it["parent_uuid"],
|
|
815
|
+
"is_sidechain": it["is_sidechain"]}
|
|
816
|
+
tools, thinking = [], []
|
|
817
|
+
for b in it["blocks"]:
|
|
818
|
+
bk = b.get("kind")
|
|
819
|
+
if bk in ("tool_call", "tool_use"):
|
|
820
|
+
res = b.get("result")
|
|
821
|
+
err = bool(res and res.get("is_error"))
|
|
822
|
+
tools.append({"name": b.get("name"), "is_error": err})
|
|
823
|
+
elif bk == "tool_result": # orphan error channel (spec delta b)
|
|
824
|
+
tools.append({"name": None, "is_error": bool(b.get("is_error"))})
|
|
825
|
+
elif bk == "thinking":
|
|
826
|
+
ln = _outline_label(b.get("text", ""))
|
|
827
|
+
if ln:
|
|
828
|
+
thinking.append(ln)
|
|
829
|
+
for tref in tools:
|
|
830
|
+
if tref["is_error"]:
|
|
831
|
+
error_count += 1
|
|
832
|
+
if tref["name"]:
|
|
833
|
+
tool_counts[tref["name"]] = tool_counts.get(tref["name"], 0) + 1
|
|
834
|
+
if tools:
|
|
835
|
+
t["tools"] = tools
|
|
836
|
+
if thinking:
|
|
837
|
+
t["thinking"] = thinking
|
|
838
|
+
if kind == "assistant":
|
|
839
|
+
if it.get("model"):
|
|
840
|
+
t["model"] = it["model"]
|
|
841
|
+
models[it["model"]] = models.get(it["model"], 0) + 1
|
|
842
|
+
tok = it.get("tokens")
|
|
843
|
+
if tok is not None:
|
|
844
|
+
t["tokens"] = tok
|
|
845
|
+
for k in tokens:
|
|
846
|
+
tokens[k] += tok.get(k, 0)
|
|
847
|
+
if kind == "meta":
|
|
848
|
+
t["meta_kind"] = it.get("meta_kind")
|
|
849
|
+
t["skill_name"] = it.get("skill_name")
|
|
850
|
+
if not t["label"]:
|
|
851
|
+
t["label"] = _outline_label(it.get("skill_name") or "")
|
|
852
|
+
turns.append(t)
|
|
853
|
+
ts_vals = [r[2] for r in logical if r[2]]
|
|
854
|
+
d0 = _parse_outline_ts(ts_vals[0] if ts_vals else None)
|
|
855
|
+
d1 = _parse_outline_ts(ts_vals[-1] if ts_vals else None)
|
|
856
|
+
duration = int((d1 - d0).total_seconds()) if d0 and d1 else None
|
|
857
|
+
return {"session_id": session_id,
|
|
858
|
+
"subagent_meta": asm["subagent_meta"],
|
|
859
|
+
"stats": {"turns": turn_counts, "tool_counts": tool_counts,
|
|
860
|
+
"error_count": error_count, "models": models,
|
|
861
|
+
"duration_seconds": duration, "tokens": tokens,
|
|
862
|
+
"cost_usd": asm["header_cost"]},
|
|
863
|
+
"turns": turns}
|
|
864
|
+
|
|
865
|
+
|
|
723
866
|
_TASK_TRIO = ("TaskCreate", "TaskUpdate", "TaskList")
|
|
724
867
|
|
|
725
868
|
|
|
@@ -1317,3 +1460,117 @@ def read_full_payload(source_path, byte_offset, tool_use_id, which):
|
|
|
1317
1460
|
resp["stderr"] = tur["stderr"][:_FULL_PAYLOAD_CEILING] # per-stream bound (see above)
|
|
1318
1461
|
return resp
|
|
1319
1462
|
return None
|
|
1463
|
+
|
|
1464
|
+
|
|
1465
|
+
# ---------------------------------------------------------------------------
|
|
1466
|
+
# #177 S4: on-demand media kernel. The route re-reads the source JSONL line by
|
|
1467
|
+
# byte_offset (the #178 mechanism), decodes the base64 of the addressed media
|
|
1468
|
+
# ordinal, and serves the raw bytes — nothing is ever written to cache.db.
|
|
1469
|
+
# ---------------------------------------------------------------------------
|
|
1470
|
+
_MEDIA_LINE_CEILING = 64 * 1024 * 1024 # raw line read cap (pathological guard)
|
|
1471
|
+
_MEDIA_PAYLOAD_CEILING = 20 * 1024 * 1024 # decoded cap; enforced on ENCODED length
|
|
1472
|
+
# Response Content-Type is the matched constant — never an echoed transcript string.
|
|
1473
|
+
_MEDIA_TYPE_ALLOWLIST = {
|
|
1474
|
+
"image/png": "image/png", "image/jpeg": "image/jpeg",
|
|
1475
|
+
"image/gif": "image/gif", "image/webp": "image/webp",
|
|
1476
|
+
"application/pdf": "application/pdf",
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
|
|
1480
|
+
def locate_media(conn, session_id, *, tool_use_id=None, uuid=None, index=0):
|
|
1481
|
+
"""``(source_path, byte_offset)`` for the row whose stored placeholder
|
|
1482
|
+
carries media ordinal ``index`` — tool_use_id mode reads tool_result
|
|
1483
|
+
``media[]``; uuid mode reads user-content image/document blocks. Mirrors
|
|
1484
|
+
locate_tool_payload: instr() prefilter (never LIKE — ids contain ``_``),
|
|
1485
|
+
candidates parsed + exact-matched, deterministic ORDER BY matching
|
|
1486
|
+
get_conversation. ``None`` -> 404. Pre-reingest rows have no placeholder
|
|
1487
|
+
and correctly 404 (the client renders the badge for them anyway)."""
|
|
1488
|
+
if tool_use_id is not None:
|
|
1489
|
+
rows = conn.execute(
|
|
1490
|
+
"SELECT source_path, byte_offset, blocks_json FROM conversation_messages "
|
|
1491
|
+
"WHERE session_id=? AND instr(blocks_json, ?) > 0 "
|
|
1492
|
+
"ORDER BY timestamp_utc, id", (session_id, tool_use_id)).fetchall()
|
|
1493
|
+
else:
|
|
1494
|
+
rows = conn.execute(
|
|
1495
|
+
"SELECT source_path, byte_offset, blocks_json FROM conversation_messages "
|
|
1496
|
+
"WHERE session_id=? AND uuid=? ORDER BY timestamp_utc, id",
|
|
1497
|
+
(session_id, uuid)).fetchall()
|
|
1498
|
+
for source_path, byte_offset, blocks_json in rows:
|
|
1499
|
+
try:
|
|
1500
|
+
blocks = _json.loads(blocks_json)
|
|
1501
|
+
except (ValueError, TypeError):
|
|
1502
|
+
continue
|
|
1503
|
+
if not isinstance(blocks, list):
|
|
1504
|
+
continue
|
|
1505
|
+
for b in blocks:
|
|
1506
|
+
if not isinstance(b, dict):
|
|
1507
|
+
continue
|
|
1508
|
+
if tool_use_id is not None:
|
|
1509
|
+
if (b.get("kind") == "tool_result"
|
|
1510
|
+
and b.get("tool_use_id") == tool_use_id):
|
|
1511
|
+
for m in b.get("media") or []:
|
|
1512
|
+
if isinstance(m, dict) and m.get("index") == index:
|
|
1513
|
+
return source_path, byte_offset
|
|
1514
|
+
elif (b.get("kind") in ("image", "document")
|
|
1515
|
+
and b.get("index") == index):
|
|
1516
|
+
return source_path, byte_offset
|
|
1517
|
+
return None
|
|
1518
|
+
|
|
1519
|
+
|
|
1520
|
+
def read_media_bytes(source_path, byte_offset, *, tool_use_id=None, uuid=None,
|
|
1521
|
+
index=0):
|
|
1522
|
+
"""Re-read the source line and return ``("ok", media_type, raw_bytes)`` for
|
|
1523
|
+
media ordinal ``index``, else ``("unsupported"|"too_large"|"gone", None,
|
|
1524
|
+
None)`` (-> 404 / 413 / 410). The decoded cap is enforced as an
|
|
1525
|
+
ENCODED-length precheck — never decode-then-measure (Codex F4); decode is
|
|
1526
|
+
strict (validate=True; binascii.Error subclasses ValueError). The media
|
|
1527
|
+
walk IS iter_media_items — the same generator the ingest placeholders used,
|
|
1528
|
+
so ordinals cannot drift (spec §4.1 chokepoint)."""
|
|
1529
|
+
try:
|
|
1530
|
+
with open(source_path, "rb") as fh:
|
|
1531
|
+
fh.seek(byte_offset)
|
|
1532
|
+
line = fh.readline(_MEDIA_LINE_CEILING + 1)
|
|
1533
|
+
if len(line) > _MEDIA_LINE_CEILING:
|
|
1534
|
+
return ("too_large", None, None)
|
|
1535
|
+
obj = _json.loads(line)
|
|
1536
|
+
except (OSError, ValueError):
|
|
1537
|
+
return ("gone", None, None)
|
|
1538
|
+
if not isinstance(obj, dict):
|
|
1539
|
+
return ("gone", None, None)
|
|
1540
|
+
content = (obj.get("message") or {}).get("content")
|
|
1541
|
+
if not isinstance(content, list):
|
|
1542
|
+
return ("gone", None, None)
|
|
1543
|
+
if tool_use_id is not None:
|
|
1544
|
+
target = None
|
|
1545
|
+
for b in content:
|
|
1546
|
+
if (isinstance(b, dict) and b.get("type") == "tool_result"
|
|
1547
|
+
and b.get("tool_use_id") == tool_use_id):
|
|
1548
|
+
target = b.get("content")
|
|
1549
|
+
break
|
|
1550
|
+
if target is None:
|
|
1551
|
+
return ("gone", None, None)
|
|
1552
|
+
else:
|
|
1553
|
+
target = content
|
|
1554
|
+
item = None
|
|
1555
|
+
for idx, m in iter_media_items(target):
|
|
1556
|
+
if idx == index:
|
|
1557
|
+
item = m
|
|
1558
|
+
break
|
|
1559
|
+
if item is None:
|
|
1560
|
+
return ("gone", None, None)
|
|
1561
|
+
source = item.get("source")
|
|
1562
|
+
if not isinstance(source, dict):
|
|
1563
|
+
return ("gone", None, None)
|
|
1564
|
+
media_type = _MEDIA_TYPE_ALLOWLIST.get(source.get("media_type"))
|
|
1565
|
+
if media_type is None:
|
|
1566
|
+
return ("unsupported", None, None)
|
|
1567
|
+
data = source.get("data")
|
|
1568
|
+
if not isinstance(data, str):
|
|
1569
|
+
return ("gone", None, None)
|
|
1570
|
+
if len(data) > _MEDIA_PAYLOAD_CEILING * 4 // 3:
|
|
1571
|
+
return ("too_large", None, None)
|
|
1572
|
+
try:
|
|
1573
|
+
raw = _base64.b64decode(data, validate=True)
|
|
1574
|
+
except ValueError:
|
|
1575
|
+
return ("gone", None, None)
|
|
1576
|
+
return ("ok", media_type, raw)
|