1use 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#[derive(Debug, thiserror::Error)]
29pub enum SessionError {
30 #[error("{path}: {source}")]
32 Io {
33 path: PathBuf,
35 source: std::io::Error,
37 },
38 #[error("Session file is not a valid yapi session: {0}")]
40 Invalid(PathBuf),
41 #[error("Entry {0} not found")]
43 NotFound(String),
44 #[error("{0}")]
46 Rejected(String),
47 #[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#[derive(Clone, Debug, Default, PartialEq)]
81pub struct SessionContext {
82 pub messages: Vec<Message>,
84 pub thinking_level: String,
86 pub model: Option<(String, String)>,
88}
89
90#[derive(Clone, Debug, PartialEq, Eq)]
92pub struct SessionSummary {
93 pub path: PathBuf,
95 pub id: String,
97 pub cwd: String,
99 pub name: Option<String>,
101 pub first_message: String,
103 pub all_messages_text: String,
105 pub parent_session: Option<String>,
107 pub message_count: usize,
109 pub modified_ms: u64,
111}
112
113#[derive(Clone, Debug, PartialEq)]
115pub struct TreeNode {
116 pub entry: FileEntry,
118 pub children: Vec<usize>,
120 pub label: Option<String>,
122 pub label_timestamp: Option<String>,
124}
125
126#[derive(Clone, Debug, Default, PartialEq)]
128pub struct SessionTree {
129 pub nodes: Vec<TreeNode>,
131 pub roots: Vec<usize>,
133}
134
135pub struct SessionManager {
137 session_id: String,
138 file: Option<PathBuf>,
139 dir: PathBuf,
140 cwd: PathBuf,
141 persist: bool,
142 flushed: bool,
143 unsaved: Option<usize>,
146 write_error: Option<String>,
149 write_failed: bool,
151 entries: Vec<Entry>,
152 by_id: HashMap<String, usize>,
153 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
174fn 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
207fn 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 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 _ => {
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
246fn 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 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 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 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 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 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 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 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 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 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 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 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 pub fn take_write_error(&mut self) -> Option<String> {
549 self.write_error.take()
550 }
551
552 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 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 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 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 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 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 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 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 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 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 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 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 pub fn id(&self) -> &str {
768 &self.session_id
769 }
770
771 pub fn file(&self) -> Option<&Path> {
773 self.file.as_deref().filter(|_| self.persist)
774 }
775
776 pub fn is_persisted(&self) -> bool {
778 self.persist
779 }
780
781 pub fn dir(&self) -> &Path {
783 &self.dir
784 }
785
786 pub fn cwd(&self) -> &Path {
788 &self.cwd
789 }
790
791 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 pub fn leaf_id(&self) -> Option<&str> {
801 self.leaf.as_deref()
802 }
803
804 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 pub fn label(&self, id: &str) -> Option<&str> {
813 self.labels.get(id).map(|(label, _)| label.as_str())
814 }
815
816 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 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 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 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 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 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 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 pub fn reset_leaf(&mut self) {
954 self.leaf = None;
955 }
956
957 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 pub fn build_context(&self) -> SessionContext {
986 build_context(&self.branch_path(None))
987 }
988
989 pub fn build_projection(&self) -> Projection<'_> {
991 build_projection(&self.branch_path(None))
992 }
993
994 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 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
1115pub(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#[derive(Clone, Debug)]
1182pub struct ProjectedEntry<'a> {
1183 pub source: &'a FileEntry,
1185 pub messages: Vec<Message>,
1187}
1188
1189#[derive(Clone, Debug)]
1191pub struct Projection<'a> {
1192 pub entries: Vec<ProjectedEntry<'a>>,
1195 pub messages: Vec<Message>,
1197 pub thinking_level: String,
1199 pub model: Option<(String, String)>,
1201}
1202
1203pub 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 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
1288pub 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
1298pub 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
1322fn cwd_matches(recorded: &str, cwd: &Path) -> bool {
1325 !recorded.is_empty() && crate::tools::path::resolve_lexically(cwd, Path::new(recorded)) == cwd
1326}
1327
1328fn 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
1342fn 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
1374pub 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
1388pub 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 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
1480pub 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
1491pub 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}