cctally 1.53.0 → 1.54.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 +21 -0
- package/bin/_cctally_dashboard.py +78 -0
- package/bin/_cctally_db.py +9 -8
- package/bin/_lib_conversation.py +6 -3
- package/bin/_lib_conversation_export.py +325 -0
- package/bin/_lib_conversation_query.py +204 -48
- package/dashboard/static/assets/index-DXzPdFUE.js +78 -0
- package/dashboard/static/assets/{index-Bli-0vNb.css → index-Yau7oYp8.css} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-Dwu_Neot.js +0 -71
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,27 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.54.0] - 2026-06-22
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **Dashboard conversation viewer — side-by-side session comparison.** A new **⟷ Compare with…** control in the reader head puts the conversation list into a pick-mode (a banner with Cancel/Esc; the anchor session's row greyed out) to choose a second session, then opens a side-by-side **prompt-sequence diff** of the two runs — built for comparing variations of the same or a similar task to see where they diverged and which run was cheaper / faster / cleaner. A metrics-delta strip heads the view with A→B Cost, Tokens, Prompts, Errors, Duration, and Files (an improvement arrow only on the lower-is-better cost / errors / duration), the two prompt spines are aligned by their normalized first lines with a ⚡ divergence marker on replaced regions and a hatched gap where one run has an extra prompt, and clicking any row lazily expands both runs' full prompt text inline (new `GET /api/conversation/<id>/prompts` route behind the same fail-closed transcript gate) with an "open in reader →" jump per side. The view is two-column on a wide viewport and a unified single column below ~1100px, divergence is conveyed by the ⚡ bar + ◆ markers + del/add styling (never color alone), and the comparison is shareable and cold-loadable via its `#/conversations/compare/<A>/<B>` URL with ⇄ swap / ✕ close. (#217)
|
|
12
|
+
- **Dashboard conversation viewer — transcript export & per-card extraction.** The reader header gains an `Export ▾` menu with four Markdown scopes — Whole transcript, Prompts only, Chat only, and Replay recipe (a numbered list of your prompts) — each offering Copy (clipboard) and Download (`.md`); the export is computed server-side over the whole assembled session (new `GET /api/conversation/<id>/export` route behind the same fail-closed transcript gate), so it's complete even for a long transcript you've only partially paged in, and truncated tool data is marked `… [truncated]` rather than shown as complete. A diff card additionally offers a `.patch` download (a real `diff --git`/`@@` unified diff — Write yields an applyable add-patch, Edit/MultiEdit are snippet-relative best-effort), and a Bash card offers a `copy full` action (`$ cmd` + stdout + stderr, with a truncation marker when clipped). (#217)
|
|
13
|
+
- **Dashboard conversation viewer — files-touched tab + tool-type/subagent focus filters.** The outline sidebar gains an `[Outline] [Files]` tab listing every file the session modified via an Edit/MultiEdit/Write call (in first-touch order) with a summed `+N −M` badge per path, expandable to per-touch jump rows that scroll the transcript to the change; a truncated edit with no computable stat is still listed (its badge side omitted), and `NotebookEdit`/read-only tools never appear. The focus control gains a `▾ More` menu adding three filters on the same single-select axis — Edits (Edit/MultiEdit/Write turns), Bash, and a Subagent submenu that isolates one top-level subagent thread (labelled by its agent kind, key fallback when no metadata) — that filter the reading column like the four primary modes; `v` keeps cycling only the four primary modes and returns to All from a More filter. (#217)
|
|
14
|
+
- **Dashboard conversation viewer — git-context diff rendering + a task-completion summary.** An injected `Injected context` block that carries an unfenced git diff (e.g. `diff --git a/CLAUDE.md b/CLAUDE.md … @@ … @@`) now renders as a real red/green unified diff — per-file path header, a `+N −M` stat, and the same syntax-highlighted hunk rows as an Edit diff — with the surrounding prose left as Markdown; detection is conservative (a real `diff --git` marker, never a bare `-`/`+` bullet), so a Markdown list is never mistaken for a diff. And when a session's main-thread to-do checklist ends fully completed, the reader header shows a green `✓ Complete · N` chip (always visible regardless of scroll) that jumps to the final checklist, plus a `✓ Session complete (N tasks)` outline landmark — both gated on the final *main-thread* snapshot being entirely done, so a subagent's own checklist never triggers them. (#217)
|
|
15
|
+
- **Dashboard conversation viewer — in-reader cost/token analytics.** Each assistant turn's cost footer gains a thin micro-bar whose width and intensity encode that turn's cost relative to the most-expensive turn loaded so far (the exact `$`/token text is unchanged; the bar is a relative cue with the precise cost in its tooltip); the reader header carries a cumulative-cost chip showing `$so-far / $total` plus a progress bar that tracks your scroll position, prefixed `~` when earlier pages aren't loaded yet (a lower bound) and hidden both on a costless session and until you've scrolled a turn into view (so it never flashes a transient `$0.00`); and the outline's cache-rebuild stat expands from a single count into a per-rebuild jump list — each row labelled with its turn, wasted tokens, and `~$` cost, worst-first, capped at three with a "+N more" expander, honoring the `dashboard.cache_failure_markers` opt-out. (#217)
|
|
16
|
+
- **Dashboard conversation viewer — per-turn bookmarks & notes.** Each turn's action row gains a ★ bookmark toggle with an inline note editor (saved on Enter/blur, cancelled on Esc); bookmarks persist in your browser's local storage only — nothing leaves the machine — and surface as ★ landmarks in the outline (labelled with your note or the turn's heading, including bookmarks on subagent turns), with a ★ chip in the outline's "Jump to" cluster and reader keys `i` / `I` to step to the next / previous bookmark and `t` to toggle a bookmark on the current turn. (#217)
|
|
17
|
+
- **Dashboard conversation viewer — inline PDF rendering.** A PDF attachment now offers a `view inline ▾` toggle that renders the PDF in the reading column at a capped, scrollable height (with a `collapse ▴` control); the viewer mounts — and the bytes fetch — only when expanded, is served behind the same privacy gate as every other transcript media request, and falls back to the `open ↗` link automatically in a browser without a built-in PDF viewer. (#217)
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
- **Dashboard conversation search — the Files facet now matches a file-path substring instead of a path prefix.** Searching the Files facet for a bare basename (`package.json`) or a mid-path fragment (`cctally`) now returns the sessions that touched a matching path, instead of requiring a leading path prefix; the match is a case-insensitive substring over the (modest) touched-file table. (#223)
|
|
21
|
+
- **Dashboard conversation find bar — regex matches are now underlined in the transcript.** In regex find mode the in-prose highlight is no longer suppressed: matches get a best-effort inline underline (per rendered text segment, case-toggle-aware), alongside the existing match count and jump-to-match. Invalid or pathological patterns degrade to no underline (the match count still drives navigation). (#223)
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
- **Dashboard conversation viewer — the reader-header `Export ▾` and focus `▾ More` menus are now fully keyboard-navigable.** Both popovers previously exposed a `role="menu"` with no arrow-key navigation; they now implement the full menu keyboard pattern — opening moves focus to the first item, `↑`/`↓` cycle through items (wrapping), `Home`/`End` jump to the ends, and `Esc` closes and restores focus to the trigger, with a single roving tab stop so `Tab` moves past the menu rather than through every item. In the focus `▾ More` menu, `→` opens the Subagent submenu (focusing its first entry) and `←` collapses back to the parent. (#224)
|
|
25
|
+
- **Dashboard conversation viewer — git-context diff path parsing no longer over-captures a trailing word into the file path.** The `diff --git a/… b/…` path capture now stops at whitespace instead of greedily running to end-of-line, so a pathological marker line carrying trailing prose after the `b/` path can't bleed that text into the displayed new-path header; real injected diffs (whose paths are whitespace-free) are unaffected. (#224)
|
|
26
|
+
- Conversation viewer: subagents dispatched without an explicit type (the default general-purpose case, e.g. SDD implementors/reviewers) now show their kind, description, and token/duration/tool usage in the card chip and outline — previously only explicitly-typed subagents (e.g. Explore) did (#225).
|
|
27
|
+
- **Dashboard conversation viewer — jumping to a deeply-nested subagent no longer occasionally leaves that subagent's card collapsed.** When you jump to a target inside a subagent that was itself spawned by another subagent (a grandchild thread), the reader force-opens the whole ancestor chain; that expansion now latches each card's open-state in the same render that opens it instead of a beat later, so the brief reset that runs once the jump has scrolled into place can no longer race the parent's expansion and silently re-collapse the nested target. (#222)
|
|
28
|
+
|
|
8
29
|
## [1.53.0] - 2026-06-21
|
|
9
30
|
|
|
10
31
|
### Added
|
|
@@ -5483,6 +5483,15 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5483
5483
|
# Live-tail SSE for the open reader (spec §2). Matched BEFORE the
|
|
5484
5484
|
# <id> reader catch-all.
|
|
5485
5485
|
self._handle_get_conversation_events(path)
|
|
5486
|
+
elif path.startswith("/api/conversation/") and path.endswith("/export"):
|
|
5487
|
+
# #217 S5: whole-session Markdown export (F1/F5). Matched BEFORE the
|
|
5488
|
+
# <id> reader catch-all (same precedence as /outline).
|
|
5489
|
+
self._handle_get_conversation_export(path)
|
|
5490
|
+
elif path.startswith("/api/conversation/") and path.endswith("/prompts"):
|
|
5491
|
+
# #217 S7: ordered main-thread prompt spine for session comparison
|
|
5492
|
+
# (F10). Matched BEFORE the <id> reader catch-all (same precedence
|
|
5493
|
+
# as /outline).
|
|
5494
|
+
self._handle_get_conversation_prompts(path)
|
|
5486
5495
|
elif path.startswith("/api/conversation/"):
|
|
5487
5496
|
self._handle_get_conversation_detail(path)
|
|
5488
5497
|
else:
|
|
@@ -7877,6 +7886,75 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
7877
7886
|
return
|
|
7878
7887
|
self._respond_json(200, body)
|
|
7879
7888
|
|
|
7889
|
+
def _handle_get_conversation_prompts(self, path: str) -> None:
|
|
7890
|
+
"""``GET /api/conversation/<sid>/prompts`` — ordered main-thread human
|
|
7891
|
+
prompts + full text (#217 S7 F10, the session-comparison spine). Same
|
|
7892
|
+
fail-closed transcript privacy gate as ``/outline`` —
|
|
7893
|
+
``_require_transcripts_allowed()`` ONLY (no ``_check_origin_csrf``: the
|
|
7894
|
+
sibling transcript GETs gate on this predicate alone). Unknown id → 404.
|
|
7895
|
+
"""
|
|
7896
|
+
if not self._require_transcripts_allowed():
|
|
7897
|
+
return
|
|
7898
|
+
import urllib.parse as _u
|
|
7899
|
+
session_id = _u.unquote(path[len("/api/conversation/"):-len("/prompts")])
|
|
7900
|
+
if not session_id:
|
|
7901
|
+
self.send_error(404, "conversation not found")
|
|
7902
|
+
return
|
|
7903
|
+
ok, body = self._run_conversation_query(
|
|
7904
|
+
lambda conn: self._conversation_query().get_conversation_prompts(conn, session_id),
|
|
7905
|
+
"/api/conversation/prompts")
|
|
7906
|
+
if not ok:
|
|
7907
|
+
return
|
|
7908
|
+
if body is None:
|
|
7909
|
+
self.send_error(404, "conversation not found")
|
|
7910
|
+
return
|
|
7911
|
+
self._respond_json(200, body)
|
|
7912
|
+
|
|
7913
|
+
_CONV_EXPORT_SCOPES = ("all", "prompts", "chat", "recipe")
|
|
7914
|
+
|
|
7915
|
+
def _handle_get_conversation_export(self, path: str) -> None:
|
|
7916
|
+
"""``GET /api/conversation/<sid>/export?scope=<all|prompts|chat|recipe>``
|
|
7917
|
+
— whole-session Markdown (issue #217 S5 F1/F5).
|
|
7918
|
+
|
|
7919
|
+
Same fail-closed transcript privacy gate as ``/outline`` / ``/payload``
|
|
7920
|
+
/ ``/find`` — ``_require_transcripts_allowed()`` ONLY. **No
|
|
7921
|
+
``_check_origin_csrf``** (Codex P0-1): the sibling transcript GETs gate
|
|
7922
|
+
on this predicate alone; ``_check_origin_csrf`` rejects a missing
|
|
7923
|
+
``Origin`` and would make export STRICTER than its sibling reader routes.
|
|
7924
|
+
|
|
7925
|
+
``scope`` is validated HERE, BEFORE the kernel (the
|
|
7926
|
+
``_run_conversation_query``-collapses-kernel-exceptions-to-500 gotcha —
|
|
7927
|
+
an invalid scope is a clean 400, never a 500). Unknown session → 404.
|
|
7928
|
+
Emits ``text/markdown; charset=utf-8`` (the client builds the download
|
|
7929
|
+
Blob/filename, so no ``Content-Disposition`` is needed)."""
|
|
7930
|
+
if not self._require_transcripts_allowed():
|
|
7931
|
+
return
|
|
7932
|
+
import urllib.parse as _u
|
|
7933
|
+
session_id = _u.unquote(path[len("/api/conversation/"):-len("/export")])
|
|
7934
|
+
q = _u.parse_qs(self.path.partition("?")[2])
|
|
7935
|
+
scope = _qs_str(q, "scope", "all")
|
|
7936
|
+
if scope not in self._CONV_EXPORT_SCOPES:
|
|
7937
|
+
self._respond_json(400, {"error": f"unknown scope: {scope}"})
|
|
7938
|
+
return
|
|
7939
|
+
if not session_id:
|
|
7940
|
+
self.send_error(404, "conversation not found")
|
|
7941
|
+
return
|
|
7942
|
+
ok, body = self._run_conversation_query(
|
|
7943
|
+
lambda conn: self._conversation_query().get_conversation_export(
|
|
7944
|
+
conn, session_id, scope),
|
|
7945
|
+
"/api/conversation/export")
|
|
7946
|
+
if not ok:
|
|
7947
|
+
return
|
|
7948
|
+
if body is None:
|
|
7949
|
+
self.send_error(404, "conversation not found")
|
|
7950
|
+
return
|
|
7951
|
+
data = body.encode("utf-8")
|
|
7952
|
+
self.send_response(200)
|
|
7953
|
+
self.send_header("Content-Type", "text/markdown; charset=utf-8")
|
|
7954
|
+
self.send_header("Content-Length", str(len(data)))
|
|
7955
|
+
self.end_headers()
|
|
7956
|
+
self.wfile.write(data)
|
|
7957
|
+
|
|
7880
7958
|
def _handle_get_conversation_find(self, path: str) -> None:
|
|
7881
7959
|
"""``GET /api/conversation/<sid>/find?q=...&kind=...`` — in-conversation
|
|
7882
7960
|
find → document-ordered rendered-turn anchors (#177 S6). Same fail-closed
|
package/bin/_cctally_db.py
CHANGED
|
@@ -2413,14 +2413,15 @@ def _apply_cache_schema(conn: sqlite3.Connection) -> None:
|
|
|
2413
2413
|
tool TEXT NOT NULL,
|
|
2414
2414
|
UNIQUE(message_id, file_path, tool)
|
|
2415
2415
|
);
|
|
2416
|
-
-- COLLATE NOCASE
|
|
2417
|
-
--
|
|
2418
|
-
--
|
|
2419
|
-
--
|
|
2420
|
-
--
|
|
2421
|
-
--
|
|
2422
|
-
--
|
|
2423
|
-
--
|
|
2416
|
+
-- COLLATE NOCASE keeps the index's case-folding identical to the default
|
|
2417
|
+
-- (case-insensitive) ``LIKE`` (#217 S2 / I-3 review Important #1): NOCASE folds
|
|
2418
|
+
-- A-Z/a-z the same way the LIKE built-in does, so matching semantics are
|
|
2419
|
+
-- byte-identical to a BINARY index. (#223: ``_search_files`` now substring-
|
|
2420
|
+
-- matches EVERY query (``file_path LIKE '%q%'``) — a deliberate scan over the
|
|
2421
|
+
-- modest touch table — so this index no longer drives a prefix probe; it is
|
|
2422
|
+
-- retained as-is with no schema change. NOCASE was originally load-bearing so a
|
|
2423
|
+
-- ``LIKE 'prefix%'`` could ride the btree, which SQLite only does when the index
|
|
2424
|
+
-- column folds the same way.)
|
|
2424
2425
|
CREATE INDEX IF NOT EXISTS idx_file_touches_path
|
|
2425
2426
|
ON conversation_file_touches(file_path COLLATE NOCASE);
|
|
2426
2427
|
|
package/bin/_lib_conversation.py
CHANGED
|
@@ -617,9 +617,12 @@ def _attach_subagent_result(blocks, obj):
|
|
|
617
617
|
block — the unambiguous subagent-spawn result shape. Zero or >1 tool_result
|
|
618
618
|
blocks: no-op (the kernel then degrades that subagent card to title-only).
|
|
619
619
|
The kind (subagent_type) is captured separately on the spawn tool_use block;
|
|
620
|
-
the kernel
|
|
621
|
-
|
|
622
|
-
|
|
620
|
+
the kernel identifies a spawn by the tool NAME (Agent/Task), so an untyped
|
|
621
|
+
general-purpose spawn — no subagent_type recorded — still links, with the
|
|
622
|
+
kernel defaulting its kind to ``general-purpose`` (#225). The kernel joins
|
|
623
|
+
the two on tool_use_id. ``agentId`` == the subagent file's ``_subagent_key``.
|
|
624
|
+
Meta keys are normalized to snake_case here so the kernel stays a pure
|
|
625
|
+
pass-through (same posture as is_error / tool_use_id)."""
|
|
623
626
|
tur = obj.get("toolUseResult")
|
|
624
627
|
if not isinstance(tur, dict):
|
|
625
628
|
return
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""Pure, I/O-free Markdown serializer for a conversation session (issue #217 S5,
|
|
2
|
+
F1/F5). No DB / filesystem / locks — fed the assembled ``items`` (+
|
|
3
|
+
``subagent_meta``) from ``_assemble_session`` in ``_lib_conversation_query.py``,
|
|
4
|
+
so the renderer stays golden-testable (the same purity contract as
|
|
5
|
+
``_lib_share.py``). Golden-tested in ``tests/test_conversation_export.py``.
|
|
6
|
+
|
|
7
|
+
Public surface::
|
|
8
|
+
|
|
9
|
+
export_session_markdown(items, scope, *, subagent_meta=None,
|
|
10
|
+
title=None, session_id=None) -> str
|
|
11
|
+
|
|
12
|
+
``scope`` is one of ``all`` / ``chat`` / ``prompts`` / ``recipe`` (the four
|
|
13
|
+
export scopes; the handler validates the value, so an unknown scope here falls
|
|
14
|
+
back to the ``all`` renderer). ``subagent_meta`` supplies subagent labels
|
|
15
|
+
(Codex P1-2); the serializer groups subagent turns by ``subagent_key`` in
|
|
16
|
+
document order — a deliberate simplification of the reader's full nesting tree.
|
|
17
|
+
|
|
18
|
+
Item / block shape (matches ``_assemble_session``): each item is a dict with
|
|
19
|
+
``kind`` (``human``/``assistant``/``tool_result``/``meta``), ``anchor`` (with
|
|
20
|
+
``uuid``), ``ts``, ``text``, ``blocks`` (list), ``subagent_key`` (``None`` for
|
|
21
|
+
the main session), ``is_sidechain``, plus ``meta_kind`` on meta items and
|
|
22
|
+
``model`` on assistant turns. Tool blocks are ``kind == "tool_call"`` with
|
|
23
|
+
``name`` / ``input`` (bounded dict) / ``input_truncated`` / ``result``
|
|
24
|
+
(``{text, truncated, is_error, ...}`` or ``None``) / optional ``edit_stat`` /
|
|
25
|
+
optional ``stderr`` (the structured Bash stderr suffix of ``result.text``).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
# Deliberately the narrow {Edit, MultiEdit, Write} set — NOT the wider
|
|
29
|
+
# `_FILE_TOUCH_TOOLS` (which includes NotebookEdit). Edit-family tool calls
|
|
30
|
+
# render as a fenced ```diff block.
|
|
31
|
+
_EDIT_TOOLS = {"edit", "multiedit", "write"}
|
|
32
|
+
_TRUNCATED = " … [truncated]"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def export_session_markdown(items, scope, *, subagent_meta=None, title=None,
|
|
36
|
+
session_id=None):
|
|
37
|
+
"""Render a whole assembled session to Markdown for one export scope."""
|
|
38
|
+
items = list(items or [])
|
|
39
|
+
subagent_meta = subagent_meta or {}
|
|
40
|
+
if scope == "recipe":
|
|
41
|
+
return _render_recipe(items, title)
|
|
42
|
+
if scope == "prompts":
|
|
43
|
+
return _render_prompts(items, title)
|
|
44
|
+
if scope == "chat":
|
|
45
|
+
return _render_chat(items, title)
|
|
46
|
+
return _render_all(items, subagent_meta, title) # "all" (default)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---- shared predicates / helpers ------------------------------------------
|
|
50
|
+
|
|
51
|
+
def _is_main(it):
|
|
52
|
+
"""True for a main-session turn (not a subagent thread, not a sidechain)."""
|
|
53
|
+
return it.get("subagent_key") is None and not it.get("is_sidechain")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _item_text(it):
|
|
57
|
+
return (it.get("text") or "").strip()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _human_prompts(items):
|
|
61
|
+
"""Main-session human turns with non-empty prose, in document order."""
|
|
62
|
+
return [it for it in items
|
|
63
|
+
if it.get("kind") == "human" and _is_main(it) and _item_text(it)]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def extract_prompt_entries(items):
|
|
67
|
+
"""Ordered main-thread human prompts as ``[{"uuid", "text"}]``.
|
|
68
|
+
|
|
69
|
+
Single source of truth for the structured prompt spine (#217 S7) — reuses
|
|
70
|
+
the same main-thread predicate (``_human_prompts``) and the same prose
|
|
71
|
+
extraction (``_item_text``) as the recipe/prompts export, so the
|
|
72
|
+
``/prompts`` route and the export can never drift. The uuid is the turn's
|
|
73
|
+
``anchor`` uuid — the same identity ``get_conversation_outline`` emits — so
|
|
74
|
+
the spine aligns 1:1 with the outline's human turns.
|
|
75
|
+
"""
|
|
76
|
+
return [{"uuid": it["anchor"]["uuid"], "text": _item_text(it)}
|
|
77
|
+
for it in _human_prompts(items)]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _title_header(title):
|
|
81
|
+
"""An H1 title block (with a trailing blank line) or empty string."""
|
|
82
|
+
t = (title or "").strip()
|
|
83
|
+
return f"# {t}\n\n" if t else ""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _role_header(it):
|
|
87
|
+
kind = it.get("kind")
|
|
88
|
+
ts = it.get("ts") or ""
|
|
89
|
+
if kind == "human":
|
|
90
|
+
label = "## 👤 Human"
|
|
91
|
+
elif kind == "assistant":
|
|
92
|
+
label = "## 🤖 Assistant"
|
|
93
|
+
elif kind == "meta":
|
|
94
|
+
label = f"## ⚙️ {_meta_label(it)}"
|
|
95
|
+
else: # tool_result orphan / other
|
|
96
|
+
label = "## 🔧 Tool result"
|
|
97
|
+
return f"{label}{f' · {ts}' if ts else ''}"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _meta_label(it):
|
|
101
|
+
mk = (it.get("meta_kind") or "note")
|
|
102
|
+
return mk.replace("_", " ").title()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _blockquote(text):
|
|
106
|
+
"""Render ``text`` as a Markdown blockquote (one ``> `` per line)."""
|
|
107
|
+
lines = (text or "").splitlines() or [""]
|
|
108
|
+
return "\n".join(f"> {ln}" if ln else ">" for ln in lines)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ---- tool-call rendering ---------------------------------------------------
|
|
112
|
+
|
|
113
|
+
def _render_tool_call(b):
|
|
114
|
+
"""Render one ``tool_call`` block to a Markdown chunk."""
|
|
115
|
+
name = b.get("name") or "Tool"
|
|
116
|
+
nm = name.lower()
|
|
117
|
+
inp = b.get("input") if isinstance(b.get("input"), dict) else {}
|
|
118
|
+
if nm in _EDIT_TOOLS:
|
|
119
|
+
return _render_edit_call(name, inp, b)
|
|
120
|
+
if nm == "bash":
|
|
121
|
+
return _render_bash_call(inp, b)
|
|
122
|
+
return _render_generic_call(name, b)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _edit_diff_lines(name, inp):
|
|
126
|
+
"""Reconstruct +/- diff lines for an Edit/MultiEdit/Write call from the
|
|
127
|
+
bounded input (best-effort — bounded leaves may be clipped)."""
|
|
128
|
+
nm = (name or "").lower()
|
|
129
|
+
lines = []
|
|
130
|
+
if nm == "write":
|
|
131
|
+
content = inp.get("content")
|
|
132
|
+
if isinstance(content, str):
|
|
133
|
+
for ln in content.splitlines():
|
|
134
|
+
lines.append("+" + ln)
|
|
135
|
+
return lines
|
|
136
|
+
if nm == "multiedit":
|
|
137
|
+
edits = inp.get("edits")
|
|
138
|
+
if isinstance(edits, list):
|
|
139
|
+
for e in edits:
|
|
140
|
+
e = e if isinstance(e, dict) else {}
|
|
141
|
+
lines.extend(_one_edit_lines(e.get("old_string"),
|
|
142
|
+
e.get("new_string")))
|
|
143
|
+
return lines
|
|
144
|
+
# edit
|
|
145
|
+
return _one_edit_lines(inp.get("old_string"), inp.get("new_string"))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _one_edit_lines(old, new):
|
|
149
|
+
out = []
|
|
150
|
+
if isinstance(old, str):
|
|
151
|
+
for ln in old.splitlines():
|
|
152
|
+
out.append("-" + ln)
|
|
153
|
+
if isinstance(new, str):
|
|
154
|
+
for ln in new.splitlines():
|
|
155
|
+
out.append("+" + ln)
|
|
156
|
+
return out
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _render_edit_call(name, inp, b):
|
|
160
|
+
path = inp.get("file_path")
|
|
161
|
+
path = path if isinstance(path, str) and path else "(file)"
|
|
162
|
+
diff = "\n".join(_edit_diff_lines(name, inp))
|
|
163
|
+
trunc = _TRUNCATED if b.get("input_truncated") else ""
|
|
164
|
+
head = f"**{name}** `{path}`{trunc}"
|
|
165
|
+
return f"{head}\n\n```diff\n{diff}\n```"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _render_bash_call(inp, b):
|
|
169
|
+
command = inp.get("command")
|
|
170
|
+
command = command if isinstance(command, str) else ""
|
|
171
|
+
res = b.get("result") if isinstance(b.get("result"), dict) else None
|
|
172
|
+
out_lines = [f"$ {command}"]
|
|
173
|
+
if res is not None:
|
|
174
|
+
text = res.get("text") or ""
|
|
175
|
+
stderr = b.get("stderr")
|
|
176
|
+
stdout = text
|
|
177
|
+
# result.text == stdout + stderr (the empirical Bash storage shape); when
|
|
178
|
+
# the structured stderr suffix is present, split it out for clarity.
|
|
179
|
+
if isinstance(stderr, str) and stderr and text.endswith(stderr):
|
|
180
|
+
stdout = text[: len(text) - len(stderr)]
|
|
181
|
+
if stdout:
|
|
182
|
+
out_lines.append(stdout.rstrip("\n"))
|
|
183
|
+
if isinstance(stderr, str) and stderr:
|
|
184
|
+
out_lines.append("# stderr")
|
|
185
|
+
out_lines.append(stderr.rstrip("\n"))
|
|
186
|
+
if res.get("truncated"):
|
|
187
|
+
out_lines.append(_TRUNCATED.strip())
|
|
188
|
+
body = "\n".join(out_lines)
|
|
189
|
+
return f"```bash\n{body}\n```"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _render_generic_call(name, b):
|
|
193
|
+
parts = [f"**Tool: {name}**"]
|
|
194
|
+
summary = b.get("input_summary")
|
|
195
|
+
if isinstance(summary, str) and summary.strip() and summary.strip() != "{}":
|
|
196
|
+
parts.append(f"`{summary.strip()}`")
|
|
197
|
+
res = b.get("result") if isinstance(b.get("result"), dict) else None
|
|
198
|
+
if res is not None:
|
|
199
|
+
text = (res.get("text") or "").rstrip("\n")
|
|
200
|
+
trunc = _TRUNCATED if res.get("truncated") else ""
|
|
201
|
+
if text:
|
|
202
|
+
parts.append(f"```\n{text}\n```{trunc}" if not trunc
|
|
203
|
+
else f"```\n{text}\n```\n{trunc.strip()}")
|
|
204
|
+
elif trunc:
|
|
205
|
+
parts.append(trunc.strip())
|
|
206
|
+
return "\n\n".join(parts)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# ---- per-turn rendering (the `all` matrix) ---------------------------------
|
|
210
|
+
|
|
211
|
+
def _render_turn_body(it):
|
|
212
|
+
"""The body chunks of a single turn (prose, thinking, tool calls, meta)."""
|
|
213
|
+
chunks = []
|
|
214
|
+
kind = it.get("kind")
|
|
215
|
+
prose = _item_text(it)
|
|
216
|
+
|
|
217
|
+
if kind == "meta":
|
|
218
|
+
# A meta turn renders its body as a labeled blockquote.
|
|
219
|
+
if prose:
|
|
220
|
+
chunks.append(_blockquote(prose))
|
|
221
|
+
return chunks
|
|
222
|
+
|
|
223
|
+
if prose:
|
|
224
|
+
chunks.append(prose)
|
|
225
|
+
|
|
226
|
+
for b in it.get("blocks", []):
|
|
227
|
+
bk = b.get("kind")
|
|
228
|
+
if bk == "thinking":
|
|
229
|
+
tx = (b.get("text") or "").strip()
|
|
230
|
+
if tx:
|
|
231
|
+
chunks.append("> 💭 Thinking\n>\n" + _blockquote(tx))
|
|
232
|
+
elif bk in ("tool_call", "tool_use"):
|
|
233
|
+
chunks.append(_render_tool_call(b))
|
|
234
|
+
elif bk == "tool_result":
|
|
235
|
+
# orphan tool_result block (rare — a result the kernel could not fold)
|
|
236
|
+
tx = (b.get("text") or "").rstrip("\n")
|
|
237
|
+
trunc = _TRUNCATED.strip() if b.get("truncated") else ""
|
|
238
|
+
if tx:
|
|
239
|
+
chunks.append(f"**Tool result**\n\n```\n{tx}\n```"
|
|
240
|
+
+ (f"\n{trunc}" if trunc else ""))
|
|
241
|
+
return chunks
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _render_all(items, subagent_meta, title):
|
|
245
|
+
"""Full-fidelity, document-order render. Main-session turns render inline;
|
|
246
|
+
consecutive subagent turns (same subagent_key) group under one heading."""
|
|
247
|
+
parts = [_title_header(title)] if _title_header(title) else []
|
|
248
|
+
cur_subagent = None # the subagent_key whose heading is currently open
|
|
249
|
+
|
|
250
|
+
for it in items:
|
|
251
|
+
sk = it.get("subagent_key")
|
|
252
|
+
if sk is not None or it.get("is_sidechain"):
|
|
253
|
+
# Subagent turn — open a grouping heading on a new key.
|
|
254
|
+
if sk != cur_subagent:
|
|
255
|
+
label = _subagent_label(sk, subagent_meta)
|
|
256
|
+
parts.append(f"### ⎇ Subagent: {label}")
|
|
257
|
+
cur_subagent = sk
|
|
258
|
+
else:
|
|
259
|
+
cur_subagent = None # back to the main thread
|
|
260
|
+
|
|
261
|
+
body = _render_turn_body(it)
|
|
262
|
+
block = [_role_header(it)]
|
|
263
|
+
block.extend(body)
|
|
264
|
+
parts.append("\n\n".join(p for p in block if p))
|
|
265
|
+
|
|
266
|
+
return "\n\n".join(p for p in parts if p).rstrip() + "\n"
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _subagent_label(sk, subagent_meta):
|
|
270
|
+
meta = subagent_meta.get(sk) if isinstance(subagent_meta, dict) else None
|
|
271
|
+
if isinstance(meta, dict):
|
|
272
|
+
kind = meta.get("kind") or meta.get("subagent_type")
|
|
273
|
+
if isinstance(kind, str) and kind.strip():
|
|
274
|
+
return kind.strip()
|
|
275
|
+
return sk if isinstance(sk, str) and sk else "subagent"
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# ---- the `chat` scope (prose-only, main-session) --------------------------
|
|
279
|
+
|
|
280
|
+
def _render_chat(items, title):
|
|
281
|
+
"""Human + assistant prose only — no thinking, no tools, no meta;
|
|
282
|
+
main-session only. Deliberately leaner than the live 'chat' focus mode
|
|
283
|
+
(which retains thinking) — an intentional divergence (spec §1)."""
|
|
284
|
+
parts = [_title_header(title)] if _title_header(title) else []
|
|
285
|
+
for it in items:
|
|
286
|
+
if it.get("kind") not in ("human", "assistant"):
|
|
287
|
+
continue
|
|
288
|
+
if not _is_main(it):
|
|
289
|
+
continue
|
|
290
|
+
prose = _item_text(it)
|
|
291
|
+
if not prose:
|
|
292
|
+
continue
|
|
293
|
+
parts.append(f"{_role_header(it)}\n\n{prose}")
|
|
294
|
+
return "\n\n".join(p for p in parts if p).rstrip() + "\n"
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# ---- the `prompts` scope --------------------------------------------------
|
|
298
|
+
|
|
299
|
+
def _render_prompts(items, title):
|
|
300
|
+
"""Main-session human turns only, each as ``## Prompt N`` + full text."""
|
|
301
|
+
parts = [_title_header(title)] if _title_header(title) else []
|
|
302
|
+
for n, it in enumerate(_human_prompts(items), start=1):
|
|
303
|
+
parts.append(f"## Prompt {n}\n\n{_item_text(it)}")
|
|
304
|
+
return "\n\n".join(p for p in parts if p).rstrip() + "\n"
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# ---- the `recipe` scope (F5) ----------------------------------------------
|
|
308
|
+
|
|
309
|
+
def _render_recipe(items, title):
|
|
310
|
+
"""A ``# Replay recipe`` header + a numbered list of main-session prompts —
|
|
311
|
+
the re-runnable script form."""
|
|
312
|
+
prompts = _human_prompts(items)
|
|
313
|
+
out = ["# Replay recipe"]
|
|
314
|
+
if title and title.strip():
|
|
315
|
+
out.append(f"_{title.strip()}_")
|
|
316
|
+
if prompts:
|
|
317
|
+
numbered = []
|
|
318
|
+
for n, it in enumerate(prompts, start=1):
|
|
319
|
+
# Collapse internal newlines so each prompt is one numbered step.
|
|
320
|
+
text = " ".join(_item_text(it).split())
|
|
321
|
+
numbered.append(f"{n}. {text}")
|
|
322
|
+
out.append("\n".join(numbered))
|
|
323
|
+
else:
|
|
324
|
+
out.append("_(no prompts)_")
|
|
325
|
+
return "\n\n".join(out).rstrip() + "\n"
|