Skip to main content

hotl_types/
lib.rs

1//! L1 — canonical conversation types.
2//!
3//! Pure data + serde. No tokio, no I/O. Forward-compat serde is policy:
4//! `#[serde(other)] Unknown` on persisted enums, `format_version` in the
5//! session header, optional fields default + skip-when-none.
6//!
7//! Assistant content is kept as **verbatim provider blocks** (`serde_json::Value`)
8//! rather than re-typed structs: signed thinking blocks must echo back to the
9//! provider byte-faithfully or replay breaks (review A11), and unknown future
10//! block types survive a round-trip losslessly. Typed *views* are provided for
11//! the engine (`assistant_text`, `assistant_tool_uses`).
12
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15
16/// Bumped only on breaking changes to the persisted entry format.
17pub const FORMAT_VERSION: u32 = 1;
18
19/// Structural provenance on every injected user item (grok 04):
20/// no consumer ever parses message text to learn where it came from.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum SyntheticReason {
24    ProjectInstructions,
25    SystemReminder,
26    Steer,
27    CompactionSummary,
28    SubagentResult,
29    DoomLoopNudge,
30    RetryFeedback,
31    Moim,
32    Memory,
33    SubdirInstructions,
34    #[serde(other)]
35    Unknown,
36}
37
38/// One conversation item. Internally tagged so `#[serde(other)]` can absorb
39/// item kinds this binary doesn't know yet (payload dropped, no crash).
40#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
41#[serde(tag = "type", rename_all = "snake_case")]
42pub enum Item {
43    System {
44        text: String,
45    },
46    User {
47        text: String,
48        #[serde(default, skip_serializing_if = "Option::is_none")]
49        synthetic: Option<SyntheticReason>,
50    },
51    /// Verbatim provider content blocks (text / tool_use / thinking / ...).
52    Assistant {
53        blocks: Vec<Value>,
54    },
55    /// All results for one assistant turn's tool calls, in source order
56    /// (the API requires them in a single user message).
57    ToolResults {
58        results: Vec<ToolResultItem>,
59    },
60    #[serde(other)]
61    Unknown,
62}
63
64#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
65pub struct ToolResultItem {
66    pub tool_use_id: String,
67    pub content: String,
68    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
69    pub is_error: bool,
70}
71
72/// A tool invocation extracted from assistant blocks.
73#[derive(Debug, Clone, PartialEq)]
74pub struct ToolUse {
75    pub id: String,
76    pub name: String,
77    pub input: Value,
78}
79
80/// Concatenated text of the assistant's text blocks.
81pub fn assistant_text(blocks: &[Value]) -> String {
82    blocks
83        .iter()
84        .filter(|b| b.get("type").and_then(Value::as_str) == Some("text"))
85        .filter_map(|b| b.get("text").and_then(Value::as_str))
86        .collect::<Vec<_>>()
87        .join("")
88}
89
90/// Tool-use blocks in source order.
91pub fn assistant_tool_uses(blocks: &[Value]) -> Vec<ToolUse> {
92    blocks
93        .iter()
94        .filter(|b| b.get("type").and_then(Value::as_str) == Some("tool_use"))
95        .filter_map(|b| {
96            Some(ToolUse {
97                id: b.get("id")?.as_str()?.to_string(),
98                name: b.get("name")?.as_str()?.to_string(),
99                input: b.get("input").cloned().unwrap_or(Value::Null),
100            })
101        })
102        .collect()
103}
104
105/// Why a sample stopped. `Other` absorbs stop reasons newer than this binary.
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(rename_all = "snake_case")]
108pub enum StopReason {
109    EndTurn,
110    MaxTokens,
111    ToolUse,
112    StopSequence,
113    PauseTurn,
114    Refusal,
115    #[serde(other)]
116    Other,
117}
118
119/// Normalized usage; fields absent from a provider response default to zero.
120#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
121pub struct TokenUsage {
122    #[serde(default)]
123    pub input_tokens: u64,
124    #[serde(default)]
125    pub output_tokens: u64,
126    #[serde(default)]
127    pub cache_read_input_tokens: u64,
128    #[serde(default)]
129    pub cache_creation_input_tokens: u64,
130}
131
132impl std::ops::AddAssign for TokenUsage {
133    fn add_assign(&mut self, rhs: Self) {
134        self.input_tokens += rhs.input_tokens;
135        self.output_tokens += rhs.output_tokens;
136        self.cache_read_input_tokens += rhs.cache_read_input_tokens;
137        self.cache_creation_input_tokens += rhs.cache_creation_input_tokens;
138    }
139}
140
141#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
142pub struct SessionHeader {
143    pub format_version: u32,
144    pub session_id: String,
145    /// Reserved for fork/resume (M3); always serialized so old logs stay readable.
146    pub parent_session_id: Option<String>,
147    pub model: String,
148    pub created_at_ms: u64,
149}
150
151/// One appended log record. `parent_id` forms a chain (a tree from M3);
152/// M0 logs are strictly linear.
153#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
154pub struct Entry {
155    pub id: String,
156    pub parent_id: Option<String>,
157    pub ts_ms: u64,
158    pub payload: EntryPayload,
159}
160
161#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
162#[serde(tag = "kind", rename_all = "snake_case")]
163pub enum EntryPayload {
164    Header {
165        header: SessionHeader,
166    },
167    Item {
168        item: Item,
169    },
170    Usage {
171        usage: TokenUsage,
172    },
173    Cancelled {
174        reason: String,
175    },
176    /// Compaction re-points the projection: history before `kept_from` is
177    /// replaced by `digest` items; the log itself keeps everything.
178    Compaction {
179        digest: Vec<Item>,
180        /// Leading items of the pre-compaction projection preserved verbatim.
181        prefix_end: usize,
182        /// Index into the pre-compaction projection where the verbatim tail
183        /// starts. Both indices are relative to the projection *at compaction
184        /// time*; replay reconstructs by applying compactions in log order.
185        kept_from: usize,
186        /// True when the summarize call failed and the floor was applied.
187        degraded: bool,
188    },
189    /// Re-point the projection to its first `keep_items` items — the
190    /// `branch_move` of the commit-protocol vocabulary, expressed against
191    /// the linear projection (M3b). Fork UIs arrive with M4; the entry and
192    /// its replay semantics are settled here.
193    BranchMove {
194        keep_items: usize,
195    },
196    /// Digest of an abandoned branch, appended after a `branch_move` so the
197    /// lesson survives without the tokens (commit-protocol `supersede`).
198    Supersede {
199        digest: Vec<Item>,
200    },
201    /// A permission ask committed **before** it surfaces (durable asks):
202    /// if the process dies before a matching `ask_resolved`, replay
203    /// sees a dangling ask and resume re-surfaces it. Log-only (not a
204    /// projection item).
205    PendingAsk {
206        id: String,
207        summary: String,
208        #[serde(default, skip_serializing_if = "Option::is_none")]
209        protected_why: Option<String>,
210    },
211    /// Resolution of a `pending_ask` (§2b): the human answered.
212    AskResolved {
213        id: String,
214        allowed: bool,
215    },
216    /// Sets/overwrites the session's display name. Log-only — not a
217    /// projection item (like `PendingAsk`); the last one wins on replay.
218    Rename {
219        name: String,
220    },
221    #[serde(other)]
222    Unknown,
223}
224
225pub fn new_ulid() -> String {
226    ulid::Ulid::new().to_string()
227}
228
229/// A session display name: trimmed, non-empty, at most 64 chars.
230/// The one validator every entry point (CLI, ACP, TUI) funnels through.
231pub fn normalize_session_name(raw: &str) -> Option<String> {
232    let name = raw.trim();
233    (!name.is_empty() && name.chars().count() <= 64).then(|| name.to_string())
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    fn roundtrip<T: Serialize + for<'a> Deserialize<'a>>(v: &T) -> String {
241        let a = serde_json::to_string(v).unwrap();
242        let back: T = serde_json::from_str(&a).unwrap();
243        let b = serde_json::to_string(&back).unwrap();
244        assert_eq!(
245            a, b,
246            "serialize → deserialize → serialize must be byte-identical"
247        );
248        a
249    }
250
251    #[test]
252    fn items_roundtrip_byte_identical() {
253        let items = vec![
254            Item::System {
255                text: "you are hotl".into(),
256            },
257            Item::User {
258                text: "hi".into(),
259                synthetic: None,
260            },
261            Item::User {
262                text: "<project-instructions>...</project-instructions>".into(),
263                synthetic: Some(SyntheticReason::ProjectInstructions),
264            },
265            Item::Assistant {
266                blocks: vec![
267                    serde_json::json!({"type":"thinking","thinking":"","signature":"sig=="}),
268                    serde_json::json!({"type":"text","text":"hello"}),
269                    serde_json::json!({"type":"tool_use","id":"toolu_1","name":"read","input":{"path":"a.rs"}}),
270                ],
271            },
272            Item::ToolResults {
273                results: vec![ToolResultItem {
274                    tool_use_id: "toolu_1".into(),
275                    content: "fn main() {}".into(),
276                    is_error: false,
277                }],
278            },
279        ];
280        for item in &items {
281            roundtrip(item);
282        }
283    }
284
285    #[test]
286    fn entry_roundtrip_and_mutation() {
287        let mut e = Entry {
288            id: new_ulid(),
289            parent_id: None,
290            ts_ms: 1,
291            payload: EntryPayload::Item {
292                item: Item::User {
293                    text: "x".into(),
294                    synthetic: None,
295                },
296            },
297        };
298        roundtrip(&e);
299        // mutate, re-serialize — still stable
300        e.ts_ms = 2;
301        roundtrip(&e);
302    }
303
304    #[test]
305    fn unknown_variants_survive() {
306        let item: Item = serde_json::from_str(r#"{"type":"hologram","payload":{"x":1}}"#).unwrap();
307        assert_eq!(item, Item::Unknown);
308        let reason: SyntheticReason = serde_json::from_str(r#""quantum_nudge""#).unwrap();
309        assert_eq!(reason, SyntheticReason::Unknown);
310        let payload: EntryPayload =
311            serde_json::from_str(r#"{"kind":"visibility","target":"e1"}"#).unwrap();
312        assert_eq!(payload, EntryPayload::Unknown);
313        let stop: StopReason = serde_json::from_str(r#""cosmic_ray""#).unwrap();
314        assert_eq!(stop, StopReason::Other);
315    }
316
317    #[test]
318    fn assistant_views() {
319        let blocks = vec![
320            serde_json::json!({"type":"text","text":"I'll read "}),
321            serde_json::json!({"type":"text","text":"the file."}),
322            serde_json::json!({"type":"tool_use","id":"t1","name":"read","input":{"path":"x"}}),
323        ];
324        assert_eq!(assistant_text(&blocks), "I'll read the file.");
325        let uses = assistant_tool_uses(&blocks);
326        assert_eq!(uses.len(), 1);
327        assert_eq!(uses[0].name, "read");
328    }
329
330    #[test]
331    fn rename_entry_roundtrips_with_snake_case_kind() {
332        let json = roundtrip(&EntryPayload::Rename {
333            name: "fix-auth".into(),
334        });
335        assert!(json.contains("\"kind\":\"rename\""), "wire kind: {json}");
336        assert!(json.contains("\"name\":\"fix-auth\""), "wire name: {json}");
337    }
338
339    #[test]
340    fn normalize_session_name_trims_and_bounds() {
341        assert_eq!(
342            normalize_session_name("  fix auth  "),
343            Some("fix auth".into())
344        );
345        assert_eq!(normalize_session_name("   "), None);
346        assert_eq!(normalize_session_name(""), None);
347        let long = "x".repeat(65);
348        assert_eq!(normalize_session_name(&long), None);
349        let max = "é".repeat(64); // chars, not bytes
350        assert_eq!(normalize_session_name(&max), Some(max.clone()));
351    }
352}