cctally 1.39.0 → 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 +6 -0
- package/bin/_cctally_dashboard.py +25 -0
- package/bin/_lib_conversation_query.py +128 -8
- package/dashboard/static/assets/{index-JAqj_fCI.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-E2kVc-MU.js +0 -67
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,12 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.40.0] - 2026-06-12
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Conversation viewer: a full-session navigation layer — a collapsible outline sidebar (toggle `o`) listing every turn as a landmark with nested thinking entries and scroll-sync highlighting, a stats overview (turn counts, duration, tokens, cost, models, tool histogram, and an error row that jumps to the first error), jump-to-next keys with no wrap-around (`e`/`E` errors, `u`/`U` prompts, `b`/`B` subagents, `p`/`P` plans/questions) mirrored by a clickable glyph cluster with counts, and focus modes (`v` cycles All → Chat → Prompts → Errors; hidden turns coalesce into a clickable `· N hidden ·` marker, and a jump whose target the mode would hide resets to All before landing) (#177 Session 5).
|
|
12
|
+
- Conversation viewer: per-turn timestamps and inter-turn markers — each turn header shows a quiet `· HH:mm` in your `display.tz` (precise instant in a tooltip), with a `⏸ 42 min later` gap rule between turns ten or more minutes apart, a `— Jun 13 —` rule when the calendar day changes, and a combined `⏸ 9.5 h later · Jun 13` when both apply — plus per-turn token footers that extend the assistant cost line to `$0.0214 · in 1.2k · out 4.8k · cache 310k` (tokens-only when a turn has usage but no attributable cost, cost-only for un-reingested turns) (#177 Session 5).
|
|
13
|
+
|
|
8
14
|
## [1.39.0] - 2026-06-12
|
|
9
15
|
|
|
10
16
|
### Added
|
|
@@ -5348,6 +5348,10 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5348
5348
|
elif path.startswith("/api/conversation/") and path.endswith("/media"):
|
|
5349
5349
|
# #177 S4: on-demand media bytes. Matched BEFORE the <id> reader.
|
|
5350
5350
|
self._handle_get_conversation_media(path)
|
|
5351
|
+
elif path.startswith("/api/conversation/") and path.endswith("/outline"):
|
|
5352
|
+
# #177 S5: full-session outline skeleton + stats. Matched BEFORE
|
|
5353
|
+
# the <id> reader catch-all (Codex F2 — same precedence as /payload).
|
|
5354
|
+
self._handle_get_conversation_outline(path)
|
|
5351
5355
|
elif path.startswith("/api/conversation/"):
|
|
5352
5356
|
self._handle_get_conversation_detail(path)
|
|
5353
5357
|
else:
|
|
@@ -7391,6 +7395,27 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
7391
7395
|
return
|
|
7392
7396
|
self._respond_json(200, payload)
|
|
7393
7397
|
|
|
7398
|
+
def _handle_get_conversation_outline(self, path: str) -> None:
|
|
7399
|
+
"""``GET /api/conversation/<sid>/outline`` — full-session skeleton +
|
|
7400
|
+
session stats (#177 S5). Same fail-closed privacy gate; unknown id → 404.
|
|
7401
|
+
"""
|
|
7402
|
+
if not self._require_transcripts_allowed():
|
|
7403
|
+
return
|
|
7404
|
+
import urllib.parse as _u
|
|
7405
|
+
session_id = _u.unquote(path[len("/api/conversation/"):-len("/outline")])
|
|
7406
|
+
if not session_id:
|
|
7407
|
+
self.send_error(404, "conversation not found")
|
|
7408
|
+
return
|
|
7409
|
+
ok, body = self._run_conversation_query(
|
|
7410
|
+
lambda conn: self._conversation_query().get_conversation_outline(conn, session_id),
|
|
7411
|
+
"/api/conversation/outline")
|
|
7412
|
+
if not ok:
|
|
7413
|
+
return
|
|
7414
|
+
if body is None:
|
|
7415
|
+
self.send_error(404, "conversation not found")
|
|
7416
|
+
return
|
|
7417
|
+
self._respond_json(200, body)
|
|
7418
|
+
|
|
7394
7419
|
_MEDIA_FETCH_SITE_ALLOWED = ("same-origin", "same-site", "none")
|
|
7395
7420
|
|
|
7396
7421
|
def _handle_get_conversation_media(self, path: str) -> None:
|
|
@@ -16,6 +16,7 @@ import json as _json
|
|
|
16
16
|
import os
|
|
17
17
|
import re
|
|
18
18
|
import sqlite3
|
|
19
|
+
from datetime import datetime as _datetime
|
|
19
20
|
|
|
20
21
|
# Public surface (Plan 2): shipped in the npm tarball + brew formula + public
|
|
21
22
|
# mirror — imported by the dashboard's conversation endpoints at runtime.
|
|
@@ -393,14 +394,15 @@ def _turn_usage_map(conn, turn_keys):
|
|
|
393
394
|
return usage
|
|
394
395
|
|
|
395
396
|
|
|
396
|
-
def
|
|
397
|
-
"""
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
+
"""
|
|
404
406
|
exists = conn.execute(
|
|
405
407
|
"SELECT 1 FROM conversation_messages WHERE session_id=? LIMIT 1",
|
|
406
408
|
(session_id,)).fetchone()
|
|
@@ -693,6 +695,26 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
|
|
|
693
695
|
for it in items:
|
|
694
696
|
it.pop("_source_tool_use_id", None)
|
|
695
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
|
+
|
|
696
718
|
# Cursor pagination over the item list (anchored to each item's canonical id).
|
|
697
719
|
# A non-None `after` that matches no item's anchor (stale/deleted cursor)
|
|
698
720
|
# yields an EMPTY page — never silently re-serves the head (M1).
|
|
@@ -743,6 +765,104 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
|
|
|
743
765
|
}
|
|
744
766
|
|
|
745
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
|
+
|
|
746
866
|
_TASK_TRIO = ("TaskCreate", "TaskUpdate", "TaskList")
|
|
747
867
|
|
|
748
868
|
|