yog/transcript/mod.rs
1//! Transcript view-model (DESIGN §5.1 #12, §11 Altitude-2 Transcript tab).
2//!
3//! An agent's conversation is a directory of message files at
4//! `<workspace>/agents/<agent-id>/messages/`. Each file is
5//! `NNN-<origin>.<ext>` — **order lives in the filename** (the `NNN`
6//! counter), and **origin lives in the filename token**:
7//!
8//! | ext | origin token | entry |
9//! |------|--------------|----------------------------------------------|
10//! | `md` | `<sender>` | delivered message, envelope stripped — bar its `epitaph:` |
11//! | `json` | `tool` | `tool_result` (content / is_error / tool_use_id) |
12//! | `json` | `<model-id>` | model output — canonical content blocks |
13//!
14//! Both `.json` origins carry the **same envelope**: a bare array of canonical
15//! blocks as legacy litany committed it, or an API-shaped object wrapping them
16//! in `content` — with the provider's token `usage` report as `content`'s
17//! sibling when one was reported (lernie ≥0.0.4).
18//! One function answers where the blocks live (`parse::block_array`)
19//! — two answers left every real `NNN-tool.json` in the Raw bucket (bl-47ec).
20//! | other / unparseable name / unparseable bytes | — | Raw bucket (never dropped) |
21//!
22//! **The directory is not append-only.** litany's compactor deletes message
23//! files and squashes the span they lived in, so a hole in the `NNN` counter
24//! is entries that were *removed* — [`compaction`] derives each one and seats
25//! a virtual [`EntryKind::Compacted`] marker in it, carrying whatever
26//! `summary/**` the compactor wrote in their place.
27//!
28//! Everything is a pure function of the on-disk bytes (§3.5 stateless
29//! re-read): no field caches a fact the files already carry. "Tool in
30//! progress" is a *query* over the entries (a committed `tool_use` with no
31//! committed `tool_result`), never a stored flag (PRINCIPLES: single source
32//! of truth).
33//!
34//! **[`build`] reads only what is committed.** The two *trailing* virtual
35//! entries — the live streaming tail and the settled-failure notice — are
36//! [`tail`]'s, folded on by the caller from what it already holds: the
37//! rendered snapshot's [`Stream`](crate::git_tree::Stream), and the §7.3
38//! [`Wound`](crate::steps_view::Wound) off a built steps view. Because the
39//! halves have different clocks (§7.2): the committed read is memoized per
40//! published snapshot, and the tail moves at frame cadence. Merging them into
41//! one build made the tail as slow as the derivation, which is the defect
42//! bl-54f7 closed.
43
44mod compaction;
45pub(crate) use compaction::seq_of;
46mod key;
47pub(crate) use key::key;
48mod parse;
49/// The committed record's disk read — its own file at §12's budget (bl-73e7).
50mod read;
51/// The two virtual **trailing** entries and the folds that seat them.
52mod tail;
53pub(crate) mod wire;
54use parse::{parse_model, parse_tool_result};
55pub use read::build;
56/// One message file classified — [`build`]'s own reading of a single entry,
57/// for the §7.3 orphaned-tail predicate (`steps_view::orphan`, bl-abba), which
58/// needs the **tail** entry alone and must not pay a whole record to get it.
59pub(crate) use read::classify;
60
61/// Directory under the workspace holding the per-agent worktrees (ARCH §2.3).
62const AGENTS_DIR: &str = "agents";
63/// The committed-transcript directory inside an agent's worktree.
64const MESSAGES_DIR: &str = "messages";
65/// The one reserved `.json` origin token: a `tool_result` payload.
66const TOOL_ORIGIN: &str = "tool";
67const MD_EXT: &str = "md";
68const JSON_EXT: &str = "json";
69/// A parsed agent transcript: the ordered `messages/` entries, plus whichever
70/// [`tail`] entry the conversation's moment has — the live streaming tail
71/// while a call is in flight, the settled-failure notice once it has stopped
72/// on a §7.3 wound.
73#[derive(Debug, Clone, PartialEq, Eq, Default)]
74pub struct Transcript {
75 pub entries: Vec<Entry>,
76}
77
78/// One transcript row. `raw` is the verbatim backing bytes surfaced by the
79/// Raw toggle for *any* entry (§11 "every tab has a Raw toggle showing
80/// verbatim bytes"); `kind` is the parsed projection.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct Entry {
83 /// Source filename (`003-claude-opus.json`), or one of [`tail`]'s
84 /// bracketed synthetic names for a virtual entry.
85 pub name: String,
86 /// Verbatim backing bytes (the file's contents; the folded text for the
87 /// streaming entry).
88 pub raw: Vec<u8>,
89 pub kind: EntryKind,
90}
91
92/// The origin classification of a transcript entry (see the module table).
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub enum EntryKind {
95 /// `.md` — a delivered message; `sender` is the filename origin token
96 /// and `body` is the deposit's content with its `---` frontmatter
97 /// envelope stripped (see [`classify`]). `epitaph` is `Some` exactly on a
98 /// **result deposit** — a child's terminal, which asserts how it ended and
99 /// may say nothing else (ARCH §2.6).
100 Delivered {
101 sender: String,
102 epitaph: Option<crate::inboxview::Epitaph>,
103 body: String,
104 },
105 /// `NNN-<model>.json` — model output as canonical content blocks, with
106 /// the provider's committed `usage` counters when the bytes carry them.
107 Model {
108 model_id: String,
109 blocks: Vec<Block>,
110 usage: Usage,
111 },
112 /// `NNN-tool.json` — a `tool_result`.
113 ToolResult {
114 tool_use_id: String,
115 content: String,
116 is_error: bool,
117 },
118 /// The live streaming tail folded from the open `response.json` — the
119 /// model's reasoning and its answer so far, held apart because they are two
120 /// things being said and each becomes its own row, exactly as the
121 /// [`Block::Thinking`]/[`Block::Text`] pair of the *committed* entry that
122 /// supersedes them will (§7.2 the thinking ruling). Either may be empty:
123 /// a model that has only thought so far, or one that answered without
124 /// reasoning, and an empty half is simply no row.
125 Streaming { thinking: String, text: String },
126 /// The conversation's **settled failure**, in the §7.3 wound vocabulary —
127 /// it stopped, nobody is driving it, and this is what happened to it last
128 /// (bl-015b). Virtual and trailing, like [`Streaming`](Self::Streaming),
129 /// and folded on by the same caller ([`Transcript::with_wound`]).
130 ///
131 /// It carries the [`Wound`](crate::steps_view::Wound) and not the sentence
132 /// built from it, for [`Compacted`](Self::Compacted)'s reason: the words a
133 /// seat paints are the wound's own projection, and a headless seat runs
134 /// that same projection over this decoded entry.
135 Wounded { wound: crate::steps_view::Wound },
136 /// A span of entries litany's compactor **deleted** — a hole in the `NNN`
137 /// counter, standing where they were. `first` and `last` are the missing
138 /// counter values, inclusive, and are the only thing this entry asserts.
139 /// `summary` is the conversation's whole compaction record, which rides
140 /// the earliest gap and is empty on every other one *and* wherever the
141 /// compactor left none — the pairing is unavailable on disk and is not
142 /// guessed ([`compaction`]). Virtual: no file backs it, exactly as none
143 /// backs [`Streaming`](Self::Streaming).
144 Compacted {
145 first: usize,
146 last: usize,
147 summary: String,
148 },
149 /// Unparseable filename or unparseable bytes — surfaced verbatim rather
150 /// than dropped (§15 Y12: "surface them in a Raw bucket").
151 Raw,
152}
153
154/// The committed `usage` record's token counters, verbatim from the bytes
155/// (lernie ≥0.0.4 seals the provider's report beside `content`:
156/// `{"content":[…],"usage":{"input_tokens":5,…}}`). Counter names are the
157/// provider's own — no vocabulary is pinned here, so a counter brazen adds
158/// rides through with no edit. Empty is the general path: a legacy bare-array
159/// entry, or a provider that reported nothing (a `0` would be a lie).
160pub type Usage = std::collections::BTreeMap<String, u64>;
161
162/// One content block of a model message (§4.4 canonical blocks).
163#[derive(Debug, Clone, PartialEq, Eq)]
164pub enum Block {
165 Text(String),
166 Thinking(String),
167 /// A tool call: rendered as a chip with id / name / input summary.
168 ToolUse {
169 id: String,
170 name: String,
171 input_summary: String,
172 },
173}
174
175impl Transcript {
176 /// Is `tool_use_id` a committed `tool_use` still in progress — i.e. no
177 /// committed `tool_result` anywhere in the sequence names it? Derived on
178 /// demand, never stored (DESIGN §5.1 #12, §11). The id is **opaque**:
179 /// `call_…` (OpenAI) and `toolu_…` (Anthropic) pair by byte equality and
180 /// no shape is assumed.
181 pub fn tool_in_progress(&self, tool_use_id: &str) -> bool {
182 !self.entries.iter().any(|e| {
183 matches!(&e.kind, EntryKind::ToolResult { tool_use_id: id, .. } if id == tool_use_id)
184 })
185 }
186}
187
188#[cfg(test)]
189mod tests;