cctally 1.53.0 → 1.55.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 +55 -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-Cnjzrt1O.css +1 -0
- package/dashboard/static/assets/index-CwWzvgaI.js +78 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-Bli-0vNb.css +0 -1
- package/dashboard/static/assets/index-Dwu_Neot.js +0 -71
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,61 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.55.0] - 2026-06-25
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Conversation viewer: agent/subagent threads now keep a sticky orientation header (and a tinted body wash) so you always know which agent you're reading at any scroll depth, with the pinned header doubling as an always-reachable collapse control; a discoverable expand-all/collapse-all control surfaces the existing `]`/`[` shortcuts. (#228 S2)
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
- Conversation viewer discovery facelift: the cross-session search input now occupies a full-width row with a leading magnifier and a non-truncating placeholder, with Filters and Sort moved to a second controls row beneath it; browse rows collapse to a single scannable line with a compact model chip and a right-aligned cost/message-count cluster, and the per-row project label is hidden when every loaded conversation shares one project; the open conversation is now highlighted with a left accent bar in the browse list AND in search results; a zero-result search shows a richer empty state that echoes the query with a "Search all conversations" escape (when a narrow facet is active) and a "Clear search" action; in a single-kind search facet the redundant match-kind badge that merely echoes the facet is suppressed and the mostly-$0.00 per-hit cost is dropped from search rows, while match-kind badges now read as filled chips; and the in-conversation find bar gains an always-visible `regex`/`case` mode tag plus a stronger pressed-toggle state, reflowing on phone widths to a full-width input with the toggles/counter/nav wrapped beneath (#228 S4).
|
|
15
|
+
- Conversation comparison view: metric regressions now show a red ▲ (not neutral grey), so a costlier/slower/error-ier run is as visible as an improvement; A/B identity is anchored with a blue/purple accent across the header, a sticky column header, the diff, and the expand panel; diff rows are marked with −/+/= (colour is no longer the only cue); a legend keys the encoding; "Compare with…" is surfaced in the reader header; the pick banner names the session; and sub-hour durations drop the "0h" prefix (#228 S5).
|
|
16
|
+
- Conversation viewer: agents spawned from a main turn now sit on the main thread spine with the magenta branch-dot and a stronger card instead of being indented like nested agents; only true agent-in-agent nesting indents (progressively, at any depth). A `↳ launched <kind> agent` connector now marks where each subagent was launched. (#228 S2)
|
|
17
|
+
- Conversation viewer: expanded *thinking* blocks now get a distinct "quiet reasoning" treatment — an indigo left border and a faint indigo wash on the body, with the prose dimmed, italicized, and dropped one size — so reasoning no longer reads like the assistant's actual answer; embedded inline code and fenced code stay upright and monospace, and no other block is restyled. (#228 S3)
|
|
18
|
+
- Conversation viewer: the per-turn cost footer is de-noised — the always-on cost micro-bar stays on every assistant turn, but the verbose `$… · in … · out … · cache …` text now renders only for turns at or above a small absolute cost floor ($0.05); for cheaper turns the exact `$` figure and full token breakdown move into the footer's hover/long-press tooltip, so the cost stays reachable while the reading column stays quiet. (#228 S3)
|
|
19
|
+
- Conversation viewer: on phone-width screens (≤640px) the reader header now collapses to two slim rows — Row 1 is `← Back · title · ⋯`, Row 2 is a compact `Focus: All ▾` dropdown plus Find and Outline — so reading starts in the top of the screen instead of below a tall toolbar; the secondary actions (Export, Compare with…, Latest ↓, Expand-all, Collapse-all) and the completion/cumulative-cost summaries fold into the `⋯` overflow menu, and the four-button focus segment becomes the single compact dropdown that also absorbs the Edits/Bash/per-subagent focus filters. The desktop and tablet header is unchanged. (#228 S3)
|
|
20
|
+
- **Dashboard conversation viewer — consistent keyboard-focus rings and a spacing rhythm applied viewer-wide.** Every interactive element in the conversation viewer now shows the same blue keyboard-focus ring — replacing the previous mix of border-colour, outline, and inset-shadow styles — including rows inside scrolling lists (where the ring is drawn inset so it no longer clips against the list edge), the full-width comparison rows, and the `Export ▾` button (which previously showed no ring at all); and the viewer's hard-coded padding/margin/gap values are put onto a shared 8px spacing scale, snapping a handful of off-grid values (mostly by ~1px) so spacing reads more evenly, while deliberately tight spacing and nesting indents are left untouched. No behavioural change beyond the visual normalization. (#229)
|
|
21
|
+
- **Dashboard conversation viewer — the message reader now virtualizes its list, so opening and scrolling very long conversations stays fast.** Only the message cards in or near the viewport are mounted (and recycled as you scroll) instead of rendering every loaded card at once, cutting cold-mount cost from "the whole loaded window" to "just what's on screen" — a cold deep-link or tail-open of a 1,000+-turn session is interactive within a beat and stays smooth as you scroll the full transcript. Reverse-paging, find/outline/deep-link jumps, sidechain expand/collapse, live-tail stick-to-bottom and the "↓ N new" pill, keyboard `j`/`k` navigation and the bulk `[`/`]` expand/collapse-all, and screen-reader traversal all keep working unchanged. (#232)
|
|
22
|
+
- **Dashboard conversation viewer — in-conversation find now scrolls the matched word into view and highlights matches inside code and tool panels.** A find hit on a long turn used to center the whole turn, leaving the matched word above or below the fold so you had to hunt for it; the reader now scrolls the first highlighted match within the turn (skipping any match buried inside an inner scroll/clamp container) into the center, so you land on the word itself. Find matches are also now highlighted inside fenced code blocks (registered and unknown languages) and the tool panels that previously showed no highlight at all — Bash commands, Read results, JSON request bodies, generic tool results, web-search fallbacks, and both context and add/del lines of a diff — so a match in code or tool I/O reads as clearly as a prose match. Prose highlighting, inline-code (still unhighlighted), and all non-find navigation are unchanged. (#236)
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
- **Dashboard conversation viewer — pressing Esc in the conversation search box no longer kicks you out of the viewer.** While the rail's cross-session search input was focused, Esc used to both clear the box AND fall through to the workspace's global Esc, ejecting you back to the dashboard; it now just clears and unfocuses the box and stays in the conversation workspace (Esc with nothing focused still clears a present needle, else exits — unchanged). Esc with the filters popover open now closes the popover instead of doing nothing. (#228 S4)
|
|
26
|
+
- **Dashboard conversation viewer — a find hit inside an auto-expanded thinking/tool block now lands the matched word dead-center on tall turns.** When a find match was buried in a collapsed disclosure, the reader would open the disclosure and center the word, but the just-opened block settles to a slightly shorter height over a fraction of a second afterward — and with the scroll already committed, the matched word drifted upward (up to a few hundred pixels above center on very tall turns) so you had to glance up to find it. The reader now keeps the word pinned at center as the block settles, so it lands centered; pure-text matches (which never had the drift) and every other jump are unchanged. (#237)
|
|
27
|
+
- **Dashboard conversation viewer — a cold far jump now lands on its target on the first click, and a find hit deep inside a collapsed subagent card is brought into view with its highlight.** This resolves the two precision residuals left open after #233. (1) Clicking a far outline/search entry right after opening a long transcript — where the target has to be paged in first — used to scroll into the region but settle short with the target un-mounted, needing a second click, because a single estimate-based scroll over hundreds of not-yet-measured rows can't land deterministically. The reader now first *walks* the list toward the target so those rows measure, then writes the scroller's scroll position directly to the now-known offset, so it lands on the first click. (2) A find match on a turn nested deep inside a collapsed subagent card used to recenter the card itself with no visible highlight; the matched turn now force-opens its enclosing card, reveals the matched turn's `<mark>` highlight, and centers the turn. The warm far-jump precision from #233, the cold deep-link restore, reverse/forward paging, `j`/`k`, jump-to-latest, the "↓ N new" pill, and top-level find matches are all unchanged. (#234)
|
|
28
|
+
- **Dashboard conversation viewer — jumping to an already-loaded far-off turn now lands precisely on it.** After the reader was virtualized, clicking an outline/search entry (or jump-to-next) for a turn far from the current view but already within the loaded transcript would scroll into the right area yet settle a row or two short of the turn, because the smooth scroll landed on react-virtuoso's *estimated* offset for the not-yet-measured rows in between and only re-corrected loosely. The jump now uses the library's convergent scroll primitive — an instant `scrollIntoView` driven to completion as those rows are measured, with the within-row centering deferred until it settles — so the target turn lands where the flash highlights it; the brief target highlight, the cold deep-link, and reverse/forward paging are unchanged. (A far jump whose target has not been paged in yet can still land short on the first click — a second jump lands it — and is tracked as a follow-up.) (#233)
|
|
29
|
+
- **Dashboard conversation viewer — message cards are no longer clipped on the right after the reader was virtualized.** Switching the virtualized list to plain block layout exposed a right-edge clip at every width (worst on phones): the cost footer's trailing `cache …k`, the assistant header meta, and the cards' right gutter were shaved off, because the reader's reading inset lived on the scroll container while react-virtuoso's absolutely-positioned content wrapper sized to the scroller's padding box — overshooting the visible area by one inset-width and pushing that strip under the horizontal-overflow clip. The horizontal reading inset now lives on the inner list (with the timeline spine re-aligned so it doesn't move), so the list spans the true content width, the right gutter is restored, and full-bleed rows are fully visible — with the virtualized scroll size model, reverse-/forward-paging, and every jump unchanged. (#232)
|
|
30
|
+
- **Dashboard conversation viewer — clicking an outline entry (or any jump) in an already-open long conversation now scrolls to that turn again.** After the reader was virtualized, a warm in-session jump — clicking an outline/search/landmark entry, or jump-to-next — could leave the reader where it was instead of scrolling to and flashing the target, with far jumps landing nowhere near the right place; only a cold deep-link landed (and only because its target happened to be on screen). The jump fed react-virtuoso's scroll the reader's internal virtual row index instead of the plain list position the library expects, so the scroll request fell out of range and was ignored. Every reader scroll-to-row — jump landing, the keyboard `j`/`k`/`g` moves, the click-to-collapse re-pin, the open-position scroll, and the "↓ N new" pill — now passes the list position, so warm jumps scroll to their target again. The cold deep-link freeze fix and reverse-/forward-paging are unchanged. (#232)
|
|
31
|
+
- **Dashboard conversation viewer — scrolling and every jump in a long conversation now actually move the viewport.** A deeper cause of the same symptom: the reader's list wrapper was dropping the styles react-virtuoso sets on it, including the padding that reserves the off-screen scroll space, so the scrollbar only spanned the handful of message cards mounted on screen. With no room to scroll into, the list was stuck — you couldn't scroll past the initial window, and jump-to-latest, outline/find jumps and keyboard `j`/`k` had nowhere to land (a 700-turn session's scrollbar was frozen at roughly five cards' height). The wrapper now keeps those styles and uses plain block layout (which react-virtuoso requires), so the full scroll height is restored and scrolling, jumps, find-step, jump-to-latest and reverse-paging all reach any part of the transcript. (#232)
|
|
32
|
+
- **Dashboard conversation viewer — a cold deep link into the middle of a very long transcript no longer hangs the tab.** After the reader was virtualized, deep-linking straight to a mid-stream turn of a session large enough to exceed the reader's mounted-window cap could pin the browser tab at 100% CPU indefinitely (it never recovered). The jump-to-target pager re-fires every time it pages the window, and on a cold open several of those re-fires started rival backward drains at once; the rivals raced on the reader's single reverse-fetch overlap flag and one of them fell into a tight wait loop that starved the page-fetch it was waiting on, so neither could finish. The pager now keeps a single drain in flight (a re-fire awaits the running one instead of starting a rival) and its overlap waits yield to the event loop, so a cold deep-link becomes interactive within a beat; genuine reverse- and forward-paging, which the virtualization also temporarily defers until the open has settled on screen, are unchanged. Reader items also now carry their `aria-posinset`/`aria-setsize` so a screen reader can announce position within the virtualized feed. (#232)
|
|
33
|
+
- **Dashboard conversation viewer — deep-linking into the middle of a very long transcript no longer freezes the reader.** Opening a permalink straight to a mid-stream turn of a long session — one big enough to exceed the reader's mounted-window cap — used to lock up (and sometimes crash) the tab on a cold load for over a minute. As the reader paged back from the tail toward the target turn, it re-rendered every turn already on screen on each page it pulled in, so the work grew with the square of how much had loaded and pinned the main thread. Paging now reuses the turns it has already rendered instead of rebuilding them, the mounted window stays bounded as it trims the far off-screen edge, and each page paints before the next is fetched — so a cold deep-link becomes interactive within a beat and streams the rest in, the same way a normal open already loads. A warm jump (the conversation already open) and a normal tail open are unchanged. (#231)
|
|
34
|
+
- **Dashboard conversation viewer — tool/diff card headers no longer clip their status badges off-screen on phone widths.** In a tool or Edit/Write card's collapsed header, a long tool name (e.g. `browser_console_messages`) or a long edited-file basename used to push the trailing badges — the `· truncated`/`· error` status, the MCP-server pill, and an Edit card's `+N −M` stat — past the card's right edge, where the reader's horizontal-overflow backstop clipped them out of sight at narrow widths; the name/basename now ellipsizes so those badges stay visible and the header stays one row, with no change at widths where the full name already fits (the diff body's own horizontal scroller is unaffected). (#230)
|
|
35
|
+
- **Dashboard conversation viewer — a very long transcript stays responsive while you reverse-page through it.** The reader now bounds how much of the transcript it keeps mounted at once: as you scroll back through a long session it drops the far, off-screen edge of the loaded window once it grows past a soft cap, and re-fetches it transparently if you scroll back toward it — so the DOM no longer grows without bound (an expand-all on a huge session can't balloon it either). The turn you're reading, your jump/find target, and the current/pinned turn are never dropped, the bound never kicks in while a page is still loading, and live-tail stick-to-bottom plus the "↓ N new" pill keep working unchanged. (#228 S3)
|
|
36
|
+
- **Dashboard conversation viewer — a bare/singular conversation deep link now opens its conversation.** A permalink written in the singular `#/conversation/<id>` (or `#/conversation/<id>/<turn>`) form — the shape the issue literally produces — now opens the reader exactly like the canonical plural `#/conversations/<id>` route, instead of landing on the dashboard; the singular form is accepted as a read-only alias (links are still emitted in the canonical plural form). (#228 S3)
|
|
37
|
+
- **Dashboard conversation viewer — a long line of code no longer drags the reading column sideways on mobile.** Line-numbered code (Read-result output) is now caged as its own horizontal scroller with its line-number gutter pinned, and the reading column is contained end-to-end so an unwrappable long line scrolls inside its own box instead of shearing the whole transcript sideways at narrow widths; the reading column also gets a horizontal-overflow backstop at every width so a slightly-diagonal swipe stays vertical. Wrapped code blocks are unaffected. (#228 S3)
|
|
38
|
+
- **Dashboard conversation viewer — the `☰ Outline` button now works on tablet-width screens.** In the 641–1100px band the outline button was dead (the persistent outline column is hidden at those widths, but the button still tried to toggle it); it now opens the same slide-over outline sheet that mobile uses, with its open state reflected on the button, so the whole no-column band gets a working outline. Resizing from that band up to a wide (≥1101px) viewport closes the sheet so it can't reappear on a later return to tablet width, and the persistent outline column still shows only at ≥1101px — the 640px (single-pane) and 1100px (column-vs-sheet) breakpoints stay distinct. (#228 S3)
|
|
39
|
+
- **Dashboard conversation viewer — the session-comparison header now shows each run's real title.** The ⟷ Compare view's Run A / Run B column headers now show the same derived conversation title the rail shows for each session (sourced from the loaded browse list), falling back to the short `Session <id>` label only when that title hasn't been loaded yet — previously they always rendered the short id. The comparison also stops re-fetching both sessions' outlines on every dashboard refresh: it loads them once as a static snapshot of the two finished runs, trimming redundant `/api/conversation/<id>/outline` requests (e.g. while a compared session is still live elsewhere). (#227)
|
|
40
|
+
- **Dashboard conversation viewer — a shared design-token foundation plus accessibility fixes.** The viewer gains a shared design-token layer (a type scale, semantic-intent and diff colours, a distinct A/B comparison palette, a spacing rhythm, and a shared focus ring) with all viewer text lifted to a ≥11px floor so nothing renders too small to read; the outline resize divider now takes keyboard focus on click (so a mouse user can immediately arrow-resize), closing a comparison returns focus to the **⟷ Compare with…** trigger instead of dropping it, and live-tail arrivals plus the cross-session search error / "Searching…" states are now announced to screen readers. (#228 S1)
|
|
41
|
+
|
|
42
|
+
## [1.54.0] - 2026-06-22
|
|
43
|
+
|
|
44
|
+
### Added
|
|
45
|
+
- **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)
|
|
46
|
+
- **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)
|
|
47
|
+
- **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)
|
|
48
|
+
- **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)
|
|
49
|
+
- **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)
|
|
50
|
+
- **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)
|
|
51
|
+
- **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)
|
|
52
|
+
|
|
53
|
+
### Changed
|
|
54
|
+
- **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)
|
|
55
|
+
- **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)
|
|
56
|
+
|
|
57
|
+
### Fixed
|
|
58
|
+
- **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)
|
|
59
|
+
- **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)
|
|
60
|
+
- 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).
|
|
61
|
+
- **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)
|
|
62
|
+
|
|
8
63
|
## [1.53.0] - 2026-06-21
|
|
9
64
|
|
|
10
65
|
### 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"
|