Skip to main content

yapi_core/
session.rs

1//! Session files: a JSONL tree of entries with a movable leaf.
2//!
3//! Port of `packages/coding-agent/src/core/session-manager.ts` in pi `v1.0.0`.
4//! Entries are kept as order-preserving documents so a migration rewrites files the
5//! way pi does. Typed views are parsed alongside. A new session reaches disk only
6//! once it has a user or assistant message.
7
8use std::collections::HashMap;
9use std::fs::{self, OpenOptions};
10use std::io::Write as _;
11use std::path::{Path, PathBuf};
12
13use indexmap::IndexMap;
14use serde_json::Value;
15use yapi_types::message::{
16    BranchSummaryMessage, CompactionSummaryMessage, Content, ContentBlock, CustomMessage, Message,
17    Usage,
18};
19use yapi_types::session::{
20    BranchSummaryEntry, CURRENT_VERSION, CompactionEntry, ContextEditEntry, CustomEntry,
21    CustomMessageEntry, EntryMeta, FileEntry, LabelEntry, MessageEntry, ModelChangeEntry,
22    Replacement, SessionHeader, SessionInfoEntry, ThinkingLevelChangeEntry, UsageEntry,
23};
24
25use crate::time::{now_iso, parse_iso, uuid_v4, uuid_v7};
26
27/// Session file errors.
28#[derive(Debug, thiserror::Error)]
29pub enum SessionError {
30    /// Reading or writing the file failed.
31    #[error("{path}: {source}")]
32    Io {
33        /// The file.
34        path: PathBuf,
35        /// The cause.
36        source: std::io::Error,
37    },
38    /// The file is not a session.
39    #[error("Session file is not a valid yapi session: {0}")]
40    Invalid(PathBuf),
41    /// An entry id that does not exist.
42    #[error("Entry {0} not found")]
43    NotFound(String),
44    /// An operation the target entry does not allow.
45    #[error("{0}")]
46    Rejected(String),
47    /// A JSON conversion failed.
48    #[error(transparent)]
49    Json(#[from] serde_json::Error),
50}
51
52type Result<T> = std::result::Result<T, SessionError>;
53
54#[derive(Clone)]
55struct Entry {
56    doc: Value,
57    view: Option<FileEntry>,
58}
59
60impl Entry {
61    fn new(doc: Value) -> Entry {
62        let view = serde_json::from_value(doc.clone()).ok();
63        Entry { doc, view }
64    }
65
66    fn kind(&self) -> &str {
67        self.doc["type"].as_str().unwrap_or_default()
68    }
69
70    fn id(&self) -> Option<&str> {
71        self.doc["id"].as_str()
72    }
73
74    fn parent_id(&self) -> Option<&str> {
75        self.doc["parentId"].as_str()
76    }
77}
78
79/// The model context a branch produces.
80#[derive(Clone, Debug, Default, PartialEq)]
81pub struct SessionContext {
82    /// Messages, with compaction and branch summaries and context edits applied.
83    pub messages: Vec<Message>,
84    /// The last thinking level set on the branch, or `off` when none.
85    pub thinking_level: String,
86    /// The last model used or selected on the branch, as (provider, model id).
87    pub model: Option<(String, String)>,
88}
89
90/// Summary of a session file, for listings.
91#[derive(Clone, Debug, PartialEq, Eq)]
92pub struct SessionSummary {
93    /// The file.
94    pub path: PathBuf,
95    /// The session id.
96    pub id: String,
97    /// The working directory recorded in the header.
98    pub cwd: String,
99    /// The latest name, if any.
100    pub name: Option<String>,
101    /// Text of the first user message.
102    pub first_message: String,
103    /// Text of every user and assistant message, space-separated, for search.
104    pub all_messages_text: String,
105    /// The session this one was forked from.
106    pub parent_session: Option<String>,
107    /// Number of messages.
108    pub message_count: usize,
109    /// Modification time, Unix milliseconds.
110    pub modified_ms: u64,
111}
112
113/// One entry of a [`SessionTree`].
114#[derive(Clone, Debug, PartialEq)]
115pub struct TreeNode {
116    /// The entry.
117    pub entry: FileEntry,
118    /// Indexes of the children, oldest first.
119    pub children: Vec<usize>,
120    /// The entry's label.
121    pub label: Option<String>,
122    /// When the label was last set.
123    pub label_timestamp: Option<String>,
124}
125
126/// Every entry of a session as a tree, stored flat.
127#[derive(Clone, Debug, Default, PartialEq)]
128pub struct SessionTree {
129    /// The nodes, in file order.
130    pub nodes: Vec<TreeNode>,
131    /// Indexes of entries without a known parent.
132    pub roots: Vec<usize>,
133}
134
135/// A session: its entries, where it is stored, and the current leaf.
136pub struct SessionManager {
137    session_id: String,
138    file: Option<PathBuf>,
139    dir: PathBuf,
140    cwd: PathBuf,
141    persist: bool,
142    flushed: bool,
143    /// The first entry a failed write left off the file. The next write
144    /// appends it and those after it.
145    unsaved: Option<usize>,
146    /// The session's first failed write, until
147    /// [`SessionManager::take_write_error`] returns it.
148    write_error: Option<String>,
149    /// A write failed since the session started.
150    write_failed: bool,
151    entries: Vec<Entry>,
152    by_id: HashMap<String, usize>,
153    /// Label and timestamp by target, in pi's `Map` order: a relabelled
154    /// target keeps its place.
155    labels: IndexMap<String, (String, String)>,
156    leaf: Option<String>,
157}
158
159fn io(path: &Path) -> impl FnOnce(std::io::Error) -> SessionError + '_ {
160    move |source| SessionError::Io {
161        path: path.to_path_buf(),
162        source,
163    }
164}
165
166fn to_doc(entry: &FileEntry) -> Value {
167    serde_json::to_value(entry).unwrap_or(Value::Null)
168}
169
170fn line(doc: &Value) -> String {
171    yapi_types::json::stringify(doc) + "\n"
172}
173
174/// Reads the entries of a session file, or none when the file is missing or does
175/// not start with a session header. With `repair`, a valid file whose last line
176/// lacks its newline gets one appended.
177fn load_docs(path: &Path, repair: bool) -> Result<Vec<Value>> {
178    let Ok(bytes) = fs::read(path) else {
179        return Ok(Vec::new());
180    };
181    let text = String::from_utf8_lossy(&bytes);
182    let docs: Vec<Value> = text
183        .split('\n')
184        .filter(|line| !line.trim().is_empty())
185        .filter_map(|line| {
186            serde_json::from_str(line)
187                .or_else(|_| serde_json::from_str(&repair_surrogates(line)))
188                .ok()
189        })
190        .collect();
191    let valid = docs
192        .first()
193        .is_some_and(|header| header["type"] == "session" && header["id"].is_string());
194    if !valid {
195        return Ok(Vec::new());
196    }
197    if repair && !text.is_empty() && !text.ends_with('\n') {
198        OpenOptions::new()
199            .append(true)
200            .open(path)
201            .and_then(|mut file| file.write_all(b"\n"))
202            .map_err(io(path))?;
203    }
204    Ok(docs)
205}
206
207/// `line` with each `\u` escape of an unpaired UTF-16 surrogate replaced by
208/// one of U+FFFD. pi writes them for text cut inside a surrogate pair, which
209/// serde_json rejects.
210fn repair_surrogates(line: &str) -> String {
211    let unit = |text: &str| u16::from_str_radix(text.strip_prefix("\\u")?.get(..4)?, 16).ok();
212    let mut repaired = String::with_capacity(line.len());
213    let mut rest = line;
214    while let Some(at) = rest.find('\\') {
215        repaired.push_str(&rest[..at]);
216        let escape = &rest[at..];
217        // A parsed `\uXXXX` is six ASCII bytes.
218        let (text, len) = match unit(escape) {
219            Some(0xD800..=0xDBFF) if matches!(unit(&escape[6..]), Some(0xDC00..=0xDFFF)) => {
220                (&escape[..12], 12)
221            }
222            Some(0xD800..=0xDFFF) => ("\\ufffd", 6),
223            // Any other escape, `\\` included, is copied whole.
224            _ => {
225                let len = 1 + escape[1..].chars().next().map_or(0, char::len_utf8);
226                (&escape[..len], len)
227            }
228        };
229        repaired.push_str(text);
230        rest = &escape[len..];
231    }
232    repaired.push_str(rest);
233    repaired
234}
235
236fn short_id(taken: &dyn Fn(&str) -> bool) -> String {
237    for _ in 0..100 {
238        let id = uuid_v4()[..8].to_owned();
239        if !taken(&id) {
240            return id;
241        }
242    }
243    uuid_v4()
244}
245
246/// Upgrades version 1 and 2 entries in place. Returns whether anything changed.
247fn migrate(docs: &mut [Value]) -> bool {
248    let version = docs
249        .iter()
250        .find(|doc| doc["type"] == "session")
251        .and_then(|header| header["version"].as_u64())
252        .unwrap_or(1);
253    if version >= u64::from(CURRENT_VERSION) {
254        return false;
255    }
256    if version < 2 {
257        let mut ids: Vec<String> = Vec::new();
258        let mut previous: Option<String> = None;
259        for doc in docs.iter_mut() {
260            if doc["type"] == "session" {
261                doc["version"] = Value::from(2);
262                continue;
263            }
264            let id = short_id(&|candidate| ids.iter().any(|id| id == candidate));
265            ids.push(id.clone());
266            doc["id"] = Value::from(id.clone());
267            doc["parentId"] = previous.map_or(Value::Null, Value::from);
268            previous = Some(id);
269        }
270        for index in 0..docs.len() {
271            if docs[index]["type"] != "compaction" {
272                continue;
273            }
274            if let Some(kept) = docs[index]["firstKeptEntryIndex"].as_u64() {
275                let target = docs
276                    .get(kept as usize)
277                    .filter(|target| target["type"] != "session")
278                    .map(|target| target["id"].clone());
279                if let Some(object) = docs[index].as_object_mut() {
280                    if let Some(id) = target {
281                        object.insert("firstKeptEntryId".into(), id);
282                    }
283                    object.shift_remove("firstKeptEntryIndex");
284                }
285            }
286        }
287    }
288    for doc in docs.iter_mut() {
289        if doc["type"] == "session" {
290            doc["version"] = Value::from(CURRENT_VERSION);
291        } else if doc["type"] == "message" && doc["message"]["role"] == "hookMessage" {
292            doc["message"]["role"] = Value::from("custom");
293        }
294    }
295    true
296}
297
298impl SessionManager {
299    fn blank(cwd: &Path, dir: &Path, persist: bool) -> SessionManager {
300        SessionManager {
301            session_id: String::new(),
302            file: None,
303            dir: dir.to_path_buf(),
304            cwd: cwd.to_path_buf(),
305            persist,
306            flushed: false,
307            unsaved: None,
308            write_error: None,
309            write_failed: false,
310            entries: Vec::new(),
311            by_id: HashMap::new(),
312            labels: IndexMap::new(),
313            leaf: None,
314        }
315    }
316
317    /// A new session stored in `dir`, created on disk once it has a conversation.
318    /// `id` must be a valid session id.
319    pub fn create(cwd: &Path, dir: &Path, id: Option<String>) -> Result<SessionManager> {
320        if let Some(id) = &id {
321            validate_session_id(id)?;
322        }
323        fs::create_dir_all(dir).map_err(io(dir))?;
324        let mut manager = SessionManager::blank(cwd, dir, true);
325        manager.new_session(id, None);
326        Ok(manager)
327    }
328
329    /// A copy at the same leaf that is never written, to try appends on.
330    pub fn preview(&self) -> SessionManager {
331        let mut copy = SessionManager::blank(&self.cwd, &self.dir, false);
332        copy.session_id.clone_from(&self.session_id);
333        copy.entries = self.entries.clone();
334        copy.index();
335        copy.leaf.clone_from(&self.leaf);
336        copy
337    }
338
339    /// A session that is never written.
340    pub fn in_memory(cwd: &Path) -> SessionManager {
341        let mut manager = SessionManager::blank(cwd, Path::new(""), false);
342        manager.new_session(None, None);
343        manager
344    }
345
346    /// Opens a session file. A missing file starts a new session at that path. The
347    /// working directory comes from the header unless given, and new sessions go to
348    /// `dir`, else the file's directory.
349    pub fn open(path: &Path, dir: Option<&Path>, cwd: Option<&Path>) -> Result<SessionManager> {
350        let docs = load_docs(path, true)?;
351        let header_cwd = docs
352            .first()
353            .and_then(|header| header["cwd"].as_str())
354            .filter(|cwd| !cwd.is_empty())
355            .map(PathBuf::from);
356        let cwd = cwd
357            .map(Path::to_path_buf)
358            .or(header_cwd)
359            .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
360        let dir = dir
361            .map(Path::to_path_buf)
362            .unwrap_or_else(|| path.parent().unwrap_or(Path::new("")).to_path_buf());
363        let mut manager = SessionManager::blank(&cwd, &dir, true);
364        manager.set_file(path, docs)?;
365        Ok(manager)
366    }
367
368    /// The most recently modified session file in `dir` (one from `cwd` when
369    /// filtering), or a new session.
370    pub fn continue_recent(cwd: &Path, dir: &Path, filter_cwd: bool) -> Result<SessionManager> {
371        fs::create_dir_all(dir).map_err(io(dir))?;
372        match find_most_recent(dir, filter_cwd.then_some(cwd)) {
373            Some(recent) => SessionManager::open(&recent, Some(dir), Some(cwd)),
374            None => SessionManager::create(cwd, dir, None),
375        }
376    }
377
378    fn set_file(&mut self, path: &Path, docs: Vec<Value>) -> Result<()> {
379        if docs.is_empty() {
380            if fs::metadata(path).is_ok_and(|meta| meta.len() > 0) {
381                return Err(SessionError::Invalid(path.to_path_buf()));
382            }
383            let existed = path.exists();
384            self.new_session(None, None);
385            self.file = Some(path.to_path_buf());
386            if existed {
387                self.rewrite()?;
388                self.flushed = true;
389            }
390            return Ok(());
391        }
392        self.file = Some(path.to_path_buf());
393        self.load(docs)?;
394        self.flushed = true;
395        Ok(())
396    }
397
398    fn load(&mut self, mut docs: Vec<Value>) -> Result<()> {
399        let migrated = migrate(&mut docs);
400        self.session_id = docs[0]["id"].as_str().unwrap_or_default().to_owned();
401        self.entries = docs.into_iter().map(Entry::new).collect();
402        if migrated {
403            self.rewrite()?;
404        }
405        self.index();
406        Ok(())
407    }
408
409    fn index(&mut self) {
410        self.by_id.clear();
411        self.labels.clear();
412        self.leaf = None;
413        for (position, entry) in self.entries.iter().enumerate() {
414            if entry.kind() == "session" {
415                continue;
416            }
417            let Some(id) = entry.id() else { continue };
418            self.by_id.insert(id.to_owned(), position);
419            self.leaf = Some(id.to_owned());
420            if let Some(FileEntry::Label(label)) = &entry.view {
421                match &label.label {
422                    Some(text) if !text.is_empty() => {
423                        self.labels.insert(
424                            label.target_id.clone(),
425                            (text.clone(), label.meta.timestamp.clone()),
426                        );
427                    }
428                    _ => {
429                        self.labels.shift_remove(&label.target_id);
430                    }
431                }
432            }
433        }
434    }
435
436    /// Starts over with a fresh header and returns the new file path when persisted.
437    pub fn new_session(&mut self, id: Option<String>, parent: Option<String>) -> Option<PathBuf> {
438        self.session_id = id.unwrap_or_else(uuid_v7);
439        let timestamp = now_iso();
440        let header = FileEntry::Session(SessionHeader {
441            version: Some(CURRENT_VERSION),
442            id: self.session_id.clone(),
443            timestamp: timestamp.clone(),
444            cwd: self.cwd.to_string_lossy().into_owned(),
445            parent_session: parent,
446            provider: None,
447            model_id: None,
448            thinking_level: None,
449            branched_from: None,
450        });
451        self.entries = vec![Entry::new(to_doc(&header))];
452        self.by_id.clear();
453        self.labels.clear();
454        self.leaf = None;
455        self.flushed = false;
456        self.unsaved = None;
457        self.write_failed = false;
458        if self.persist {
459            let file_timestamp = timestamp.replace([':', '.'], "-");
460            self.file = Some(
461                self.dir
462                    .join(format!("{file_timestamp}_{}.jsonl", self.session_id)),
463            );
464        }
465        self.file.clone()
466    }
467
468    fn rewrite(&self) -> Result<()> {
469        let (true, Some(file)) = (self.persist, &self.file) else {
470            return Ok(());
471        };
472        let text: String = self.entries.iter().map(|entry| line(&entry.doc)).collect();
473        // A crash or full disk mid-write must not empty the history.
474        yapi_ai::auth::lock::write_atomic(file, text.as_bytes(), 0o666).map_err(io(file))
475    }
476
477    fn has_conversation(&self) -> bool {
478        self.entries.iter().any(|entry| {
479            entry.kind() == "message"
480                && matches!(
481                    entry.doc["message"]["role"].as_str(),
482                    Some("user" | "assistant")
483                )
484        })
485    }
486
487    /// Writes the newest entry, after those a failed write left off the file.
488    fn persist_entry(&mut self) -> Result<()> {
489        let (true, Some(file)) = (self.persist, self.file.clone()) else {
490            return Ok(());
491        };
492        if !self.flushed {
493            if !self.has_conversation() {
494                return Ok(());
495            }
496            if let Some(dir) = file.parent() {
497                fs::create_dir_all(dir).map_err(io(dir))?;
498            }
499            let text: String = self.entries.iter().map(|entry| line(&entry.doc)).collect();
500            write_new(&file, &text)?;
501            self.flushed = true;
502        } else {
503            // `append` pushed the newest entry, so the vector is not empty.
504            let from = self.unsaved.unwrap_or(self.entries.len() - 1);
505            let text: String = self.entries[from..]
506                .iter()
507                .map(|entry| line(&entry.doc))
508                .collect();
509            append_text(&file, &text)?;
510        }
511        Ok(())
512    }
513
514    fn new_meta(&self) -> EntryMeta {
515        EntryMeta {
516            id: short_id(&|id| self.by_id.contains_key(id)),
517            parent_id: self.leaf.clone(),
518            timestamp: now_iso(),
519        }
520    }
521
522    /// Records `entry` and writes it. When the write fails the entry stays,
523    /// as in pi, and the next write appends it first, so the file never holds
524    /// an entry whose parent it lacks.
525    fn append(&mut self, entry: FileEntry) -> Result<String> {
526        let id = entry.meta().map(|meta| meta.id.clone()).unwrap_or_default();
527        self.entries.push(Entry {
528            doc: to_doc(&entry),
529            view: Some(entry),
530        });
531        self.by_id.insert(id.clone(), self.entries.len() - 1);
532        self.leaf = Some(id.clone());
533        if let Err(error) = self.persist_entry() {
534            // Before the first flush every entry is written anyway.
535            if self.flushed {
536                self.unsaved.get_or_insert(self.entries.len() - 1);
537            }
538            if !std::mem::replace(&mut self.write_failed, true) {
539                self.write_error = Some(error.to_string());
540            }
541            return Err(error);
542        }
543        self.unsaved = None;
544        Ok(id)
545    }
546
547    /// The error of the session's first failed write, returned once.
548    pub fn take_write_error(&mut self) -> Option<String> {
549        self.write_error.take()
550    }
551
552    /// Appends a message on the current branch and returns its entry id.
553    pub fn append_message(&mut self, message: Message) -> Result<String> {
554        let meta = self.new_meta();
555        self.append(FileEntry::Message(MessageEntry { meta, message }))
556    }
557
558    /// Records a thinking level change.
559    pub fn append_thinking_level_change(&mut self, level: &str) -> Result<String> {
560        let meta = self.new_meta();
561        self.append(FileEntry::ThinkingLevelChange(ThinkingLevelChangeEntry {
562            meta,
563            thinking_level: level.to_owned(),
564        }))
565    }
566
567    /// Records a model change.
568    pub fn append_model_change(&mut self, provider: &str, model_id: &str) -> Result<String> {
569        let meta = self.new_meta();
570        self.append(FileEntry::ModelChange(ModelChangeEntry {
571            meta,
572            provider: provider.to_owned(),
573            model_id: model_id.to_owned(),
574        }))
575    }
576
577    /// Records a compaction with the current system message, so the prompt
578    /// survives when the messages before `firstKeptEntryId` drop out.
579    pub fn append_compaction(
580        &mut self,
581        summary: String,
582        first_kept_entry_id: Option<String>,
583        tokens_before: u64,
584        details: Option<Value>,
585        from_hook: Option<bool>,
586        usage: Option<Usage>,
587    ) -> Result<String> {
588        let meta = self.new_meta();
589        let system = yapi_ai::transcript::current_system_message(&self.build_context().messages)
590            .map(|mut system| {
591                system.timestamp = parse_iso(&meta.timestamp).unwrap_or_default();
592                Box::new(Message::System(system))
593            });
594        let first_kept = first_kept_entry_id.unwrap_or_else(|| meta.id.clone());
595        self.append(FileEntry::Compaction(CompactionEntry {
596            meta,
597            summary,
598            first_kept_entry_id: Some(Some(first_kept)),
599            tokens_before,
600            details,
601            usage,
602            from_hook,
603            system_message: system,
604        }))
605    }
606
607    /// Stores extension state outside the model context.
608    pub fn append_custom_entry(
609        &mut self,
610        custom_type: &str,
611        data: Option<Value>,
612    ) -> Result<String> {
613        let meta = self.new_meta();
614        self.append(FileEntry::Custom(CustomEntry {
615            custom_type: custom_type.to_owned(),
616            data,
617            meta,
618        }))
619    }
620
621    /// Stores an extension message that is part of the model context.
622    pub fn append_custom_message(
623        &mut self,
624        custom_type: &str,
625        content: Content,
626        display: bool,
627        details: Option<Value>,
628    ) -> Result<String> {
629        let meta = self.new_meta();
630        self.append(FileEntry::CustomMessage(CustomMessageEntry {
631            custom_type: custom_type.to_owned(),
632            content,
633            display,
634            details,
635            meta,
636        }))
637    }
638
639    /// Records usage outside the model context, such as a cache warming
640    /// request's, attributed to `provider` and `model`.
641    pub fn append_usage(
642        &mut self,
643        kind: &str,
644        provider: &str,
645        model: &str,
646        usage: Usage,
647        note: Option<String>,
648    ) -> Result<String> {
649        let meta = self.new_meta();
650        self.append(FileEntry::Usage(UsageEntry {
651            meta,
652            kind: kind.to_owned(),
653            provider: provider.to_owned(),
654            model: model.to_owned(),
655            usage,
656            note,
657        }))
658    }
659
660    /// Names the session, with line breaks as spaces.
661    pub fn append_session_info(&mut self, name: &str) -> Result<String> {
662        let meta = self.new_meta();
663        let name = name
664            .split(['\r', '\n'])
665            .filter(|part| !part.is_empty())
666            .collect::<Vec<_>>()
667            .join(" ")
668            .trim()
669            .to_owned();
670        self.append(FileEntry::SessionInfo(SessionInfoEntry {
671            meta,
672            name: Some(name),
673        }))
674    }
675
676    /// Edits an earlier model-visible entry on the active branch: `None` drops it
677    /// from the context, a replacement swaps its content. Text replacing an
678    /// assistant or tool result becomes a text block.
679    pub fn append_context_edit(
680        &mut self,
681        target_id: &str,
682        replacement: Option<Content>,
683    ) -> Result<String> {
684        let target = self
685            .entry(target_id)
686            .ok_or_else(|| SessionError::NotFound(target_id.to_owned()))?;
687        if !self
688            .branch_path(None)
689            .iter()
690            .any(|entry| entry.meta().is_some_and(|meta| meta.id == target_id))
691        {
692            return Err(SessionError::Rejected(format!(
693                "Entry {target_id} is not on the active branch"
694            )));
695        }
696        let role = match target {
697            FileEntry::CustomMessage(_) => "custom",
698            FileEntry::Message(entry) => match &entry.message {
699                Message::User(_) => "user",
700                Message::Assistant(_) => "assistant",
701                Message::ToolResult(_) => "toolResult",
702                _ => "",
703            },
704            _ => "",
705        };
706        if role.is_empty() {
707            return Err(SessionError::Rejected(format!(
708                "Entry {target_id} does not contribute editable model content"
709            )));
710        }
711        let replacement = replacement.map(|content| match content {
712            Content::Text(text) if matches!(role, "assistant" | "toolResult") => {
713                Content::Blocks(vec![ContentBlock::text(text)])
714            }
715            content => content,
716        });
717        let meta = self.new_meta();
718        self.append(FileEntry::ContextEdit(ContextEditEntry {
719            meta,
720            target_id: target_id.to_owned(),
721            replacement: replacement.map(|content| Replacement { content }),
722        }))
723    }
724
725    /// Sets or clears the label of an entry.
726    pub fn append_label(&mut self, target_id: &str, label: Option<String>) -> Result<String> {
727        if !self.by_id.contains_key(target_id) {
728            return Err(SessionError::NotFound(target_id.to_owned()));
729        }
730        let meta = self.new_meta();
731        let timestamp = meta.timestamp.clone();
732        // A failed write keeps the entry, so the label applies either way.
733        let appended = self.append(FileEntry::Label(LabelEntry {
734            meta,
735            target_id: target_id.to_owned(),
736            label: label.clone(),
737        }));
738        match label.filter(|label| !label.is_empty()) {
739            Some(label) => {
740                self.labels.insert(target_id.to_owned(), (label, timestamp));
741            }
742            None => {
743                self.labels.shift_remove(target_id);
744            }
745        }
746        appended
747    }
748
749    /// The latest session name.
750    pub fn name(&self) -> Option<String> {
751        self.entries
752            .iter()
753            .rev()
754            .find_map(|entry| match &entry.view {
755                Some(FileEntry::SessionInfo(info)) => Some(
756                    info.name
757                        .as_deref()
758                        .map(str::trim)
759                        .filter(|name| !name.is_empty())
760                        .map(str::to_owned),
761                ),
762                _ => None,
763            })?
764    }
765
766    /// The session id.
767    pub fn id(&self) -> &str {
768        &self.session_id
769    }
770
771    /// The session file, when persisted.
772    pub fn file(&self) -> Option<&Path> {
773        self.file.as_deref().filter(|_| self.persist)
774    }
775
776    /// Whether the session is written to disk.
777    pub fn is_persisted(&self) -> bool {
778        self.persist
779    }
780
781    /// The directory new session files go to.
782    pub fn dir(&self) -> &Path {
783        &self.dir
784    }
785
786    /// The session's working directory.
787    pub fn cwd(&self) -> &Path {
788        &self.cwd
789    }
790
791    /// The header.
792    pub fn header(&self) -> Option<&SessionHeader> {
793        match self.entries.first().and_then(|entry| entry.view.as_ref()) {
794            Some(FileEntry::Session(header)) => Some(header),
795            _ => None,
796        }
797    }
798
799    /// The current leaf entry id.
800    pub fn leaf_id(&self) -> Option<&str> {
801        self.leaf.as_deref()
802    }
803
804    /// An entry by id.
805    pub fn entry(&self, id: &str) -> Option<&FileEntry> {
806        self.by_id
807            .get(id)
808            .and_then(|position| self.entries[*position].view.as_ref())
809    }
810
811    /// The label of an entry.
812    pub fn label(&self, id: &str) -> Option<&str> {
813        self.labels.get(id).map(|(label, _)| label.as_str())
814    }
815
816    /// The header and the other entries as stored, in file order: what pi's
817    /// HTML export embeds.
818    pub fn documents(&self) -> (Option<&Value>, Vec<&Value>) {
819        let header = self
820            .entries
821            .first()
822            .filter(|entry| entry.kind() == "session")
823            .map(|entry| &entry.doc);
824        let entries = self
825            .entries
826            .iter()
827            .filter(|entry| entry.kind() != "session")
828            .map(|entry| &entry.doc)
829            .collect();
830        (header, entries)
831    }
832
833    /// Every entry except the header, in file order.
834    pub fn entries(&self) -> impl Iterator<Item = &FileEntry> {
835        self.entries
836            .iter()
837            .filter(|entry| entry.kind() != "session")
838            .filter_map(|entry| entry.view.as_ref())
839    }
840
841    /// pi's `getTree`: entries under their parents, children oldest first.
842    /// Entries whose parent is missing become roots.
843    pub fn tree(&self) -> SessionTree {
844        let entries: Vec<&FileEntry> = self.entries().collect();
845        let index: HashMap<&str, usize> = entries
846            .iter()
847            .enumerate()
848            .filter_map(|(position, entry)| entry.meta().map(|meta| (meta.id.as_str(), position)))
849            .collect();
850        let mut tree = SessionTree {
851            nodes: entries
852                .iter()
853                .map(|entry| {
854                    let id = entry
855                        .meta()
856                        .map(|meta| meta.id.as_str())
857                        .unwrap_or_default();
858                    let label = self.labels.get(id);
859                    TreeNode {
860                        entry: (*entry).clone(),
861                        children: Vec::new(),
862                        label: label.map(|(label, _)| label.clone()),
863                        label_timestamp: label.map(|(_, timestamp)| timestamp.clone()),
864                    }
865                })
866                .collect(),
867            roots: Vec::new(),
868        };
869        for (position, entry) in entries.iter().enumerate() {
870            let meta = entry.meta();
871            let parent = meta
872                .and_then(|meta| meta.parent_id.as_deref())
873                .filter(|parent| Some(*parent) != meta.map(|meta| meta.id.as_str()))
874                .and_then(|parent| index.get(parent));
875            match parent {
876                Some(&parent) => tree.nodes[parent].children.push(position),
877                None => tree.roots.push(position),
878            }
879        }
880        let time = |node: &TreeNode| {
881            node.entry
882                .meta()
883                .and_then(|meta| crate::time::parse_iso(&meta.timestamp))
884                .unwrap_or(0)
885        };
886        for position in 0..tree.nodes.len() {
887            let mut children = std::mem::take(&mut tree.nodes[position].children);
888            children.sort_by_key(|child| time(&tree.nodes[*child]));
889            tree.nodes[position].children = children;
890        }
891        tree
892    }
893
894    /// Positions in `entries` of the path from the root to `from` (default:
895    /// the leaf).
896    fn branch_positions(&self, from: Option<&str>) -> Vec<usize> {
897        let mut positions = Vec::new();
898        let mut current = from
899            .or(self.leaf.as_deref())
900            .and_then(|id| self.by_id.get(id));
901        while let Some(&position) = current {
902            positions.push(position);
903            current = self.entries[position]
904                .parent_id()
905                .and_then(|parent| self.by_id.get(parent));
906        }
907        positions.reverse();
908        positions
909    }
910
911    /// The path from the root to `from` (default: the leaf).
912    pub fn branch_path(&self, from: Option<&str>) -> Vec<&FileEntry> {
913        self.branch_positions(from)
914            .into_iter()
915            .filter_map(|position| self.entries[position].view.as_ref())
916            .collect()
917    }
918
919    /// pi's `serializeSessionBranch`: a fresh header and the current branch with
920    /// parent ids rechained, as JSONL.
921    pub fn serialize_branch(&self) -> String {
922        let positions = self.branch_positions(None);
923        let header = serde_json::json!({
924            "type": "session",
925            "version": CURRENT_VERSION,
926            "id": self.session_id,
927            "timestamp": crate::time::now_iso(),
928            "cwd": self.cwd.display().to_string(),
929        });
930        let mut out = line(&header);
931        let mut parent = Value::Null;
932        for position in positions {
933            let mut doc = self.entries[position].doc.clone();
934            if let Some(object) = doc.as_object_mut() {
935                object.insert("parentId".into(), parent.clone());
936            }
937            parent = doc["id"].clone();
938            out.push_str(&line(&doc));
939        }
940        out
941    }
942
943    /// Moves the leaf to an existing entry. The next append starts a branch there.
944    pub fn branch(&mut self, id: &str) -> Result<()> {
945        if !self.by_id.contains_key(id) {
946            return Err(SessionError::NotFound(id.to_owned()));
947        }
948        self.leaf = Some(id.to_owned());
949        Ok(())
950    }
951
952    /// Moves the leaf before the first entry.
953    pub fn reset_leaf(&mut self) {
954        self.leaf = None;
955    }
956
957    /// Branches to `from` (or the root) and records a summary of the branch left.
958    pub fn branch_with_summary(
959        &mut self,
960        from: Option<&str>,
961        summary: String,
962        details: Option<Value>,
963        from_hook: Option<bool>,
964        usage: Option<Usage>,
965    ) -> Result<String> {
966        if let Some(id) = from
967            && !self.by_id.contains_key(id)
968        {
969            return Err(SessionError::NotFound(id.to_owned()));
970        }
971        let from_id = self.leaf.clone().unwrap_or_else(|| "root".into());
972        self.leaf = from.map(str::to_owned);
973        let meta = self.new_meta();
974        self.append(FileEntry::BranchSummary(BranchSummaryEntry {
975            meta,
976            from_id,
977            summary,
978            details,
979            usage,
980            from_hook,
981        }))
982    }
983
984    /// The model context of the current branch.
985    pub fn build_context(&self) -> SessionContext {
986        build_context(&self.branch_path(None))
987    }
988
989    /// The current branch's context, entry by entry.
990    pub fn build_projection(&self) -> Projection<'_> {
991        build_projection(&self.branch_path(None))
992    }
993
994    /// Starts a new session file holding the branch up to `leaf_id`, with labels on
995    /// it, and switches to it. Returns the new file when persisted.
996    pub fn create_branched_session(&mut self, leaf_id: &str) -> Result<Option<PathBuf>> {
997        let previous_file = self.file.clone();
998        let path: Vec<FileEntry> = self
999            .branch_path(Some(leaf_id))
1000            .into_iter()
1001            .cloned()
1002            .collect();
1003        if path.is_empty() {
1004            return Err(SessionError::NotFound(leaf_id.to_owned()));
1005        }
1006        let mut kept: Vec<Value> = Vec::new();
1007        let mut label_targets: HashMap<String, String> = HashMap::new();
1008        let mut pending_labels: Vec<String> = Vec::new();
1009        let mut parent: Option<String> = None;
1010        for entry in &path {
1011            let Some(meta) = entry.meta() else { continue };
1012            if matches!(entry, FileEntry::Label(_)) {
1013                pending_labels.push(meta.id.clone());
1014                continue;
1015            }
1016            for label in pending_labels.drain(..) {
1017                label_targets.insert(label, meta.id.clone());
1018            }
1019            let position = self.by_id[&meta.id];
1020            let mut doc = self.entries[position].doc.clone();
1021            doc["parentId"] = parent.clone().map_or(Value::Null, Value::from);
1022            if let FileEntry::Compaction(compaction) = entry
1023                && let Some(Some(first_kept)) = &compaction.first_kept_entry_id
1024                && first_kept != &meta.id
1025                && let Some(target) = label_targets.get(first_kept)
1026            {
1027                doc["firstKeptEntryId"] = Value::from(target.clone());
1028            }
1029            parent = Some(meta.id.clone());
1030            kept.push(doc);
1031        }
1032
1033        let kept_ids: Vec<String> = kept
1034            .iter()
1035            .filter_map(|doc| doc["id"].as_str().map(str::to_owned))
1036            .collect();
1037        let labels: Vec<(String, String, String)> = self
1038            .labels
1039            .iter()
1040            .filter(|(target, _)| kept_ids.contains(target))
1041            .map(|(target, (label, timestamp))| (target.clone(), label.clone(), timestamp.clone()))
1042            .collect();
1043
1044        let previous_dir = self.dir.clone();
1045        let persist = self.persist;
1046        self.new_session(
1047            None,
1048            if persist {
1049                previous_file.map(|f| f.to_string_lossy().into_owned())
1050            } else {
1051                None
1052            },
1053        );
1054        self.dir = previous_dir;
1055        let mut all_ids = kept_ids.clone();
1056        let mut docs: Vec<Value> = vec![self.entries[0].doc.clone()];
1057        docs.extend(kept);
1058        let mut label_parent = kept_ids.last().cloned();
1059        for (target, label, timestamp) in labels {
1060            let id = short_id(&|candidate| all_ids.iter().any(|id| id == candidate));
1061            all_ids.push(id.clone());
1062            let entry = FileEntry::Label(LabelEntry {
1063                meta: EntryMeta {
1064                    id: id.clone(),
1065                    parent_id: label_parent.clone(),
1066                    timestamp,
1067                },
1068                target_id: target,
1069                label: Some(label),
1070            });
1071            docs.push(to_doc(&entry));
1072            label_parent = Some(id);
1073        }
1074        self.entries = docs.into_iter().map(Entry::new).collect();
1075        self.index();
1076        if persist && self.has_conversation() {
1077            self.rewrite()?;
1078            self.flushed = true;
1079        }
1080        Ok(self.file.clone().filter(|_| persist))
1081    }
1082
1083    /// Copies every entry of `source` into a new session file for `cwd` in `dir`,
1084    /// with `parentSession` pointing at the source.
1085    pub fn fork_from(
1086        source: &Path,
1087        cwd: &Path,
1088        dir: &Path,
1089        id: Option<String>,
1090    ) -> Result<SessionManager> {
1091        let docs = load_docs(source, true)?;
1092        if docs.is_empty() {
1093            return Err(SessionError::Rejected(format!(
1094                "Cannot fork: source session file is empty or invalid: {}",
1095                source.display()
1096            )));
1097        }
1098        if let Some(id) = &id {
1099            validate_session_id(id)?;
1100        }
1101        fs::create_dir_all(dir).map_err(io(dir))?;
1102        let mut manager = SessionManager::blank(cwd, dir, true);
1103        let file = manager
1104            .new_session(id, Some(source.to_string_lossy().into_owned()))
1105            .unwrap_or_default();
1106        let mut text = line(&manager.entries[0].doc);
1107        for doc in docs.iter().filter(|doc| doc["type"] != "session") {
1108            text += &line(doc);
1109        }
1110        write_new(&file, &text)?;
1111        SessionManager::open(&file, Some(dir), Some(cwd))
1112    }
1113}
1114
1115/// The messages an entry contributes to the context before edits.
1116pub(crate) fn entry_messages(entry: &FileEntry) -> Vec<Message> {
1117    match entry {
1118        FileEntry::Message(entry) => vec![entry.message.clone()],
1119        FileEntry::CustomMessage(entry) => vec![Message::Custom(CustomMessage {
1120            custom_type: entry.custom_type.clone(),
1121            content: entry.content.clone(),
1122            display: entry.display,
1123            details: entry.details.clone(),
1124            timestamp: parse_iso(&entry.meta.timestamp).unwrap_or_default(),
1125        })],
1126        FileEntry::BranchSummary(entry) if !entry.summary.is_empty() => {
1127            vec![Message::BranchSummary(BranchSummaryMessage {
1128                summary: entry.summary.clone(),
1129                from_id: Some(entry.from_id.clone()),
1130                timestamp: parse_iso(&entry.meta.timestamp).unwrap_or_default(),
1131            })]
1132        }
1133        FileEntry::Compaction(entry) => {
1134            let summary = Message::CompactionSummary(CompactionSummaryMessage {
1135                summary: entry.summary.clone(),
1136                tokens_before: entry.tokens_before,
1137                timestamp: parse_iso(&entry.meta.timestamp).unwrap_or_default(),
1138            });
1139            match &entry.system_message {
1140                Some(system) => vec![(**system).clone(), summary],
1141                None => vec![summary],
1142            }
1143        }
1144        _ => Vec::new(),
1145    }
1146}
1147
1148fn edited(
1149    messages: Vec<Message>,
1150    replacement: Option<&yapi_types::session::Replacement>,
1151) -> Vec<Message> {
1152    let Some(replacement) = replacement else {
1153        return Vec::new();
1154    };
1155    let as_blocks = || replacement.content.clone().into_blocks();
1156    messages
1157        .into_iter()
1158        .map(|message| match message {
1159            Message::User(mut user) => {
1160                user.content = replacement.content.clone();
1161                Message::User(user)
1162            }
1163            Message::Custom(mut custom) => {
1164                custom.content = replacement.content.clone();
1165                Message::Custom(custom)
1166            }
1167            Message::Assistant(mut assistant) => {
1168                assistant.content = as_blocks();
1169                Message::Assistant(assistant)
1170            }
1171            Message::ToolResult(mut result) => {
1172                result.content = as_blocks();
1173                Message::ToolResult(result)
1174            }
1175            other => other,
1176        })
1177        .collect()
1178}
1179
1180/// One context entry of a branch and the messages it contributes after edits.
1181#[derive(Clone, Debug)]
1182pub struct ProjectedEntry<'a> {
1183    /// The entry.
1184    pub source: &'a FileEntry,
1185    /// Its model-visible messages, empty when edited out or not visible.
1186    pub messages: Vec<Message>,
1187}
1188
1189/// The context of a branch, entry by entry.
1190#[derive(Clone, Debug)]
1191pub struct Projection<'a> {
1192    /// Context entries: the last compaction first, then the entries it keeps and
1193    /// the later ones.
1194    pub entries: Vec<ProjectedEntry<'a>>,
1195    /// Every projected message, in order.
1196    pub messages: Vec<Message>,
1197    /// The last thinking level set on the branch, or `off` when none.
1198    pub thinking_level: String,
1199    /// The last model used or selected on the branch, as (provider, model id).
1200    pub model: Option<(String, String)>,
1201}
1202
1203/// Projects a branch path: the last compaction, the entries it keeps, then later
1204/// entries, with context edits applied and model and thinking level as last set.
1205pub fn build_projection<'a>(path: &[&'a FileEntry]) -> Projection<'a> {
1206    let mut thinking_level = "off".to_owned();
1207    let mut model = None;
1208    for entry in path {
1209        match entry {
1210            FileEntry::ThinkingLevelChange(change) => {
1211                thinking_level = change.thinking_level.clone()
1212            }
1213            FileEntry::ModelChange(change) => {
1214                model = Some((change.provider.clone(), change.model_id.clone()));
1215            }
1216            FileEntry::Message(entry) => {
1217                if let Message::Assistant(assistant) = &entry.message {
1218                    model = Some((assistant.provider.clone(), assistant.model.clone()));
1219                }
1220            }
1221            _ => {}
1222        }
1223    }
1224
1225    let compaction = path
1226        .iter()
1227        .rposition(|entry| matches!(entry, FileEntry::Compaction(_)));
1228    let context: Vec<&'a FileEntry> = match compaction {
1229        None => path.to_vec(),
1230        Some(index) => {
1231            let FileEntry::Compaction(entry) = path[index] else {
1232                unreachable!("position matched a compaction")
1233            };
1234            let first_kept = entry.first_kept_entry_id.clone().flatten();
1235            let mut context = vec![path[index]];
1236            let mut found = false;
1237            for candidate in &path[..index] {
1238                if candidate.meta().map(|meta| &meta.id) == first_kept.as_ref() {
1239                    found = true;
1240                }
1241                let is_system = matches!(candidate, FileEntry::Message(m) if matches!(m.message, Message::System(_)));
1242                if found && !is_system {
1243                    context.push(candidate);
1244                }
1245            }
1246            context.extend(&path[index + 1..]);
1247            context
1248        }
1249    };
1250
1251    let mut edits: HashMap<&str, Option<&yapi_types::session::Replacement>> = HashMap::new();
1252    for entry in &context {
1253        if let FileEntry::ContextEdit(edit) = entry {
1254            edits.insert(&edit.target_id, edit.replacement.as_ref());
1255        }
1256    }
1257    let entries: Vec<ProjectedEntry<'a>> = context
1258        .iter()
1259        .enumerate()
1260        .map(|(index, entry)| {
1261            // Only the newest compaction, first in the context, contributes.
1262            let messages = if matches!(entry, FileEntry::Compaction(_)) && index > 0 {
1263                Vec::new()
1264            } else {
1265                let messages = entry_messages(entry);
1266                match entry.meta().and_then(|meta| edits.get(meta.id.as_str())) {
1267                    Some(replacement) => edited(messages, *replacement),
1268                    None => messages,
1269                }
1270            };
1271            ProjectedEntry {
1272                source: entry,
1273                messages,
1274            }
1275        })
1276        .collect();
1277    Projection {
1278        messages: entries
1279            .iter()
1280            .flat_map(|entry| entry.messages.iter().cloned())
1281            .collect(),
1282        entries,
1283        thinking_level,
1284        model,
1285    }
1286}
1287
1288/// The model context of a branch path (see [`build_projection`]).
1289pub fn build_context(path: &[&FileEntry]) -> SessionContext {
1290    let projection = build_projection(path);
1291    SessionContext {
1292        messages: projection.messages,
1293        thinking_level: projection.thinking_level,
1294        model: projection.model,
1295    }
1296}
1297
1298/// Whether a session id is valid: alphanumeric at both ends, with `.`, `_` and
1299/// `-` inside.
1300pub fn validate_session_id(id: &str) -> Result<()> {
1301    let inner = |c: char| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-');
1302    let valid = id.starts_with(|c: char| c.is_ascii_alphanumeric())
1303        && id.ends_with(|c: char| c.is_ascii_alphanumeric())
1304        && id.chars().all(inner);
1305    if valid {
1306        Ok(())
1307    } else {
1308        Err(SessionError::Rejected(
1309            "Session id must be non-empty, contain only alphanumeric characters, '-', '_', and '.', and start and end with an alphanumeric character".into(),
1310        ))
1311    }
1312}
1313
1314fn header(path: &Path) -> Option<Value> {
1315    let file = fs::File::open(path).ok()?;
1316    let mut line = String::new();
1317    std::io::BufRead::read_line(&mut std::io::BufReader::new(file), &mut line).ok()?;
1318    let header: Value = yapi_types::json::parse(&line).ok()?;
1319    (header["type"] == "session").then_some(header)
1320}
1321
1322/// Whether a session recorded with working directory `recorded` belongs to
1323/// `cwd`.
1324fn cwd_matches(recorded: &str, cwd: &Path) -> bool {
1325    !recorded.is_empty() && crate::tools::path::resolve_lexically(cwd, Path::new(recorded)) == cwd
1326}
1327
1328/// Writes `text` to a new file at `path`, failing when one exists. A failed
1329/// write removes the file again, so the write can be retried.
1330fn write_new(path: &Path, text: &str) -> Result<()> {
1331    let mut handle = OpenOptions::new()
1332        .write(true)
1333        .create_new(true)
1334        .open(path)
1335        .map_err(io(path))?;
1336    handle.write_all(text.as_bytes()).map_err(|source| {
1337        let _ = fs::remove_file(path);
1338        io(path)(source)
1339    })
1340}
1341
1342/// Appends `text` to the file at `path`. A failed write is cut off again, so
1343/// the next one does not continue a partial line.
1344fn append_text(path: &Path, text: &str) -> Result<()> {
1345    let mut handle = OpenOptions::new()
1346        .append(true)
1347        .open(path)
1348        .map_err(io(path))?;
1349    let len = handle.metadata().map_err(io(path))?.len();
1350    handle.write_all(text.as_bytes()).map_err(|source| {
1351        let _ = handle.set_len(len);
1352        io(path)(source)
1353    })
1354}
1355
1356fn session_paths(dir: &Path) -> Vec<PathBuf> {
1357    let Ok(read) = fs::read_dir(dir) else {
1358        return Vec::new();
1359    };
1360    read.flatten()
1361        .map(|item| item.path())
1362        .filter(|path| path.extension().is_some_and(|ext| ext == "jsonl"))
1363        .collect()
1364}
1365
1366fn modified_ms(path: &Path) -> u64 {
1367    fs::metadata(path)
1368        .and_then(|meta| meta.modified())
1369        .ok()
1370        .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
1371        .map_or(0, |elapsed| elapsed.as_millis() as u64)
1372}
1373
1374/// The most recently modified session file in `dir`, from `cwd` when given.
1375pub fn find_most_recent(dir: &Path, cwd: Option<&Path>) -> Option<PathBuf> {
1376    let mut files: Vec<(u64, PathBuf)> = session_paths(dir)
1377        .into_iter()
1378        .map(|path| (modified_ms(&path), path))
1379        .collect();
1380    files.sort_by_key(|(modified, _)| std::cmp::Reverse(*modified));
1381    files.into_iter().map(|(_, path)| path).find(|path| {
1382        header(path).is_some_and(|header| {
1383            cwd.is_none_or(|cwd| cwd_matches(header["cwd"].as_str().unwrap_or_default(), cwd))
1384        })
1385    })
1386}
1387
1388/// The session file in `dir` with exactly this id, from `cwd` when given.
1389pub fn find_by_id(dir: &Path, id: &str, cwd: Option<&Path>) -> Option<PathBuf> {
1390    session_paths(dir).into_iter().find(|path| {
1391        header(path).is_some_and(|header| {
1392            header["id"] == id
1393                && cwd
1394                    .is_none_or(|cwd| cwd_matches(header["cwd"].as_str().unwrap_or_default(), cwd))
1395        })
1396    })
1397}
1398
1399fn summary(path: &Path) -> Option<SessionSummary> {
1400    // Listing never writes: only opening a session repairs it.
1401    let docs = load_docs(path, false).ok()?;
1402    let header = docs.first().filter(|header| header["type"] == "session")?;
1403    let mut summary = SessionSummary {
1404        path: path.to_path_buf(),
1405        id: header["id"].as_str().unwrap_or_default().to_owned(),
1406        cwd: header["cwd"].as_str().unwrap_or_default().to_owned(),
1407        name: None,
1408        first_message: String::new(),
1409        all_messages_text: String::new(),
1410        parent_session: header["parentSession"].as_str().map(str::to_owned),
1411        message_count: 0,
1412        modified_ms: 0,
1413    };
1414    let mut last_activity: Option<u64> = None;
1415    let mut all_messages: Vec<String> = Vec::new();
1416    for doc in &docs[1..] {
1417        match doc["type"].as_str() {
1418            Some("session_info") => {
1419                summary.name = doc["name"]
1420                    .as_str()
1421                    .map(str::trim)
1422                    .filter(|name| !name.is_empty())
1423                    .map(str::to_owned);
1424            }
1425            Some("message") => {
1426                summary.message_count += 1;
1427                let message = &doc["message"];
1428                if !matches!(message["role"].as_str(), Some("user" | "assistant"))
1429                    || message.get("content").is_none()
1430                {
1431                    continue;
1432                }
1433                let activity = message["timestamp"]
1434                    .as_f64()
1435                    .map(|ms| ms as u64)
1436                    .or_else(|| doc["timestamp"].as_str().and_then(parse_iso));
1437                if let Some(activity) = activity {
1438                    last_activity = Some(last_activity.unwrap_or(0).max(activity));
1439                }
1440                let text = match &message["content"] {
1441                    Value::String(text) => text.clone(),
1442                    Value::Array(blocks) => blocks
1443                        .iter()
1444                        .filter(|block| block["type"] == "text")
1445                        .filter_map(|block| block["text"].as_str())
1446                        .collect::<Vec<_>>()
1447                        .join(" "),
1448                    _ => String::new(),
1449                };
1450                if text.is_empty() {
1451                    continue;
1452                }
1453                if summary.first_message.is_empty() && message["role"] == "user" {
1454                    summary.first_message = text.clone();
1455                }
1456                all_messages.push(text);
1457            }
1458            _ => {}
1459        }
1460    }
1461    if summary.first_message.is_empty() {
1462        summary.first_message = "(no messages)".into();
1463    }
1464    summary.all_messages_text = all_messages.join(" ");
1465    summary.modified_ms = match last_activity.filter(|time| *time > 0) {
1466        Some(time) => time,
1467        None => header["timestamp"]
1468            .as_str()
1469            .and_then(parse_iso)
1470            .unwrap_or_else(|| modified_ms(path)),
1471    };
1472    Some(summary)
1473}
1474
1475fn sorted(mut sessions: Vec<SessionSummary>) -> Vec<SessionSummary> {
1476    sessions.sort_by_key(|summary| std::cmp::Reverse(summary.modified_ms));
1477    sessions
1478}
1479
1480/// Sessions in `dir`, latest activity first, only those from `cwd` when given.
1481pub fn list(dir: &Path, cwd: Option<&Path>) -> Vec<SessionSummary> {
1482    sorted(
1483        session_paths(dir)
1484            .iter()
1485            .filter_map(|path| summary(path))
1486            .filter(|summary| cwd.is_none_or(|cwd| cwd_matches(&summary.cwd, cwd)))
1487            .collect(),
1488    )
1489}
1490
1491/// Sessions of every project: the session files in each directory under `root`,
1492/// latest activity first.
1493pub fn list_all(root: &Path) -> Vec<SessionSummary> {
1494    let Ok(read) = fs::read_dir(root) else {
1495        return Vec::new();
1496    };
1497    sorted(
1498        read.flatten()
1499            .map(|item| item.path())
1500            .filter(|path| path.is_dir())
1501            .flat_map(|dir| session_paths(&dir))
1502            .filter_map(|path| summary(&path))
1503            .collect(),
1504    )
1505}