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 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 get_conversation(conn, session_id, *, after=None, limit=500):
397
- """Reader payload for one session (spec §3.2). Returns None for an unknown
398
- session. Dedups logical messages by (session_id, uuid) (canonical = earliest
399
- timestamp), groups assistant fragments into turn items by (msg_id, req_id),
400
- joins cost once, anchors a turn on its prose-bearing fragment, and exposes
401
- every member fragment uuid for jump resolution. Cursor over (timestamp_utc,
402
- id); ~500 items/page."""
403
- limit = max(1, min(int(limit), 1000))
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