Skip to main content

oino_session/manager/
mod.rs

1//! [`SessionManager`]: append-only session tree persistence (session format v3, D20).
2//!
3//! `session_dir` arguments are always the **sessions root** (e.g. `~/.oino/sessions`); new
4//! files go to `<root>/--<cwd>--/<ts>_<id>.jsonl` (session-v3 §6.1).
5
6mod files;
7mod lease;
8mod list;
9mod repair;
10mod tree;
11
12use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14use std::sync::atomic::AtomicBool;
15
16use oino_ai::{
17    AgentMessage, JsonValue, SystemMessage, TextContent, Usage, UserContent, UserContentField,
18};
19use uuid::Uuid;
20
21pub use files::{
22    LEGACY_DIR_NAME, MigratedSession, cwd_session_dir, default_agent_dir, default_sessions_root,
23    encode_cwd, migrate_legacy_file, resolve_path, session_file_name, validate_session_id,
24};
25pub use lease::{
26    LeaseInfo, SessionLease, TakeoverRequest, clear_takeover_request, lock_file_path,
27    lock_metadata_path, read_takeover_request, takeover_path, write_takeover_request,
28};
29pub use list::{ProgressSink, SessionInfo, session_info};
30pub use repair::{INTERRUPTED_TOOL_TEXT, RESUME_REPAIR_TYPE, RepairReport, TOOL_DISPATCH_TYPE};
31pub use tree::SessionTreeNode;
32
33use crate::context::{
34    EntryIndex, SessionContext, SessionProjection, build_context_entries, build_session_context,
35    build_session_projection, current_system_message, walk_up,
36};
37use crate::entry::{
38    BranchSummaryEntry, CURRENT_SESSION_VERSION, CompactionEntry, ContextReplacement,
39    EditableContent, EntryId, EntryKind, IsoTimestamp, SessionEntry, SessionHeader,
40};
41use crate::error::SessionError;
42use crate::migrate::parse_session_text;
43
44/// Options for new sessions.
45#[derive(Clone, Debug, Default, PartialEq, Eq)]
46pub struct CreateOptions {
47    /// Explicit session id (validated); default `UUIDv7`.
48    pub id: Option<String>,
49    /// Source session file recorded as `parentSession`.
50    pub parent_session: Option<PathBuf>,
51}
52
53/// Input of [`SessionManager::append_compaction`].
54#[derive(Clone, Debug, Default, PartialEq)]
55pub struct CompactionDraft {
56    /// Summary text.
57    pub summary: String,
58    /// First kept entry; `None` ⇒ the compaction's own id (retain none).
59    pub first_kept_entry_id: Option<EntryId>,
60    /// Context tokens before compaction.
61    pub tokens_before: u64,
62    /// Implementation data.
63    pub details: Option<JsonValue>,
64    /// Produced by an extension.
65    pub from_hook: Option<bool>,
66    /// Usage of the summary request.
67    pub usage: Option<Usage>,
68    /// Checkpoint; `None` ⇒ folded from the current projection.
69    pub system_message: Option<SystemMessage>,
70}
71
72/// Input of [`SessionManager::branch_with_summary`].
73#[derive(Clone, Debug, PartialEq)]
74pub struct BranchSummaryDraft {
75    /// Previous leaf whose path was summarised.
76    pub from_id: EntryId,
77    /// Summary text.
78    pub summary: String,
79    /// File tracking or extension data.
80    pub details: Option<JsonValue>,
81    /// Produced by an extension.
82    pub from_hook: Option<bool>,
83    /// Usage of the summary request.
84    pub usage: Option<Usage>,
85}
86
87/// Read-only view handed to extensions (extension-api §5.2).
88pub trait ReadonlySession {
89    /// Session cwd.
90    fn cwd(&self) -> &Path;
91    /// Session id.
92    fn session_id(&self) -> &str;
93    /// Session file, when persisted.
94    fn session_file(&self) -> Option<&Path>;
95    /// Header.
96    fn header(&self) -> &SessionHeader;
97    /// Latest session name.
98    fn session_name(&self) -> Option<String>;
99    /// Current leaf.
100    fn leaf_id(&self) -> Option<&EntryId>;
101    /// Entry by id.
102    fn entry(&self, id: &EntryId) -> Option<&SessionEntry>;
103    /// Children in file order.
104    fn children(&self, parent: Option<&EntryId>) -> Vec<&SessionEntry>;
105    /// Current label of an entry.
106    fn label(&self, id: &EntryId) -> Option<&str>;
107    /// Root → `from` (default leaf).
108    fn branch(&self, from: Option<&EntryId>) -> Vec<&SessionEntry>;
109    /// All entries in file order.
110    fn entries(&self) -> &[SessionEntry];
111    /// Tree view.
112    fn tree(&self) -> Vec<SessionTreeNode>;
113    /// Context-selected entries at the leaf.
114    fn build_context_entries(&self) -> Vec<SessionEntry>;
115    /// Projection at the leaf.
116    fn build_session_projection(&self) -> SessionProjection;
117    /// Context at the leaf.
118    fn build_session_context(&self) -> SessionContext;
119}
120
121/// Held append handle of a session file (D-WRITER): one `O_APPEND` open per file, reused
122/// for every appended line and dropped whenever the file identity changes.
123#[derive(Debug)]
124struct SessionWriter {
125    file: std::fs::File,
126    path: PathBuf,
127}
128
129/// Session tree with append-only JSONL persistence.
130#[allow(
131    clippy::struct_excessive_bools,
132    reason = "flat bookkeeping flags; a state machine would obscure the format-parity fields"
133)]
134#[derive(Debug)]
135pub struct SessionManager {
136    cwd: PathBuf,
137    session_dir: PathBuf,
138    persist: bool,
139    session_file: Option<PathBuf>,
140    flushed: bool,
141    has_conversation: bool,
142    header: SessionHeader,
143    index: EntryIndex,
144    labels: HashMap<EntryId, (String, IsoTimestamp)>,
145    leaf: Option<EntryId>,
146    writer: Option<SessionWriter>,
147    dir_synced: bool,
148}
149
150impl Clone for SessionManager {
151    fn clone(&self) -> Self {
152        Self {
153            cwd: self.cwd.clone(),
154            session_dir: self.session_dir.clone(),
155            persist: self.persist,
156            session_file: self.session_file.clone(),
157            flushed: self.flushed,
158            has_conversation: self.has_conversation,
159            header: self.header.clone(),
160            index: self.index.clone(),
161            labels: self.labels.clone(),
162            leaf: self.leaf.clone(),
163            // The append handle is per instance: a clone reopens lazily at its first append.
164            writer: None,
165            dir_synced: self.dir_synced,
166        }
167    }
168}
169
170fn new_header(cwd: &Path, opts: &CreateOptions) -> Result<SessionHeader, SessionError> {
171    if let Some(id) = &opts.id {
172        validate_session_id(id)?;
173    }
174    Ok(header_with(
175        cwd,
176        opts.id.clone(),
177        opts.parent_session.as_deref(),
178    ))
179}
180
181fn header_with(cwd: &Path, id: Option<String>, parent_session: Option<&Path>) -> SessionHeader {
182    SessionHeader {
183        version: CURRENT_SESSION_VERSION,
184        id: id.unwrap_or_else(|| Uuid::now_v7().hyphenated().to_string()),
185        timestamp: IsoTimestamp::now(),
186        cwd: cwd.to_string_lossy().into_owned(),
187        parent_session: parent_session.map(|path| path.to_string_lossy().into_owned()),
188    }
189}
190
191fn is_conversation(entry: &SessionEntry) -> bool {
192    matches!(
193        entry.message(),
194        Some(AgentMessage::User(_) | AgentMessage::Assistant(_))
195    )
196}
197
198fn sanitize_name(name: &str) -> String {
199    let mut out = String::with_capacity(name.len());
200    let mut in_break = false;
201    for ch in name.chars() {
202        if matches!(ch, '\r' | '\n') {
203            if !in_break {
204                out.push(' ');
205            }
206            in_break = true;
207        } else {
208            out.push(ch);
209            in_break = false;
210        }
211    }
212    out.trim().to_owned()
213}
214
215impl SessionManager {
216    fn blank(cwd: &Path, session_dir: &Path, persist: bool, header: SessionHeader) -> Self {
217        Self {
218            cwd: resolve_path(cwd),
219            session_dir: session_dir.to_path_buf(),
220            persist,
221            session_file: None,
222            flushed: false,
223            has_conversation: false,
224            header,
225            index: EntryIndex::default(),
226            labels: HashMap::new(),
227            leaf: None,
228            writer: None,
229            dir_synced: false,
230        }
231    }
232
233    /// New persisted session for `cwd` (file created on the first user/assistant message).
234    ///
235    /// # Errors
236    ///
237    /// [`SessionError::InvalidSessionId`] for a bad explicit id.
238    pub fn create(
239        cwd: &Path,
240        session_dir: &Path,
241        opts: CreateOptions,
242    ) -> Result<Self, SessionError> {
243        let cwd = resolve_path(cwd);
244        let mut manager = Self::blank(&cwd, session_dir, true, new_header(&cwd, &opts)?);
245        manager.new_session(opts)?;
246        Ok(manager)
247    }
248
249    /// Opens a session file: migrates v1/v2 in place and pre-v1 Oino files to
250    /// the v3 layout; a missing file starts a new session at that path.
251    ///
252    /// # Errors
253    ///
254    /// I/O, [`SessionError::UnsupportedVersion`], [`SessionError::MissingHeader`] (non-empty
255    /// file without header), [`SessionError::MissingCwd`].
256    pub fn open(
257        path: &Path,
258        session_dir: &Path,
259        cwd_override: Option<&Path>,
260    ) -> Result<Self, SessionError> {
261        let path = Self::migrate_if_legacy(&resolve_path(path), session_dir)?;
262        let cwd = match cwd_override {
263            Some(cwd) => resolve_path(cwd),
264            None if path.exists() => match files::read_header(&path) {
265                Some(header) if header.cwd.is_empty() => {
266                    return Err(SessionError::MissingCwd(path));
267                }
268                Some(header) => PathBuf::from(header.cwd),
269                None => std::env::current_dir().map_err(|e| SessionError::io(&path, e))?,
270            },
271            None => std::env::current_dir().map_err(|e| SessionError::io(&path, e))?,
272        };
273        let mut manager = Self::blank(
274            &cwd,
275            session_dir,
276            true,
277            new_header(&cwd, &CreateOptions::default())?,
278        );
279        manager.set_session_file(&path)?;
280        Ok(manager)
281    }
282
283    fn migrate_if_legacy(path: &Path, session_dir: &Path) -> Result<PathBuf, SessionError> {
284        if !path.is_file() || !files::is_legacy_file(path) {
285            return Ok(path.to_path_buf());
286        }
287        let root = path.parent().unwrap_or(session_dir);
288        let migrated = migrate_legacy_file(path, root)?;
289        Ok(migrated.path.unwrap_or(migrated.legacy_path))
290    }
291
292    /// Opens the newest session of `cwd`, else creates one.
293    ///
294    /// # Errors
295    ///
296    /// As [`SessionManager::open`] / [`SessionManager::create`].
297    pub fn continue_recent(cwd: &Path, session_dir: &Path) -> Result<Self, SessionError> {
298        match Self::find_most_recent(cwd, session_dir)? {
299            Some(path) => Self::open(&path, session_dir, Some(cwd)),
300            None => Self::create(cwd, session_dir, CreateOptions::default()),
301        }
302    }
303
304    /// In-memory session (never written). `entries` are appended after a fresh header; an
305    /// invalid explicit id is replaced by a generated one.
306    #[must_use]
307    pub fn in_memory(cwd: &Path, opts: CreateOptions, entries: Vec<SessionEntry>) -> Self {
308        let cwd = resolve_path(cwd);
309        let id = opts.id.filter(|id| validate_session_id(id).is_ok());
310        let header = header_with(&cwd, id, opts.parent_session.as_deref());
311        let mut manager = Self::blank(&cwd, Path::new(""), false, header);
312        manager.load_entries(entries);
313        manager
314    }
315
316    /// Copies all entries of `source` into a new session for `target_cwd` with
317    /// `parentSession = source`. Written immediately.
318    ///
319    /// # Errors
320    ///
321    /// I/O, invalid source ([`SessionError::MissingHeader`]), invalid id.
322    pub fn fork_from(
323        source: &Path,
324        target_cwd: &Path,
325        session_dir: &Path,
326        opts: CreateOptions,
327    ) -> Result<Self, SessionError> {
328        let source = Self::migrate_if_legacy(&resolve_path(source), session_dir)?;
329        let parsed = parse_session_text(&files::read_to_string(&source)?)?
330            .ok_or_else(|| SessionError::MissingHeader(source.clone()))?;
331        let target_cwd = resolve_path(target_cwd);
332        let header = new_header(
333            &target_cwd,
334            &CreateOptions {
335                parent_session: Some(source),
336                ..opts
337            },
338        )?;
339        let path = cwd_session_dir(session_dir, &target_cwd).join(session_file_name(&header));
340        files::write_new(&path, &files::serialize_lines(&header, &parsed.entries)?)?;
341        Self::open(&path, session_dir, Some(&target_cwd))
342    }
343
344    /// Finds a session by id: exact header id, then a unique prefix (≥ 4 chars) in the cwd directory, then over all directories.
345    ///
346    /// # Errors
347    ///
348    /// [`SessionError::AmbiguousId`].
349    pub fn find_by_id(
350        cwd: &Path,
351        id: &str,
352        session_dir: &Path,
353    ) -> Result<Option<PathBuf>, SessionError> {
354        list::find_by_id(cwd, id, session_dir)
355    }
356
357    /// Sessions of `cwd`, newest modified first.
358    ///
359    /// # Errors
360    ///
361    /// [`SessionError::Cancelled`].
362    pub fn list(
363        cwd: &Path,
364        session_dir: &Path,
365        progress: Option<&dyn ProgressSink>,
366        cancel: Option<&AtomicBool>,
367    ) -> Result<Vec<SessionInfo>, SessionError> {
368        list::list(cwd, session_dir, progress, cancel)
369    }
370
371    /// Sessions of all cwds, newest modified first.
372    ///
373    /// # Errors
374    ///
375    /// [`SessionError::Cancelled`].
376    pub fn list_all(
377        session_dir: &Path,
378        progress: Option<&dyn ProgressSink>,
379        cancel: Option<&AtomicBool>,
380    ) -> Result<Vec<SessionInfo>, SessionError> {
381        list::list_all(session_dir, progress, cancel)
382    }
383
384    /// Newest valid session file of `cwd`.
385    ///
386    /// # Errors
387    ///
388    /// Never fails today; `Result` keeps room for I/O reporting.
389    pub fn find_most_recent(
390        cwd: &Path,
391        session_dir: &Path,
392    ) -> Result<Option<PathBuf>, SessionError> {
393        Ok(list::find_most_recent(cwd, session_dir))
394    }
395
396    /// Migrates every pre-v1 `*.jsonl` directly in `sessions_root` (D21).
397    ///
398    /// # Errors
399    ///
400    /// The first I/O error; files migrated before it stay migrated.
401    pub fn migrate_legacy_dir(sessions_root: &Path) -> Result<Vec<MigratedSession>, SessionError> {
402        let mut migrated = Vec::new();
403        let mut candidates = list::jsonl_files(sessions_root);
404        candidates.sort();
405        for path in candidates {
406            if files::is_legacy_file(&path) {
407                migrated.push(migrate_legacy_file(&path, sessions_root)?);
408            }
409        }
410        Ok(migrated)
411    }
412
413    /// Fresh header and empty tree.
414    ///
415    /// # Errors
416    ///
417    /// [`SessionError::InvalidSessionId`].
418    #[allow(
419        clippy::needless_pass_by_value,
420        reason = "contract signature (session-v3 §6.2) mirrors newSession(options)"
421    )]
422    pub fn new_session(&mut self, opts: CreateOptions) -> Result<(), SessionError> {
423        self.header = new_header(&self.cwd, &opts)?;
424        self.index = EntryIndex::default();
425        self.labels.clear();
426        self.leaf = None;
427        self.flushed = false;
428        self.has_conversation = false;
429        self.writer = None;
430        self.dir_synced = false;
431        if self.persist {
432            self.session_file = Some(
433                cwd_session_dir(&self.session_dir, &self.cwd).join(session_file_name(&self.header)),
434            );
435        }
436        Ok(())
437    }
438
439    /// Binds to `path`: loads it when it exists, otherwise starts a new
440    /// session that will be written there.
441    ///
442    /// # Errors
443    ///
444    /// As [`SessionManager::open`].
445    pub fn set_session_file(&mut self, path: &Path) -> Result<(), SessionError> {
446        self.writer = None;
447        self.dir_synced = false;
448        let path = Self::migrate_if_legacy(&resolve_path(path), &self.session_dir)?;
449        if !path.exists() {
450            self.new_session(CreateOptions::default())?;
451            self.session_file = Some(path);
452            return Ok(());
453        }
454        let text = files::read_to_string(&path)?;
455        match parse_session_text(&text)? {
456            None if !text.is_empty() => Err(SessionError::MissingHeader(path)),
457            None => {
458                self.new_session(CreateOptions::default())?;
459                self.session_file = Some(path.clone());
460                files::write_atomic(&path, &files::serialize_lines(&self.header, &[])?)?;
461                self.flushed = true;
462                Ok(())
463            }
464            Some(parsed) => {
465                self.header = parsed.header;
466                self.session_file = Some(path.clone());
467                self.load_entries(parsed.entries);
468                self.flushed = true;
469                if parsed.migrated {
470                    files::write_atomic(
471                        &path,
472                        &files::serialize_lines(&self.header, self.index.entries())?,
473                    )?;
474                }
475                Ok(())
476            }
477        }
478    }
479
480    fn load_entries(&mut self, entries: Vec<SessionEntry>) {
481        self.index = EntryIndex::new(entries);
482        self.labels.clear();
483        self.leaf = None;
484        self.has_conversation = false;
485        let mut labels = HashMap::new();
486        for entry in self.index.entries() {
487            self.leaf = Some(entry.id.clone());
488            self.has_conversation |= is_conversation(entry);
489            if let EntryKind::Label { target_id, label } = &entry.kind {
490                match label.as_deref().filter(|l| !l.is_empty()) {
491                    Some(label) => {
492                        labels.insert(
493                            target_id.clone(),
494                            (label.to_owned(), entry.timestamp.clone()),
495                        );
496                    }
497                    None => {
498                        labels.remove(target_id);
499                    }
500                }
501            }
502        }
503        self.labels = labels;
504    }
505
506    /// Whether this manager writes to disk (`isPersisted`: `false` for in-memory).
507    #[must_use]
508    pub const fn is_persisted(&self) -> bool {
509        self.persist
510    }
511
512    /// Whether the file has been created (header + buffered entries written).
513    #[must_use]
514    pub const fn is_flushed(&self) -> bool {
515        self.flushed
516    }
517
518    /// Session cwd.
519    #[must_use]
520    pub fn cwd(&self) -> &Path {
521        &self.cwd
522    }
523
524    /// Sessions root.
525    #[must_use]
526    pub fn session_dir(&self) -> &Path {
527        &self.session_dir
528    }
529
530    /// Whether the sessions root is `<default agent dir>/sessions`.
531    #[must_use]
532    pub fn uses_default_session_dir(&self) -> bool {
533        default_sessions_root()
534            .is_some_and(|root| resolve_path(&root) == resolve_path(&self.session_dir))
535    }
536
537    /// Session id.
538    #[must_use]
539    pub fn session_id(&self) -> &str {
540        &self.header.id
541    }
542
543    /// Session file path (planned path before the first flush).
544    #[must_use]
545    pub fn session_file(&self) -> Option<&Path> {
546        self.session_file.as_deref()
547    }
548
549    /// Header.
550    #[must_use]
551    pub const fn header(&self) -> &SessionHeader {
552        &self.header
553    }
554
555    /// Number of distinct entry ids.
556    #[must_use]
557    pub fn entry_count(&self) -> usize {
558        self.index.id_count()
559    }
560
561    /// The pure entry index (for the free functions in [`crate::context`]).
562    #[must_use]
563    pub const fn index(&self) -> &EntryIndex {
564        &self.index
565    }
566
567    fn push(
568        &mut self,
569        kind: EntryKind,
570        id: Option<EntryId>,
571        parent: Option<EntryId>,
572        timestamp: Option<IsoTimestamp>,
573    ) -> Result<EntryId, SessionError> {
574        let id = id.unwrap_or_else(|| self.generate_id());
575        let entry = SessionEntry {
576            id: id.clone(),
577            parent_id: parent,
578            timestamp: timestamp.unwrap_or_else(IsoTimestamp::now),
579            kind,
580        };
581        self.has_conversation |= is_conversation(&entry);
582        let line = serde_json::to_string(&entry).map_err(|source| SessionError::Json {
583            path: self.session_file.clone().unwrap_or_default(),
584            line: 0,
585            source,
586        })?;
587        self.index.push(entry);
588        self.leaf = Some(id.clone());
589        self.persist_line(&line)?;
590        Ok(id)
591    }
592
593    fn generate_id(&self) -> EntryId {
594        EntryId::generate(|candidate| self.index.contains_str(candidate))
595    }
596
597    fn persist_line(&mut self, line: &str) -> Result<(), SessionError> {
598        let Some(path) = self.session_file.clone().filter(|_| self.persist) else {
599            return Ok(());
600        };
601        if self.flushed {
602            return self.write_appended(&path, line);
603        }
604        if !self.has_conversation {
605            return Ok(());
606        }
607        self.flush_initial(&path)
608    }
609
610    /// First flush: header + buffered entries via `write_new`, then `sync()` so the file and
611    /// its directory entry are durable before the caller proceeds (D-WRITER).
612    fn flush_initial(&mut self, path: &Path) -> Result<(), SessionError> {
613        files::write_new(
614            path,
615            &files::serialize_lines(&self.header, self.index.entries())?,
616        )?;
617        self.flushed = true;
618        self.writer = None;
619        self.dir_synced = false;
620        self.sync()
621    }
622
623    /// Appends `line` on the held `O_APPEND` handle, opening it for `path` first (D-WRITER:
624    /// one open handle per session file, reused for every line).
625    fn write_appended(&mut self, path: &Path, line: &str) -> Result<(), SessionError> {
626        self.ensure_writer(path)?;
627        self.with_writer(path, |file| files::write_line(file, path, line))
628    }
629
630    /// Opens the append handle for `path` unless the held one already matches.
631    fn ensure_writer(&mut self, path: &Path) -> Result<(), SessionError> {
632        if self
633            .writer
634            .as_ref()
635            .is_none_or(|writer| writer.path != path)
636        {
637            self.writer = Some(SessionWriter {
638                file: files::open_append(path)?,
639                path: path.to_path_buf(),
640            });
641        }
642        Ok(())
643    }
644
645    /// Runs `f` on the append handle of `path`; fail-closed if the handle is somehow absent.
646    fn with_writer(
647        &mut self,
648        path: &Path,
649        f: impl FnOnce(&mut std::fs::File) -> Result<(), SessionError>,
650    ) -> Result<(), SessionError> {
651        match self.writer.as_mut().filter(|writer| writer.path == path) {
652            Some(writer) => f(&mut writer.file),
653            None => Err(SessionError::io(
654                path,
655                std::io::Error::other("session append writer handle missing"),
656            )),
657        }
658    }
659
660    /// Flush + fsync the session file. Also fsyncs the parent directory until the current
661    /// file's creation is known durable (POSIX). Fail-closed durability barrier (D-FLUSH):
662    /// callers treat [`SessionError::Io`] as "do not proceed".
663    ///
664    /// A session whose first flush never succeeded (for example an unwritable directory)
665    /// retries it here, so the error surfaces at the barrier instead of being silently
666    /// retried forever. In-memory sessions and sessions with nothing written yet succeed
667    /// without touching the file system.
668    ///
669    /// # Errors
670    ///
671    /// [`SessionError::Io`] when the file cannot be opened for append, fsynced, or its
672    /// first flush retried.
673    pub fn sync(&mut self) -> Result<(), SessionError> {
674        let Some(path) = self.session_file.clone().filter(|_| self.persist) else {
675            return Ok(());
676        };
677        if !self.flushed {
678            if !self.has_conversation {
679                return Ok(());
680            }
681            return self.flush_initial(&path);
682        }
683        self.ensure_writer(&path)?;
684        self.with_writer(&path, |file| {
685            file.sync_all().map_err(|e| SessionError::io(&path, e))
686        })?;
687        if !self.dir_synced {
688            files::sync_dir(&path)?;
689            self.dir_synced = true;
690        }
691        Ok(())
692    }
693
694    /// Generic append at the leaf (used by boundary drafts).
695    ///
696    /// # Errors
697    ///
698    /// I/O when persisting.
699    pub fn append_entry(&mut self, kind: EntryKind) -> Result<EntryId, SessionError> {
700        let parent = self.leaf.clone();
701        self.push(kind, None, parent, None)
702    }
703
704    /// Appends a message; summary roles are rejected (use compaction/branch entries).
705    ///
706    /// # Errors
707    ///
708    /// [`SessionError::InvalidAppend`], I/O.
709    pub fn append_message(&mut self, message: AgentMessage) -> Result<EntryId, SessionError> {
710        if matches!(
711            message,
712            AgentMessage::BranchSummary(_) | AgentMessage::CompactionSummary(_)
713        ) {
714            return Err(SessionError::InvalidAppend(
715                "summary messages are stored as branch_summary/compaction entries",
716            ));
717        }
718        self.append_entry(EntryKind::Message { message })
719    }
720
721    /// Appends a `thinking_level_change`.
722    ///
723    /// # Errors
724    ///
725    /// I/O.
726    pub fn append_thinking_level_change(&mut self, level: &str) -> Result<EntryId, SessionError> {
727        self.append_entry(EntryKind::ThinkingLevelChange {
728            thinking_level: level.to_owned(),
729        })
730    }
731
732    /// Appends a `model_change`.
733    ///
734    /// # Errors
735    ///
736    /// I/O.
737    pub fn append_model_change(
738        &mut self,
739        provider: &str,
740        model_id: &str,
741    ) -> Result<EntryId, SessionError> {
742        self.append_entry(EntryKind::ModelChange {
743            provider: provider.to_owned(),
744            model_id: model_id.to_owned(),
745        })
746    }
747
748    /// Appends a `usage` entry (empty note omitted).
749    ///
750    /// # Errors
751    ///
752    /// I/O.
753    pub fn append_usage(
754        &mut self,
755        kind: &str,
756        provider: &str,
757        model: &str,
758        usage: Usage,
759        note: Option<String>,
760    ) -> Result<EntryId, SessionError> {
761        self.append_entry(EntryKind::Usage {
762            kind: kind.to_owned(),
763            provider: provider.to_owned(),
764            model: model.to_owned(),
765            usage,
766            note: note.filter(|n| !n.is_empty()),
767        })
768    }
769
770    /// Appends a compaction. Without an explicit checkpoint the current projection's system
771    /// state is stored, stamped with the entry time.
772    ///
773    /// # Errors
774    ///
775    /// I/O.
776    pub fn append_compaction(&mut self, draft: CompactionDraft) -> Result<EntryId, SessionError> {
777        let id = self.generate_id();
778        let now = IsoTimestamp::now();
779        let system_message = draft.system_message.or_else(|| {
780            current_system_message(&self.build_session_projection().messages).map(|mut system| {
781                system.timestamp = now.to_ms().unwrap_or(0);
782                system
783            })
784        });
785        let kind = EntryKind::Compaction(CompactionEntry {
786            summary: draft.summary,
787            first_kept_entry_id: draft.first_kept_entry_id.unwrap_or_else(|| id.clone()),
788            tokens_before: draft.tokens_before,
789            details: draft.details,
790            usage: draft.usage,
791            from_hook: draft.from_hook,
792            system_message,
793        });
794        let parent = self.leaf.clone();
795        self.push(kind, Some(id), parent, Some(now))
796    }
797
798    /// Appends a `custom` entry (extension state).
799    ///
800    /// # Errors
801    ///
802    /// I/O.
803    pub fn append_custom_entry(
804        &mut self,
805        custom_type: &str,
806        data: Option<JsonValue>,
807    ) -> Result<EntryId, SessionError> {
808        self.append_entry(EntryKind::Custom {
809            custom_type: custom_type.to_owned(),
810            data,
811        })
812    }
813
814    /// Appends a `session_info` (CR/LF runs → one space, trimmed).
815    ///
816    /// # Errors
817    ///
818    /// I/O.
819    pub fn append_session_info(&mut self, name: Option<&str>) -> Result<EntryId, SessionError> {
820        self.append_entry(EntryKind::SessionInfo {
821            name: name.map(sanitize_name),
822        })
823    }
824
825    /// Appends a `custom_message` (participates in context).
826    ///
827    /// # Errors
828    ///
829    /// I/O.
830    pub fn append_custom_message_entry(
831        &mut self,
832        custom_type: &str,
833        content: UserContentField,
834        display: bool,
835        details: Option<JsonValue>,
836    ) -> Result<EntryId, SessionError> {
837        self.append_entry(EntryKind::CustomMessage {
838            custom_type: custom_type.to_owned(),
839            content,
840            display,
841            details,
842        })
843    }
844
845    /// Appends a `context_edit` of an editable entry on the active branch; string replacements
846    /// of assistant/toolResult targets are normalised to one text block.
847    ///
848    /// # Errors
849    ///
850    /// [`SessionError::UnknownEntry`], [`SessionError::InvalidAppend`], I/O.
851    pub fn append_context_edit(
852        &mut self,
853        target: &EntryId,
854        replacement: Option<EditableContent>,
855    ) -> Result<EntryId, SessionError> {
856        let entry = self
857            .index
858            .get(target)
859            .ok_or_else(|| SessionError::UnknownEntry(target.clone()))?;
860        if !self.branch(None).iter().any(|e| e.id == *target) {
861            return Err(SessionError::InvalidAppend(
862                "context edit target is not on the active branch",
863            ));
864        }
865        let role = match &entry.kind {
866            EntryKind::CustomMessage { .. } => "custom",
867            EntryKind::Message { message } => message.role(),
868            _ => "",
869        };
870        if !matches!(role, "custom" | "user" | "assistant" | "toolResult") {
871            return Err(SessionError::InvalidAppend(
872                "context edit target does not contribute editable model content",
873            ));
874        }
875        let replacement = replacement.map(|content| ContextReplacement {
876            content: match (role, content) {
877                ("assistant", EditableContent::Text(text)) => {
878                    EditableContent::Assistant(vec![oino_ai::AssistantContent::Text(
879                        TextContent::new(text),
880                    )])
881                }
882                ("toolResult", EditableContent::Text(text)) => {
883                    EditableContent::User(vec![UserContent::Text(TextContent::new(text))])
884                }
885                (_, content) => content,
886            },
887        });
888        self.append_entry(EntryKind::ContextEdit {
889            target_id: target.clone(),
890            replacement,
891        })
892    }
893
894    /// Sets (or with `None`/empty clears) the label of `target`.
895    ///
896    /// # Errors
897    ///
898    /// [`SessionError::UnknownEntry`], I/O.
899    pub fn append_label_change(
900        &mut self,
901        target: &EntryId,
902        label: Option<&str>,
903    ) -> Result<EntryId, SessionError> {
904        if !self.index.contains(target) {
905            return Err(SessionError::UnknownEntry(target.clone()));
906        }
907        let label = label.filter(|l| !l.is_empty()).map(str::to_owned);
908        let id = self.append_entry(EntryKind::Label {
909            target_id: target.clone(),
910            label: label.clone(),
911        })?;
912        match label {
913            Some(label) => {
914                let timestamp = self
915                    .index
916                    .get(&id)
917                    .map(|e| e.timestamp.clone())
918                    .unwrap_or_default();
919                self.labels.insert(target.clone(), (label, timestamp));
920            }
921            None => {
922                self.labels.remove(target);
923            }
924        }
925        Ok(id)
926    }
927
928    /// Latest `session_info` name in file order (trimmed; empty ⇒ `None`).
929    #[must_use]
930    pub fn session_name(&self) -> Option<String> {
931        self.index
932            .entries()
933            .iter()
934            .rev()
935            .find_map(|entry| match &entry.kind {
936                EntryKind::SessionInfo { name } => Some(
937                    name.as_deref()
938                        .map(str::trim)
939                        .filter(|n| !n.is_empty())
940                        .map(str::to_owned),
941                ),
942                _ => None,
943            })?
944    }
945
946    /// Current leaf id (`None` after [`SessionManager::reset_leaf`] or when empty).
947    #[must_use]
948    pub const fn leaf_id(&self) -> Option<&EntryId> {
949        self.leaf.as_ref()
950    }
951
952    /// Current leaf entry.
953    #[must_use]
954    pub fn leaf_entry(&self) -> Option<&SessionEntry> {
955        self.leaf.as_ref().and_then(|id| self.index.get(id))
956    }
957
958    /// Entry by id.
959    #[must_use]
960    pub fn entry(&self, id: &EntryId) -> Option<&SessionEntry> {
961        self.index.get(id)
962    }
963
964    /// Entries whose parent is `parent` (`None` ⇒ roots), file order.
965    #[must_use]
966    pub fn children(&self, parent: Option<&EntryId>) -> Vec<&SessionEntry> {
967        self.index
968            .entries()
969            .iter()
970            .filter(|entry| entry.parent_id.as_ref() == parent)
971            .collect()
972    }
973
974    /// Current label of an entry.
975    #[must_use]
976    pub fn label(&self, id: &EntryId) -> Option<&str> {
977        self.labels.get(id).map(|(label, _)| label.as_str())
978    }
979
980    /// Root → `from` (default leaf), all entry types; unknown id ⇒ empty.
981    #[must_use]
982    pub fn branch(&self, from: Option<&EntryId>) -> Vec<&SessionEntry> {
983        from.or(self.leaf.as_ref())
984            .and_then(|id| self.index.get(id))
985            .map(|start| walk_up(&self.index, start))
986            .unwrap_or_default()
987    }
988
989    /// All entries in file order.
990    #[must_use]
991    pub fn entries(&self) -> &[SessionEntry] {
992        self.index.entries()
993    }
994
995    /// Tree of all entries (roots and orphans in file order, children by timestamp).
996    #[must_use]
997    pub fn tree(&self) -> Vec<SessionTreeNode> {
998        tree::build(self.index.entries(), &self.labels)
999    }
1000
1001    /// Builds context entries at the leaf.
1002    #[must_use]
1003    pub fn build_context_entries(&self) -> Vec<SessionEntry> {
1004        build_context_entries(&self.index, self.leaf.as_ref())
1005    }
1006
1007    /// Builds the session projection at the leaf.
1008    #[must_use]
1009    pub fn build_session_projection(&self) -> SessionProjection {
1010        build_session_projection(&self.index, self.leaf.as_ref())
1011    }
1012
1013    /// Builds the session context at the leaf.
1014    #[must_use]
1015    pub fn build_session_context(&self) -> SessionContext {
1016        build_session_context(&self.index, self.leaf.as_ref())
1017    }
1018
1019    /// Moves the leaf; nothing is written.
1020    ///
1021    /// # Errors
1022    ///
1023    /// [`SessionError::UnknownEntry`].
1024    pub fn set_leaf(&mut self, id: &EntryId) -> Result<(), SessionError> {
1025        if !self.index.contains(id) {
1026            return Err(SessionError::UnknownEntry(id.clone()));
1027        }
1028        self.leaf = Some(id.clone());
1029        Ok(())
1030    }
1031
1032    /// Clears the leaf; the next entry becomes a new root.
1033    pub fn reset_leaf(&mut self) {
1034        self.leaf = None;
1035        self.writer = None;
1036    }
1037
1038    /// Moves the leaf to `from` (`None` ⇒ new root) and appends a `branch_summary` there.
1039    ///
1040    /// # Errors
1041    ///
1042    /// [`SessionError::UnknownEntry`], I/O.
1043    pub fn branch_with_summary(
1044        &mut self,
1045        from: Option<&EntryId>,
1046        summary: BranchSummaryDraft,
1047    ) -> Result<EntryId, SessionError> {
1048        if let Some(from) = from.filter(|id| !self.index.contains(id)) {
1049            return Err(SessionError::UnknownEntry(from.clone()));
1050        }
1051        self.leaf = from.cloned();
1052        self.append_entry(EntryKind::BranchSummary(BranchSummaryEntry {
1053            from_id: summary.from_id,
1054            summary: summary.summary,
1055            details: summary.details,
1056            usage: summary.usage,
1057            from_hook: summary.from_hook,
1058        }))
1059    }
1060
1061    /// Header and entries of a new session holding the path root → `leaf` (labels re-emitted
1062    /// at the end, compaction `firstKeptEntryId`s that pointed at labels remapped).
1063    fn branched(&self, leaf: &EntryId) -> Result<(SessionHeader, Vec<SessionEntry>), SessionError> {
1064        let path = self.branch(Some(leaf));
1065        if path.is_empty() {
1066            return Err(SessionError::UnknownEntry(leaf.clone()));
1067        }
1068        let mut kept: Vec<SessionEntry> = Vec::with_capacity(path.len());
1069        let mut label_replacement: HashMap<EntryId, EntryId> = HashMap::new();
1070        let mut pending_labels: Vec<EntryId> = Vec::new();
1071        let mut parent: Option<EntryId> = None;
1072        for entry in path {
1073            if matches!(entry.kind, EntryKind::Label { .. }) {
1074                pending_labels.push(entry.id.clone());
1075                continue;
1076            }
1077            for label in pending_labels.drain(..) {
1078                label_replacement.insert(label, entry.id.clone());
1079            }
1080            let mut copy = entry.clone();
1081            copy.parent_id.clone_from(&parent);
1082            if let EntryKind::Compaction(data) = &mut copy.kind
1083                && data.first_kept_entry_id != entry.id
1084                && let Some(replacement) = label_replacement.get(&data.first_kept_entry_id)
1085            {
1086                data.first_kept_entry_id = replacement.clone();
1087            }
1088            parent = Some(entry.id.clone());
1089            kept.push(copy);
1090        }
1091        let header = new_header(
1092            &self.cwd,
1093            &CreateOptions {
1094                id: None,
1095                parent_session: self.session_file.clone().filter(|_| self.persist),
1096            },
1097        )?;
1098        let mut labels: Vec<(&EntryId, &(String, IsoTimestamp))> = self
1099            .labels
1100            .iter()
1101            .filter(|(target, _)| kept.iter().any(|e| &e.id == *target))
1102            .collect();
1103        labels.sort_by(|a, b| a.1.1.cmp(&b.1.1).then_with(|| a.0.cmp(b.0)));
1104        let mut taken: std::collections::HashSet<String> =
1105            kept.iter().map(|e| e.id.to_string()).collect();
1106        let mut previous = kept.last().map(|e| e.id.clone());
1107        for (target, (label, timestamp)) in labels {
1108            let id = EntryId::generate(|candidate| taken.contains(candidate));
1109            taken.insert(id.to_string());
1110            kept.push(SessionEntry {
1111                id: id.clone(),
1112                parent_id: previous.replace(id),
1113                timestamp: timestamp.clone(),
1114                kind: EntryKind::Label {
1115                    target_id: target.clone(),
1116                    label: Some(label.clone()),
1117                },
1118            });
1119        }
1120        Ok((header, kept))
1121    }
1122
1123    /// Writes a new session file containing the path root → `leaf` (`/clone`), with
1124    /// `parentSession` = this file. `self` is unchanged.
1125    ///
1126    /// # Errors
1127    ///
1128    /// [`SessionError::UnknownEntry`], [`SessionError::InvalidAppend`] for in-memory
1129    /// sessions (use [`SessionManager::branch_into_new_session`]), I/O.
1130    pub fn create_branched_session(&self, leaf: &EntryId) -> Result<PathBuf, SessionError> {
1131        if !self.persist {
1132            return Err(SessionError::InvalidAppend(
1133                "in-memory sessions have no file to branch into",
1134            ));
1135        }
1136        let (header, entries) = self.branched(leaf)?;
1137        let path = cwd_session_dir(&self.session_dir, &self.cwd).join(session_file_name(&header));
1138        files::write_new(&path, &files::serialize_lines(&header, &entries)?)?;
1139        Ok(path)
1140    }
1141
1142    /// Branched-session semantics: replaces `self` with the branched session (the
1143    /// file is written only if it contains a user/assistant message). Returns the new path
1144    /// for persisted sessions.
1145    ///
1146    /// # Errors
1147    ///
1148    /// [`SessionError::UnknownEntry`], I/O.
1149    pub fn branch_into_new_session(
1150        &mut self,
1151        leaf: &EntryId,
1152    ) -> Result<Option<PathBuf>, SessionError> {
1153        let (header, entries) = self.branched(leaf)?;
1154        self.header = header;
1155        self.load_entries(entries);
1156        self.flushed = false;
1157        self.writer = None;
1158        self.dir_synced = false;
1159        if !self.persist {
1160            self.session_file = None;
1161            return Ok(None);
1162        }
1163        let path =
1164            cwd_session_dir(&self.session_dir, &self.cwd).join(session_file_name(&self.header));
1165        self.session_file = Some(path.clone());
1166        if self.has_conversation {
1167            files::write_atomic(
1168                &path,
1169                &files::serialize_lines(&self.header, self.index.entries())?,
1170            )?;
1171            self.flushed = true;
1172        }
1173        Ok(Some(path))
1174    }
1175}
1176
1177impl ReadonlySession for SessionManager {
1178    fn cwd(&self) -> &Path {
1179        Self::cwd(self)
1180    }
1181    fn session_id(&self) -> &str {
1182        Self::session_id(self)
1183    }
1184    fn session_file(&self) -> Option<&Path> {
1185        Self::session_file(self)
1186    }
1187    fn header(&self) -> &SessionHeader {
1188        Self::header(self)
1189    }
1190    fn session_name(&self) -> Option<String> {
1191        Self::session_name(self)
1192    }
1193    fn leaf_id(&self) -> Option<&EntryId> {
1194        Self::leaf_id(self)
1195    }
1196    fn entry(&self, id: &EntryId) -> Option<&SessionEntry> {
1197        Self::entry(self, id)
1198    }
1199    fn children(&self, parent: Option<&EntryId>) -> Vec<&SessionEntry> {
1200        Self::children(self, parent)
1201    }
1202    fn label(&self, id: &EntryId) -> Option<&str> {
1203        Self::label(self, id)
1204    }
1205    fn branch(&self, from: Option<&EntryId>) -> Vec<&SessionEntry> {
1206        Self::branch(self, from)
1207    }
1208    fn entries(&self) -> &[SessionEntry] {
1209        Self::entries(self)
1210    }
1211    fn tree(&self) -> Vec<SessionTreeNode> {
1212        Self::tree(self)
1213    }
1214    fn build_context_entries(&self) -> Vec<SessionEntry> {
1215        Self::build_context_entries(self)
1216    }
1217    fn build_session_projection(&self) -> SessionProjection {
1218        Self::build_session_projection(self)
1219    }
1220    fn build_session_context(&self) -> SessionContext {
1221        Self::build_session_context(self)
1222    }
1223}