1use crate::models::{ContentBlock, Message};
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4use std::collections::{HashMap, HashSet};
5pub const CURRENT_JOURNAL_SCHEMA_VERSION: u32 = 1;
6pub type EntryId = String;
7#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
8pub struct SpawnDepth(pub u32);
9impl SpawnDepth {
10 pub fn next(self) -> Self {
11 Self(self.0.saturating_add(1))
12 }
13}
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
15#[serde(tag = "kind", rename_all = "snake_case")]
16pub enum SessionEntryKind {
17 Message {
18 message: Message,
19 },
20 User {
21 text: String,
22 },
23 Assistant {
24 text: String,
25 },
26 Compaction {
27 summary: String,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
29 tokens_before: Option<u64>,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
31 tokens_after: Option<u64>,
32 #[serde(default, skip_serializing_if = "Option::is_none")]
33 model: Option<String>,
34 },
35 BranchSummary {
36 branch_id: String,
37 summary: String,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
39 parent_branch_id: Option<String>,
40 },
41 System {
42 content: String,
43 },
44}
45impl SessionEntryKind {
46 pub fn is_contextual(&self) -> bool {
47 matches!(
48 self,
49 Self::Message { .. } | Self::User { .. } | Self::Assistant { .. }
50 )
51 }
52 pub fn as_message(&self) -> Option<Message> {
53 match self {
54 Self::Message { message } => Some(message.clone()),
55 Self::User { text } => Some(Message {
56 role: "user".into(),
57 content: vec![ContentBlock::Text {
58 text: text.clone(),
59 cache_control: None,
60 }],
61 }),
62 Self::Assistant { text } => Some(Message {
63 role: "assistant".into(),
64 content: vec![ContentBlock::Text {
65 text: text.clone(),
66 cache_control: None,
67 }],
68 }),
69 Self::Compaction { summary, .. } => Some(Message {
70 role: "system".into(),
71 content: vec![ContentBlock::Text {
72 text: format!("[compaction summary] {summary}"),
73 cache_control: None,
74 }],
75 }),
76 Self::BranchSummary { summary, .. } => Some(Message {
77 role: "system".into(),
78 content: vec![ContentBlock::Text {
79 text: format!("[branch summary] {summary}"),
80 cache_control: None,
81 }],
82 }),
83 Self::System { content } => Some(Message {
84 role: "system".into(),
85 content: vec![ContentBlock::Text {
86 text: content.clone(),
87 cache_control: None,
88 }],
89 }),
90 }
91 }
92}
93#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
94pub struct SessionEntry {
95 pub id: EntryId,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub parent_id: Option<EntryId>,
98 #[serde(flatten)]
99 pub kind: SessionEntryKind,
100 pub created_at: DateTime<Utc>,
101 #[serde(default)]
102 pub spawn_depth: u32,
103}
104impl SessionEntry {
105 pub fn new(kind: SessionEntryKind, parent_id: Option<EntryId>, spawn_depth: u32) -> Self {
106 Self {
107 id: uuid::Uuid::new_v4().to_string(),
108 parent_id,
109 kind,
110 created_at: Utc::now(),
111 spawn_depth,
112 }
113 }
114 pub fn short_id(&self) -> &str {
115 if self.id.len() >= 8 {
116 &self.id[..8]
117 } else {
118 &self.id
119 }
120 }
121}
122#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
123pub struct SessionJournal {
124 #[serde(default)]
125 pub entries: Vec<SessionEntry>,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub leaf_id: Option<EntryId>,
128 #[serde(default = "default_journal_schema_version")]
129 pub schema_version: u32,
130 #[serde(default)]
131 pub spawn_depth: u32,
132}
133fn default_journal_schema_version() -> u32 {
134 CURRENT_JOURNAL_SCHEMA_VERSION
135}
136impl SessionJournal {
137 pub fn new() -> Self {
138 Self {
139 entries: Vec::new(),
140 leaf_id: None,
141 schema_version: CURRENT_JOURNAL_SCHEMA_VERSION,
142 spawn_depth: 0,
143 }
144 }
145 pub fn with_spawn_depth(depth: u32) -> Self {
146 Self {
147 spawn_depth: depth,
148 ..Self::new()
149 }
150 }
151 pub fn append(&mut self, kind: SessionEntryKind) -> EntryId {
152 let entry = SessionEntry::new(kind, self.leaf_id.clone(), self.spawn_depth);
153 let id = entry.id.clone();
154 self.entries.push(entry);
155 self.leaf_id = Some(id.clone());
156 id
157 }
158 pub fn append_message(&mut self, message: Message) -> EntryId {
159 self.append(SessionEntryKind::Message { message })
160 }
161 pub fn append_compaction(
162 &mut self,
163 summary: String,
164 tokens_before: Option<u64>,
165 tokens_after: Option<u64>,
166 model: Option<String>,
167 ) -> EntryId {
168 self.append(SessionEntryKind::Compaction {
169 summary,
170 tokens_before,
171 tokens_after,
172 model,
173 })
174 }
175 pub fn append_branch_summary(
176 &mut self,
177 branch_id: String,
178 summary: String,
179 parent_branch_id: Option<String>,
180 ) -> EntryId {
181 self.append(SessionEntryKind::BranchSummary {
182 branch_id,
183 summary,
184 parent_branch_id,
185 })
186 }
187 pub fn branch_to(&mut self, entry_id: &str) -> Result<(), String> {
188 if self.entries.iter().any(|e| e.id == entry_id) {
189 self.leaf_id = Some(entry_id.to_string());
190 Ok(())
191 } else {
192 Err(format!("entry {entry_id} not found"))
193 }
194 }
195 pub fn fork_from(&self, from_entry_id: Option<&str>) -> Result<Self, String> {
196 let leaf = if let Some(id) = from_entry_id {
197 if !self.entries.iter().any(|e| e.id == id) {
198 return Err(format!("fork source {id} not found"));
199 }
200 Some(id.to_string())
201 } else {
202 self.leaf_id.clone()
203 };
204 Ok(Self {
205 entries: self.entries.clone(),
206 leaf_id: leaf,
207 schema_version: self.schema_version,
208 spawn_depth: self.spawn_depth.saturating_add(1),
209 })
210 }
211 pub fn index(&self) -> HashMap<&str, &SessionEntry> {
212 self.entries.iter().map(|e| (e.id.as_str(), e)).collect()
213 }
214 pub fn children_of(&self, parent_id: Option<&str>) -> Vec<&SessionEntry> {
215 self.entries
216 .iter()
217 .filter(|e| e.parent_id.as_deref() == parent_id)
218 .collect()
219 }
220 pub fn contains(&self, entry_id: &str) -> bool {
221 self.entries.iter().any(|e| e.id == entry_id)
222 }
223 pub fn leaf(&self) -> Option<&SessionEntry> {
224 self.leaf_id
225 .as_deref()
226 .and_then(|id| self.entries.iter().find(|e| e.id == id))
227 }
228 pub fn root_to_leaf(&self) -> Vec<&SessionEntry> {
229 let index: HashMap<&str, &SessionEntry> =
230 self.entries.iter().map(|e| (e.id.as_str(), e)).collect();
231 let mut path = Vec::new();
232 let mut cur = self.leaf_id.as_deref();
233 let mut seen = HashSet::new();
234 while let Some(id) = cur {
235 if !seen.insert(id) {
236 break;
237 }
238 if let Some(entry) = index.get(id) {
239 path.push(*entry);
240 cur = entry.parent_id.as_deref();
241 } else {
242 break;
243 }
244 }
245 path.reverse();
246 path
247 }
248 pub fn active_messages(&self, include_system: bool) -> Vec<Message> {
249 self.root_to_leaf()
250 .into_iter()
251 .filter_map(|e| {
252 if !include_system && !e.kind.is_contextual() {
253 return None;
254 }
255 e.kind.as_message()
256 })
257 .collect()
258 }
259 pub fn leaves(&self) -> Vec<&SessionEntry> {
260 let parents: HashSet<&str> = self
261 .entries
262 .iter()
263 .filter_map(|e| e.parent_id.as_deref())
264 .collect();
265 self.entries
266 .iter()
267 .filter(|e| !parents.contains(e.id.as_str()))
268 .collect()
269 }
270 pub fn is_empty(&self) -> bool {
271 self.entries.is_empty()
272 }
273 pub fn len(&self) -> usize {
274 self.entries.len()
275 }
276 pub fn validate(&self) -> Result<(), String> {
277 let ids: HashSet<&str> = self.entries.iter().map(|e| e.id.as_str()).collect();
278 for entry in &self.entries {
279 if let Some(parent) = entry.parent_id.as_deref()
280 && !ids.contains(parent)
281 {
282 return Err(format!("entry {} missing parent {}", entry.id, parent));
283 }
284 }
285 if let Some(leaf) = self.leaf_id.as_deref()
286 && !ids.contains(leaf)
287 {
288 return Err(format!("leaf {leaf} not found"));
289 }
290 Ok(())
291 }
292 pub fn from_messages(messages: Vec<Message>, spawn_depth: u32) -> Self {
293 let mut j = Self::with_spawn_depth(spawn_depth);
294 for msg in messages {
295 j.append(SessionEntryKind::Message { message: msg });
296 }
297 j
298 }
299 pub fn to_messages(&self) -> Vec<Message> {
300 self.active_messages(true)
301 }
302
303 pub fn rebranch_active_messages(&mut self, messages: &[Message]) {
308 let active_path = self.root_to_leaf();
309 let shared_prefix = active_path
310 .iter()
311 .zip(messages)
312 .take_while(|(entry, message)| entry.kind.as_message().as_ref() == Some(*message))
313 .count();
314 self.leaf_id = shared_prefix
315 .checked_sub(1)
316 .map(|index| active_path[index].id.clone());
317 for message in &messages[shared_prefix..] {
318 self.append_message(message.clone());
319 }
320 }
321}
322#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
323pub struct SessionImportContainer {
324 pub format_version: u32,
325 pub source: String,
326 pub metadata: Option<serde_json::Value>,
327 pub entries: Vec<SessionEntry>,
328 #[serde(default, skip_serializing_if = "Option::is_none")]
329 pub leaf_id: Option<EntryId>,
330 pub exported_at: DateTime<Utc>,
331 #[serde(default)]
332 pub spawn_depth: u32,
333}
334impl SessionImportContainer {
335 pub fn new(
336 source: String,
337 journal: &SessionJournal,
338 metadata: Option<serde_json::Value>,
339 ) -> Self {
340 Self {
341 format_version: CURRENT_JOURNAL_SCHEMA_VERSION,
342 source,
343 metadata,
344 entries: journal.entries.clone(),
345 leaf_id: journal.leaf_id.clone(),
346 exported_at: Utc::now(),
347 spawn_depth: journal.spawn_depth,
348 }
349 }
350 pub fn into_journal(self) -> Result<SessionJournal, String> {
351 let j = SessionJournal {
352 entries: self.entries,
353 leaf_id: self.leaf_id,
354 schema_version: self.format_version,
355 spawn_depth: self.spawn_depth,
356 };
357 j.validate()?;
358 Ok(j)
359 }
360 pub fn to_json(&self) -> Result<String, serde_json::Error> {
361 serde_json::to_string_pretty(self)
362 }
363 pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
364 serde_json::from_str(json)
365 }
366}
367pub fn render_tree(journal: &SessionJournal) -> String {
368 if journal.entries.is_empty() {
369 return "(empty session — no entries yet)".to_string();
370 }
371 let index = journal.index();
372 let mut out = String::new();
373 let mut children: HashMap<Option<&str>, Vec<&SessionEntry>> = HashMap::new();
374 for entry in &journal.entries {
375 children
376 .entry(entry.parent_id.as_deref())
377 .or_default()
378 .push(entry);
379 }
380 let active_ids: HashSet<&str> = journal
381 .root_to_leaf()
382 .iter()
383 .map(|e| e.id.as_str())
384 .collect();
385 let leaf = journal.leaf_id.as_deref();
386 fn render_node(
387 out: &mut String,
388 children: &HashMap<Option<&str>, Vec<&SessionEntry>>,
389 active_ids: &HashSet<&str>,
390 leaf: Option<&str>,
391 parent: Option<&str>,
392 depth: usize,
393 ) {
394 let Some(nodes) = children.get(&parent) else {
395 return;
396 };
397 for (idx, entry) in nodes.iter().enumerate() {
398 let is_last = idx + 1 == nodes.len();
399 let prefix = if depth == 0 {
400 "".to_string()
401 } else {
402 let mut p = String::new();
403 for _ in 0..depth - 1 {
404 p.push_str("│ ");
405 }
406 if is_last {
407 p.push_str("└─ ");
408 } else {
409 p.push_str("├─ ");
410 }
411 p
412 };
413 let marker = if Some(entry.id.as_str()) == leaf {
414 "*"
415 } else if active_ids.contains(entry.id.as_str()) {
416 "●"
417 } else {
418 "○"
419 };
420 let kind_label = match &entry.kind {
421 SessionEntryKind::Message { message } => {
422 let role = &message.role;
423 let snippet: String = message
424 .content
425 .iter()
426 .filter_map(|b| match b {
427 ContentBlock::Text { text, .. } => Some(text.as_str()),
428 _ => None,
429 })
430 .collect::<Vec<_>>()
431 .join(" ");
432 let short: String = snippet.chars().take(60).collect();
433 format!("{role}: {short}")
434 }
435 SessionEntryKind::User { text } => {
436 let short: String = text.chars().take(60).collect();
437 format!("user: {short}")
438 }
439 SessionEntryKind::Assistant { text } => {
440 let short: String = text.chars().take(60).collect();
441 format!("assistant: {short}")
442 }
443 SessionEntryKind::Compaction { summary, .. } => {
444 let short: String = summary.chars().take(60).collect();
445 format!("compaction: {short}")
446 }
447 SessionEntryKind::BranchSummary {
448 branch_id, summary, ..
449 } => {
450 let short: String = summary.chars().take(60).collect();
451 format!("branch:{} {short}", &branch_id[..branch_id.len().min(8)])
452 }
453 SessionEntryKind::System { content } => {
454 let short: String = content.chars().take(60).collect();
455 format!("system: {short}")
456 }
457 };
458 out.push_str(&format!(
459 "{prefix}{marker} {} [{}] {kind_label}\n",
460 entry.short_id(),
461 entry.id
462 ));
463 render_node(out, children, active_ids, leaf, Some(&entry.id), depth + 1);
464 }
465 }
466 render_node(&mut out, &children, &active_ids, leaf, None, 0);
467 let _ = index;
468 if let Some(leaf_id) = leaf {
469 out.push_str(&format!(
470 "\nleaf: {leaf_id} (active, {} entries)\n",
471 journal.entries.len()
472 ));
473 }
474 out
475}
476#[cfg(test)]
477mod tests {
478 use super::*;
479 use crate::models::{ContentBlock, Message};
480 fn msg(role: &str, text: &str) -> Message {
481 Message {
482 role: role.to_string(),
483 content: vec![ContentBlock::Text {
484 text: text.to_string(),
485 cache_control: None,
486 }],
487 }
488 }
489 #[test]
490 fn append_creates_child_of_leaf() {
491 let mut j = SessionJournal::new();
492 let a = j.append(SessionEntryKind::User {
493 text: "hello".into(),
494 });
495 let b = j.append(SessionEntryKind::Assistant {
496 text: "world".into(),
497 });
498 assert_eq!(j.leaf_id.as_deref(), Some(b.as_str()));
499 let be = j.entries.iter().find(|e| e.id == b).unwrap();
500 assert_eq!(be.parent_id.as_deref(), Some(a.as_str()));
501 }
502 #[test]
503 fn branch_moves_leaf_only() {
504 let mut j = SessionJournal::new();
505 let a = j.append(SessionEntryKind::User { text: "a".into() });
506 let b = j.append(SessionEntryKind::User { text: "b".into() });
507 let _c = j.append(SessionEntryKind::User { text: "c".into() });
508 j.branch_to(&a).unwrap();
509 let d = j.append(SessionEntryKind::User { text: "d".into() });
510 assert_eq!(j.entries.len(), 4);
511 assert_eq!(j.leaf_id.as_deref(), Some(d.as_str()));
512 let path: Vec<String> = j.root_to_leaf().iter().map(|e| e.id.clone()).collect();
513 assert_eq!(path, vec![a.clone(), d.clone()]);
514 assert!(j.entries.iter().any(|e| e.id == b));
515 }
516 #[test]
517 fn from_messages_migrates() {
518 let msgs = vec![msg("user", "hi"), msg("assistant", "hello")];
519 let j = SessionJournal::from_messages(msgs, 0);
520 assert_eq!(j.entries.len(), 2);
521 assert!(j.validate().is_ok());
522 assert_eq!(j.root_to_leaf().len(), 2);
523 }
524 #[test]
525 fn repaired_messages_form_an_append_only_sibling_branch() {
526 let original = vec![
527 msg("user", "shared"),
528 msg("assistant", "broken"),
529 msg("user", "old tail"),
530 ];
531 let mut journal = SessionJournal::from_messages(original, 0);
532 let old_leaf = journal.leaf_id.clone().expect("old leaf");
533 let repaired = vec![
534 msg("user", "shared"),
535 msg("assistant", "repaired"),
536 msg("user", "new tail"),
537 ];
538
539 journal.rebranch_active_messages(&repaired);
540
541 assert_eq!(journal.to_messages(), repaired);
542 assert!(journal.contains(&old_leaf), "old evidence must remain");
543 assert_eq!(
544 journal.entries.len(),
545 5,
546 "one shared entry plus two branches"
547 );
548 }
549 #[test]
550 fn compaction_fits() {
551 let mut j = SessionJournal::new();
552 let id = j.append_compaction("summary".into(), Some(1000), Some(100), None);
553 assert!(j.contains(&id));
554 assert!(matches!(
555 j.leaf().unwrap().kind,
556 SessionEntryKind::Compaction { .. }
557 ));
558 }
559 #[test]
560 fn branch_summary_fits() {
561 let mut j = SessionJournal::new();
562 let a = j.append(SessionEntryKind::User {
563 text: "root".into(),
564 });
565 let b = j.append_branch_summary(a.clone(), "branch summary".into(), None);
566 assert!(j.contains(&b));
567 }
568 #[test]
569 fn spawn_depth_fork() {
570 let mut j = SessionJournal::with_spawn_depth(1);
571 let forked = j.fork_from(None).unwrap();
572 assert_eq!(forked.spawn_depth, 2);
573 let a = j.append(SessionEntryKind::User {
574 text: "root".into(),
575 });
576 let fork2 = j.fork_from(Some(&a)).unwrap();
577 assert_eq!(fork2.spawn_depth, 2);
578 }
579 #[test]
580 fn foreign_roundtrip() {
581 let mut j = SessionJournal::new();
582 j.append(SessionEntryKind::User {
583 text: "hello".into(),
584 });
585 let c = SessionImportContainer::new("codewhale".into(), &j, None);
586 let json = c.to_json().unwrap();
587 let back = SessionImportContainer::from_json(&json).unwrap();
588 let j2 = back.into_journal().unwrap();
589 assert_eq!(j.entries.len(), j2.entries.len());
590 }
591 #[test]
592 fn render_marks_active() {
593 let mut j = SessionJournal::new();
594 j.append(SessionEntryKind::User {
595 text: "root".into(),
596 });
597 let tree = render_tree(&j);
598 assert!(tree.contains('*'));
599 }
600 #[test]
601 fn active_messages_root_to_leaf() {
602 let mut j = SessionJournal::new();
603 j.append(SessionEntryKind::User { text: "a".into() });
604 j.append(SessionEntryKind::User { text: "b".into() });
605 let msgs = j.active_messages(false);
606 assert_eq!(msgs.len(), 2);
607 j.branch_to(&j.entries[0].id.clone()).unwrap();
608 j.append(SessionEntryKind::User { text: "c".into() });
609 let msgs2 = j.active_messages(false);
610 assert_eq!(msgs2.len(), 2);
611 }
612}