Real-time index of opencode sessions
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Implement Phase 1-2: Core Foundation and File Reading

Phase 1 - Core Foundation:
- core-id: SessionId, MessageId, PartId types with timestamp extraction
- core-type: SessionInfo, Message (User/Assistant), Part (12 variants)
- core-err: Error enum with all variants, Result alias
- stor-path: StoragePaths with XDG-compliant path resolution

Phase 2 - File Reading:
- stor-mmap: MappedFile wrapper and MappedFileCache
- stor-read: FileReader with list/read methods for all entity types

All types implement serde Serialize/Deserialize. 10 tests passing.

rektide ffcae75e da6e17cc

+1257
+2
.gitignore
··· 1 + /target 2 + Cargo.lock
+31
Cargo.toml
··· 1 + [package] 2 + name = "opencode-session" 3 + version = "0.1.0" 4 + edition = "2021" 5 + description = "Read and watch opencode session data with zero-copy memory mapping" 6 + license = "MIT OR Apache-2.0" 7 + repository = "https://github.com/rektide/opencode-session-rs" 8 + keywords = ["opencode", "session", "mmap", "zero-copy"] 9 + categories = ["filesystem", "parsing"] 10 + 11 + [dependencies] 12 + serde = { version = "1", features = ["derive"] } 13 + serde_json = "1" 14 + thiserror = "2" 15 + xdg = "3" 16 + memmap2 = "0.9" 17 + parking_lot = "0.12" 18 + 19 + # Optional async support 20 + tokio = { version = "1", features = ["rt", "sync"], optional = true } 21 + watchman_client = { version = "0.1", optional = true } 22 + notify = { version = "8", optional = true } 23 + 24 + [features] 25 + default = [] 26 + async = ["tokio"] 27 + watch = ["async", "watchman_client"] 28 + watch-fallback = ["notify"] 29 + 30 + [dev-dependencies] 31 + tempfile = "3"
+41
src/error.rs
··· 1 + use std::path::PathBuf; 2 + use thiserror::Error; 3 + 4 + #[derive(Debug, Error)] 5 + pub enum Error { 6 + #[error("IO error: {0}")] 7 + Io(#[from] std::io::Error), 8 + 9 + #[error("JSON parse error: {0}")] 10 + Json(#[from] serde_json::Error), 11 + 12 + #[error("Invalid {entity} ID '{id}': {reason}")] 13 + InvalidId { 14 + entity: &'static str, 15 + id: String, 16 + reason: &'static str, 17 + }, 18 + 19 + #[error("{entity} not found: {id}")] 20 + NotFound { entity: &'static str, id: String }, 21 + 22 + #[error("Migration not supported: version {version}")] 23 + Migration { version: u32 }, 24 + 25 + #[error("Path not found: {0}")] 26 + PathNotFound(PathBuf), 27 + 28 + #[error("Storage root not found")] 29 + StorageRootNotFound, 30 + 31 + #[error("Invalid file format: {0}")] 32 + InvalidFormat(String), 33 + 34 + #[error("Unknown part type: {0}")] 35 + UnknownPartType(String), 36 + 37 + #[error("Unknown message role: {0}")] 38 + UnknownRole(String), 39 + } 40 + 41 + pub type Result<T> = std::result::Result<T, Error>;
+138
src/id.rs
··· 1 + use crate::Error; 2 + use serde::{Deserialize, Deserializer, Serialize, Serializer}; 3 + use std::fmt; 4 + use std::str::FromStr; 5 + 6 + const SESSION_PREFIX: &str = "ses_"; 7 + const MESSAGE_PREFIX: &str = "msg_"; 8 + const PART_PREFIX: &str = "prt_"; 9 + 10 + macro_rules! define_id { 11 + ($name:ident, $prefix:literal, $prefix_str:expr, $entity:expr) => { 12 + #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] 13 + pub struct $name(String); 14 + 15 + impl $name { 16 + pub fn new(id: impl Into<String>) -> crate::Result<Self> { 17 + let id = id.into(); 18 + if !id.starts_with($prefix_str) { 19 + return Err(Error::InvalidId { 20 + entity: $entity, 21 + id: id.clone(), 22 + reason: concat!("must start with ", $prefix), 23 + }); 24 + } 25 + if id.len() < $prefix_str.len() + 10 { 26 + return Err(Error::InvalidId { 27 + entity: $entity, 28 + id: id.clone(), 29 + reason: "ID too short", 30 + }); 31 + } 32 + Ok(Self(id)) 33 + } 34 + 35 + pub fn as_str(&self) -> &str { 36 + &self.0 37 + } 38 + 39 + pub fn prefix(&self) -> &'static str { 40 + $prefix 41 + } 42 + 43 + pub fn timestamp_hex(&self) -> &str { 44 + &self.0[$prefix_str.len()..$prefix_str.len() + 12] 45 + } 46 + 47 + pub fn timestamp(&self) -> i64 { 48 + let hex = self.timestamp_hex(); 49 + let encoded = u64::from_str_radix(hex, 16).unwrap_or(0); 50 + (encoded as i64) / 0x1000 51 + } 52 + 53 + pub fn random_part(&self) -> &str { 54 + &self.0[$prefix_str.len() + 12..] 55 + } 56 + 57 + pub fn from_filename(filename: &str) -> Option<Self> { 58 + let filename = filename.strip_suffix(".json")?; 59 + Self::new(filename).ok() 60 + } 61 + 62 + pub fn to_filename(&self) -> String { 63 + format!("{}.json", self.0) 64 + } 65 + } 66 + 67 + impl fmt::Display for $name { 68 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 69 + write!(f, "{}", self.0) 70 + } 71 + } 72 + 73 + impl FromStr for $name { 74 + type Err = Error; 75 + fn from_str(s: &str) -> crate::Result<Self> { 76 + Self::new(s) 77 + } 78 + } 79 + 80 + impl Serialize for $name { 81 + fn serialize<S: Serializer>( 82 + &self, 83 + serializer: S, 84 + ) -> std::result::Result<S::Ok, S::Error> { 85 + serializer.serialize_str(&self.0) 86 + } 87 + } 88 + 89 + impl<'de> Deserialize<'de> for $name { 90 + fn deserialize<D: Deserializer<'de>>( 91 + deserializer: D, 92 + ) -> std::result::Result<Self, D::Error> { 93 + let s = String::deserialize(deserializer)?; 94 + Self::new(s).map_err(serde::de::Error::custom) 95 + } 96 + } 97 + 98 + impl AsRef<str> for $name { 99 + fn as_ref(&self) -> &str { 100 + &self.0 101 + } 102 + } 103 + }; 104 + } 105 + 106 + define_id!(SessionId, "ses", SESSION_PREFIX, "session"); 107 + define_id!(MessageId, "msg", MESSAGE_PREFIX, "message"); 108 + define_id!(PartId, "prt", PART_PREFIX, "part"); 109 + 110 + #[cfg(test)] 111 + mod tests { 112 + use super::*; 113 + 114 + #[test] 115 + fn test_session_id() { 116 + let id = SessionId::new("ses_3975b29b7ffeDyjus9LjxKUoeX").unwrap(); 117 + assert_eq!(id.prefix(), "ses"); 118 + assert_eq!(id.as_str(), "ses_3975b29b7ffeDyjus9LjxKUoeX"); 119 + assert_eq!(id.timestamp_hex(), "3975b29b7ffe"); 120 + assert!(id.timestamp() > 0); 121 + assert_eq!(id.random_part(), "Dyjus9LjxKUoeX"); 122 + } 123 + 124 + #[test] 125 + fn test_invalid_id() { 126 + assert!(SessionId::new("invalid").is_err()); 127 + assert!(SessionId::new("ses_short").is_err()); 128 + } 129 + 130 + #[test] 131 + fn test_filename() { 132 + let id = SessionId::new("ses_3975b29b7ffeDyjus9LjxKUoeX").unwrap(); 133 + assert_eq!(id.to_filename(), "ses_3975b29b7ffeDyjus9LjxKUoeX.json"); 134 + 135 + let parsed = SessionId::from_filename("ses_3975b29b7ffeDyjus9LjxKUoeX.json").unwrap(); 136 + assert_eq!(parsed, id); 137 + } 138 + }
+21
src/lib.rs
··· 1 + pub mod error; 2 + pub mod id; 3 + pub mod storage; 4 + pub mod types; 5 + 6 + #[cfg(feature = "async")] 7 + pub mod index; 8 + 9 + #[cfg(feature = "async")] 10 + pub mod materializer; 11 + 12 + #[cfg(feature = "watch")] 13 + pub mod watch; 14 + 15 + pub use error::{Error, Result}; 16 + pub use id::{MessageId, PartId, SessionId}; 17 + pub use types::{ 18 + message::{AssistantMessage, Message, UserMessage}, 19 + part::Part, 20 + session::SessionInfo, 21 + };
+127
src/storage/mmap.rs
··· 1 + use crate::{Error, Result}; 2 + use memmap2::Mmap; 3 + use parking_lot::RwLock; 4 + use std::fs::File; 5 + use std::path::Path; 6 + use std::sync::Arc; 7 + 8 + #[derive(Debug)] 9 + pub struct MappedFile { 10 + path: std::path::PathBuf, 11 + mmap: Mmap, 12 + } 13 + 14 + impl MappedFile { 15 + pub fn open(path: &Path) -> Result<Self> { 16 + let file = File::open(path).map_err(Error::Io)?; 17 + 18 + let mmap = unsafe { Mmap::map(&file) }.map_err(Error::Io)?; 19 + 20 + Ok(Self { 21 + path: path.to_path_buf(), 22 + mmap, 23 + }) 24 + } 25 + 26 + pub fn as_bytes(&self) -> &[u8] { 27 + &self.mmap 28 + } 29 + 30 + pub fn path(&self) -> &Path { 31 + &self.path 32 + } 33 + 34 + pub fn len(&self) -> usize { 35 + self.mmap.len() 36 + } 37 + 38 + pub fn is_empty(&self) -> bool { 39 + self.mmap.is_empty() 40 + } 41 + } 42 + 43 + #[derive(Debug, Default)] 44 + pub struct MappedFileCache { 45 + files: RwLock<Vec<(std::path::PathBuf, Arc<MappedFile>)>>, 46 + } 47 + 48 + impl MappedFileCache { 49 + pub fn new() -> Self { 50 + Self::default() 51 + } 52 + 53 + pub fn get(&self, path: &Path) -> Result<Arc<MappedFile>> { 54 + { 55 + let files = self.files.read(); 56 + if let Some((_, cached)) = files.iter().find(|(p, _)| p == path) { 57 + return Ok(Arc::clone(cached)); 58 + } 59 + } 60 + 61 + let mapped = Arc::new(MappedFile::open(path)?); 62 + 63 + { 64 + let mut files = self.files.write(); 65 + files.push((path.to_path_buf(), Arc::clone(&mapped))); 66 + } 67 + 68 + Ok(mapped) 69 + } 70 + 71 + pub fn remove(&self, path: &Path) { 72 + let mut files = self.files.write(); 73 + files.retain(|(p, _)| p != path); 74 + } 75 + 76 + pub fn clear(&self) { 77 + let mut files = self.files.write(); 78 + files.clear(); 79 + } 80 + 81 + pub fn len(&self) -> usize { 82 + self.files.read().len() 83 + } 84 + 85 + pub fn is_empty(&self) -> bool { 86 + self.files.read().is_empty() 87 + } 88 + } 89 + 90 + #[cfg(test)] 91 + mod tests { 92 + use super::*; 93 + use std::io::Write; 94 + use tempfile::NamedTempFile; 95 + 96 + #[test] 97 + fn test_mapped_file() -> Result<()> { 98 + let mut temp = NamedTempFile::new()?; 99 + write!(temp, "test content")?; 100 + 101 + let path = temp.path(); 102 + let mapped = MappedFile::open(path)?; 103 + 104 + assert_eq!(mapped.as_bytes(), b"test content"); 105 + assert_eq!(mapped.len(), 12); 106 + assert!(!mapped.is_empty()); 107 + 108 + Ok(()) 109 + } 110 + 111 + #[test] 112 + fn test_cache() -> Result<()> { 113 + let mut temp = NamedTempFile::new()?; 114 + write!(temp, "cached content")?; 115 + 116 + let cache = MappedFileCache::new(); 117 + let path = temp.path(); 118 + 119 + let file1 = cache.get(path)?; 120 + let file2 = cache.get(path)?; 121 + 122 + assert_eq!(cache.len(), 1); 123 + assert!(Arc::ptr_eq(&file1, &file2)); 124 + 125 + Ok(()) 126 + } 127 + }
+7
src/storage/mod.rs
··· 1 + pub mod paths; 2 + pub mod mmap; 3 + pub mod reader; 4 + 5 + pub use paths::StoragePaths; 6 + pub use mmap::MappedFile; 7 + pub use reader::FileReader;
+144
src/storage/paths.rs
··· 1 + use crate::{Error, Result}; 2 + use std::path::PathBuf; 3 + 4 + pub struct StoragePaths { 5 + pub root: PathBuf, 6 + pub session: PathBuf, 7 + pub message: PathBuf, 8 + pub part: PathBuf, 9 + pub diff: PathBuf, 10 + pub snapshot: PathBuf, 11 + pub migration: PathBuf, 12 + } 13 + 14 + impl StoragePaths { 15 + pub fn detect() -> Result<Self> { 16 + let base = Self::detect_base()?; 17 + Self::from_base(base) 18 + } 19 + 20 + pub fn from_base(base: PathBuf) -> Result<Self> { 21 + let root = base.join("storage"); 22 + if !root.exists() { 23 + return Err(Error::StorageRootNotFound); 24 + } 25 + 26 + Ok(Self { 27 + session: root.join("session"), 28 + message: root.join("message"), 29 + part: root.join("part"), 30 + diff: root.join("session_diff"), 31 + snapshot: root.join("snapshot"), 32 + migration: root.join("migration"), 33 + root, 34 + }) 35 + } 36 + 37 + fn detect_base() -> Result<PathBuf> { 38 + if let Ok(home) = std::env::var("OPENCODE_TEST_HOME") { 39 + return Ok(PathBuf::from(home) 40 + .join(".local") 41 + .join("share") 42 + .join("opencode")); 43 + } 44 + 45 + let xdg = xdg::BaseDirectories::with_prefix("opencode"); 46 + 47 + xdg.get_data_home().ok_or(Error::StorageRootNotFound) 48 + } 49 + 50 + pub fn session_file(&self, project_id: &str, session_id: &crate::id::SessionId) -> PathBuf { 51 + self.session.join(project_id).join(session_id.to_filename()) 52 + } 53 + 54 + pub fn session_dir(&self, project_id: &str) -> PathBuf { 55 + self.session.join(project_id) 56 + } 57 + 58 + pub fn message_file( 59 + &self, 60 + session_id: &crate::id::SessionId, 61 + message_id: &crate::id::MessageId, 62 + ) -> PathBuf { 63 + self.message 64 + .join(session_id.as_str()) 65 + .join(message_id.to_filename()) 66 + } 67 + 68 + pub fn message_dir(&self, session_id: &crate::id::SessionId) -> PathBuf { 69 + self.message.join(session_id.as_str()) 70 + } 71 + 72 + pub fn part_file( 73 + &self, 74 + message_id: &crate::id::MessageId, 75 + part_id: &crate::id::PartId, 76 + ) -> PathBuf { 77 + self.part 78 + .join(message_id.as_str()) 79 + .join(part_id.to_filename()) 80 + } 81 + 82 + pub fn part_dir(&self, message_id: &crate::id::MessageId) -> PathBuf { 83 + self.part.join(message_id.as_str()) 84 + } 85 + 86 + pub fn diff_file(&self, session_id: &crate::id::SessionId) -> PathBuf { 87 + self.diff.join(session_id.to_filename()) 88 + } 89 + 90 + pub fn snapshot_dir(&self, project_id: &str) -> PathBuf { 91 + self.snapshot.join(project_id) 92 + } 93 + 94 + pub fn project_dirs(&self) -> Result<Vec<String>> { 95 + let mut projects = Vec::new(); 96 + let entries = std::fs::read_dir(&self.session).map_err(Error::Io)?; 97 + 98 + for entry in entries { 99 + let entry = entry.map_err(Error::Io)?; 100 + if entry.file_type().map_err(Error::Io)?.is_dir() { 101 + if let Some(name) = entry.file_name().to_str() { 102 + if !name.starts_with('.') { 103 + projects.push(name.to_string()); 104 + } 105 + } 106 + } 107 + } 108 + 109 + Ok(projects) 110 + } 111 + 112 + pub fn migration_version(&self) -> Result<u32> { 113 + let content = std::fs::read_to_string(&self.migration).unwrap_or_else(|_| "0".to_string()); 114 + content 115 + .trim() 116 + .parse() 117 + .map_err(|_| Error::Migration { version: 0 }) 118 + } 119 + } 120 + 121 + #[cfg(test)] 122 + mod tests { 123 + use super::*; 124 + 125 + #[test] 126 + fn test_path_building() { 127 + let paths = StoragePaths { 128 + root: PathBuf::from("/test/storage"), 129 + session: PathBuf::from("/test/storage/session"), 130 + message: PathBuf::from("/test/storage/message"), 131 + part: PathBuf::from("/test/storage/part"), 132 + diff: PathBuf::from("/test/storage/session_diff"), 133 + snapshot: PathBuf::from("/test/storage/snapshot"), 134 + migration: PathBuf::from("/test/storage/migration"), 135 + }; 136 + 137 + let session_id = crate::id::SessionId::new("ses_3975b29b7ffeDyjus9LjxKUoeX").unwrap(); 138 + let path = paths.session_file("project123", &session_id); 139 + assert_eq!( 140 + path.to_str().unwrap(), 141 + "/test/storage/session/project123/ses_3975b29b7ffeDyjus9LjxKUoeX.json" 142 + ); 143 + } 144 + }
+156
src/storage/reader.rs
··· 1 + use crate::id::{MessageId, PartId, SessionId}; 2 + use crate::storage::mmap::{MappedFile, MappedFileCache}; 3 + use crate::storage::paths::StoragePaths; 4 + use crate::types::message::FileDiff; 5 + use crate::types::{Message, Part, SessionInfo}; 6 + use crate::{Error, Result}; 7 + use std::path::Path; 8 + use std::sync::Arc; 9 + 10 + pub struct FileReader { 11 + paths: StoragePaths, 12 + cache: MappedFileCache, 13 + } 14 + 15 + impl FileReader { 16 + pub fn new() -> Result<Self> { 17 + let paths = StoragePaths::detect()?; 18 + Ok(Self::with_paths(paths)) 19 + } 20 + 21 + pub fn with_paths(paths: StoragePaths) -> Self { 22 + Self { 23 + paths, 24 + cache: MappedFileCache::new(), 25 + } 26 + } 27 + 28 + pub fn paths(&self) -> &StoragePaths { 29 + &self.paths 30 + } 31 + 32 + pub fn cache(&self) -> &MappedFileCache { 33 + &self.cache 34 + } 35 + 36 + pub fn read_mapped(&self, path: &Path) -> Result<Arc<MappedFile>> { 37 + self.cache.get(path) 38 + } 39 + 40 + pub fn read_session(&self, project_id: &str, id: &SessionId) -> Result<SessionInfo> { 41 + let path = self.paths.session_file(project_id, id); 42 + let mapped = self.read_mapped(&path)?; 43 + let session: SessionInfo = 44 + serde_json::from_slice(mapped.as_bytes()).map_err(Error::Json)?; 45 + Ok(session) 46 + } 47 + 48 + pub fn read_message(&self, session_id: &SessionId, id: &MessageId) -> Result<Message> { 49 + let path = self.paths.message_file(session_id, id); 50 + let mapped = self.read_mapped(&path)?; 51 + let message: Message = serde_json::from_slice(mapped.as_bytes()).map_err(Error::Json)?; 52 + Ok(message) 53 + } 54 + 55 + pub fn read_part(&self, message_id: &MessageId, id: &PartId) -> Result<Part> { 56 + let path = self.paths.part_file(message_id, id); 57 + let mapped = self.read_mapped(&path)?; 58 + let part: Part = serde_json::from_slice(mapped.as_bytes()).map_err(Error::Json)?; 59 + Ok(part) 60 + } 61 + 62 + pub fn read_diff(&self, session_id: &SessionId) -> Result<Vec<FileDiff>> { 63 + let path = self.paths.diff_file(session_id); 64 + if !path.exists() { 65 + return Ok(Vec::new()); 66 + } 67 + let mapped = self.read_mapped(&path)?; 68 + let diffs: Vec<FileDiff> = 69 + serde_json::from_slice(mapped.as_bytes()).map_err(Error::Json)?; 70 + Ok(diffs) 71 + } 72 + 73 + pub fn list_sessions(&self, project_id: &str) -> Result<Vec<SessionId>> { 74 + let dir = self.paths.session_dir(project_id); 75 + if !dir.exists() { 76 + return Ok(Vec::new()); 77 + } 78 + 79 + let mut sessions = Vec::new(); 80 + for entry in std::fs::read_dir(&dir)? { 81 + let entry = entry?; 82 + let path = entry.path(); 83 + if path.extension().map(|e| e == "json").unwrap_or(false) { 84 + if let Some(filename) = path.file_name().and_then(|n| n.to_str()) { 85 + if let Some(id) = SessionId::from_filename(filename) { 86 + sessions.push(id); 87 + } 88 + } 89 + } 90 + } 91 + 92 + sessions.sort_by(|a, b| b.as_str().cmp(a.as_str())); 93 + Ok(sessions) 94 + } 95 + 96 + pub fn list_messages(&self, session_id: &SessionId) -> Result<Vec<MessageId>> { 97 + let dir = self.paths.message_dir(session_id); 98 + if !dir.exists() { 99 + return Ok(Vec::new()); 100 + } 101 + 102 + let mut messages = Vec::new(); 103 + for entry in std::fs::read_dir(&dir)? { 104 + let entry = entry?; 105 + let path = entry.path(); 106 + if path.extension().map(|e| e == "json").unwrap_or(false) { 107 + if let Some(filename) = path.file_name().and_then(|n| n.to_str()) { 108 + if let Some(id) = MessageId::from_filename(filename) { 109 + messages.push(id); 110 + } 111 + } 112 + } 113 + } 114 + 115 + messages.sort_by(|a, b| a.as_str().cmp(b.as_str())); 116 + Ok(messages) 117 + } 118 + 119 + pub fn list_parts(&self, message_id: &MessageId) -> Result<Vec<PartId>> { 120 + let dir = self.paths.part_dir(message_id); 121 + if !dir.exists() { 122 + return Ok(Vec::new()); 123 + } 124 + 125 + let mut parts = Vec::new(); 126 + for entry in std::fs::read_dir(&dir)? { 127 + let entry = entry?; 128 + let path = entry.path(); 129 + if path.extension().map(|e| e == "json").unwrap_or(false) { 130 + if let Some(filename) = path.file_name().and_then(|n| n.to_str()) { 131 + if let Some(id) = PartId::from_filename(filename) { 132 + parts.push(id); 133 + } 134 + } 135 + } 136 + } 137 + 138 + parts.sort_by(|a, b| a.as_str().cmp(b.as_str())); 139 + Ok(parts) 140 + } 141 + } 142 + 143 + #[cfg(test)] 144 + mod tests { 145 + use super::*; 146 + 147 + #[test] 148 + fn test_reader_creation() { 149 + let result = FileReader::new(); 150 + match result { 151 + Ok(_) => {} 152 + Err(Error::StorageRootNotFound) => {} 153 + Err(e) => panic!("unexpected error: {}", e), 154 + } 155 + } 156 + }
+177
src/types/message.rs
··· 1 + use crate::id::{MessageId, SessionId}; 2 + use serde::{Deserialize, Serialize}; 3 + 4 + #[derive(Debug, Clone, Serialize, Deserialize)] 5 + #[serde(tag = "role")] 6 + pub enum Message { 7 + #[serde(rename = "user")] 8 + User(UserMessage), 9 + #[serde(rename = "assistant")] 10 + Assistant(AssistantMessage), 11 + } 12 + 13 + impl Message { 14 + pub fn id(&self) -> &MessageId { 15 + match self { 16 + Message::User(m) => &m.id, 17 + Message::Assistant(m) => &m.id, 18 + } 19 + } 20 + 21 + pub fn session_id(&self) -> &SessionId { 22 + match self { 23 + Message::User(m) => &m.session_id, 24 + Message::Assistant(m) => &m.session_id, 25 + } 26 + } 27 + } 28 + 29 + #[derive(Debug, Clone, Serialize, Deserialize)] 30 + pub struct UserMessage { 31 + pub id: MessageId, 32 + #[serde(rename = "sessionID")] 33 + pub session_id: SessionId, 34 + pub time: UserTime, 35 + #[serde(skip_serializing_if = "Option::is_none")] 36 + pub summary: Option<UserSummary>, 37 + pub agent: String, 38 + pub model: ModelRef, 39 + #[serde(skip_serializing_if = "Option::is_none")] 40 + pub system: Option<String>, 41 + #[serde(skip_serializing_if = "Option::is_none")] 42 + pub tools: Option<serde_json::Map<String, serde_json::Value>>, 43 + #[serde(skip_serializing_if = "Option::is_none")] 44 + pub variant: Option<String>, 45 + } 46 + 47 + #[derive(Debug, Clone, Serialize, Deserialize)] 48 + pub struct UserTime { 49 + pub created: i64, 50 + } 51 + 52 + #[derive(Debug, Clone, Serialize, Deserialize)] 53 + pub struct UserSummary { 54 + #[serde(skip_serializing_if = "Option::is_none")] 55 + pub title: Option<String>, 56 + #[serde(skip_serializing_if = "Option::is_none")] 57 + pub body: Option<String>, 58 + #[serde(default)] 59 + pub diffs: Vec<FileDiff>, 60 + } 61 + 62 + #[derive(Debug, Clone, Serialize, Deserialize)] 63 + pub struct AssistantMessage { 64 + pub id: MessageId, 65 + #[serde(rename = "sessionID")] 66 + pub session_id: SessionId, 67 + pub time: AssistantTime, 68 + #[serde(rename = "parentID")] 69 + pub parent_id: MessageId, 70 + #[serde(rename = "modelID")] 71 + pub model_id: String, 72 + #[serde(rename = "providerID")] 73 + pub provider_id: String, 74 + pub agent: String, 75 + pub path: MessagePath, 76 + pub cost: f64, 77 + pub tokens: TokenUsage, 78 + #[serde(skip_serializing_if = "Option::is_none")] 79 + pub error: Option<MessageError>, 80 + #[serde(skip_serializing_if = "Option::is_none")] 81 + pub summary: Option<bool>, 82 + #[serde(skip_serializing_if = "Option::is_none")] 83 + pub structured: Option<serde_json::Value>, 84 + #[serde(skip_serializing_if = "Option::is_none")] 85 + pub variant: Option<String>, 86 + #[serde(skip_serializing_if = "Option::is_none")] 87 + pub finish: Option<String>, 88 + } 89 + 90 + #[derive(Debug, Clone, Serialize, Deserialize)] 91 + pub struct AssistantTime { 92 + pub created: i64, 93 + #[serde(skip_serializing_if = "Option::is_none")] 94 + pub completed: Option<i64>, 95 + } 96 + 97 + #[derive(Debug, Clone, Serialize, Deserialize)] 98 + pub struct MessagePath { 99 + pub cwd: std::path::PathBuf, 100 + pub root: std::path::PathBuf, 101 + } 102 + 103 + #[derive(Debug, Clone, Serialize, Deserialize)] 104 + pub struct TokenUsage { 105 + #[serde(skip_serializing_if = "Option::is_none")] 106 + pub total: Option<u64>, 107 + pub input: u64, 108 + pub output: u64, 109 + pub reasoning: u64, 110 + pub cache: CacheUsage, 111 + } 112 + 113 + #[derive(Debug, Clone, Serialize, Deserialize)] 114 + pub struct CacheUsage { 115 + pub read: u64, 116 + pub write: u64, 117 + } 118 + 119 + #[derive(Debug, Clone, Serialize, Deserialize)] 120 + pub struct MessageError { 121 + pub name: String, 122 + pub message: String, 123 + #[serde(skip_serializing_if = "Option::is_none")] 124 + pub code: Option<String>, 125 + } 126 + 127 + #[derive(Debug, Clone, Serialize, Deserialize)] 128 + pub struct ModelRef { 129 + #[serde(rename = "providerID")] 130 + pub provider_id: String, 131 + #[serde(rename = "modelID")] 132 + pub model_id: String, 133 + } 134 + 135 + #[derive(Debug, Clone, Serialize, Deserialize)] 136 + pub struct FileDiff { 137 + pub file: String, 138 + pub before: String, 139 + pub after: String, 140 + pub additions: u64, 141 + pub deletions: u64, 142 + #[serde(skip_serializing_if = "Option::is_none")] 143 + pub status: Option<FileDiffStatus>, 144 + } 145 + 146 + #[derive(Debug, Clone, Serialize, Deserialize)] 147 + #[serde(rename_all = "lowercase")] 148 + pub enum FileDiffStatus { 149 + Added, 150 + Deleted, 151 + Modified, 152 + } 153 + 154 + #[cfg(test)] 155 + mod tests { 156 + use super::*; 157 + 158 + #[test] 159 + fn test_parse_user_message() { 160 + let json = r#"{ 161 + "id": "msg_c723b04e2001hTCN7PTiAwtfdF", 162 + "sessionID": "ses_38dc4fb1fffe2pRA7IbF4oxToZ", 163 + "role": "user", 164 + "time": { "created": 1771442996450 }, 165 + "agent": "explore", 166 + "model": { "providerID": "test", "modelID": "model-1" } 167 + }"#; 168 + 169 + let msg: Message = serde_json::from_str(json).unwrap(); 170 + match msg { 171 + Message::User(m) => { 172 + assert_eq!(m.agent, "explore"); 173 + } 174 + _ => panic!("expected user message"), 175 + } 176 + } 177 + }
+7
src/types/mod.rs
··· 1 + pub mod session; 2 + pub mod message; 3 + pub mod part; 4 + 5 + pub use session::SessionInfo; 6 + pub use message::{Message, UserMessage, AssistantMessage}; 7 + pub use part::Part;
+319
src/types/part.rs
··· 1 + use crate::id::{MessageId, PartId, SessionId}; 2 + use serde::{Deserialize, Serialize}; 3 + 4 + #[derive(Debug, Clone, Serialize, Deserialize)] 5 + #[serde(tag = "type", rename_all = "kebab-case")] 6 + pub enum Part { 7 + Text(TextPart), 8 + Reasoning(ReasoningPart), 9 + Tool(ToolPart), 10 + File(FilePart), 11 + #[serde(rename = "step-start")] 12 + StepStart(StepStartPart), 13 + #[serde(rename = "step-finish")] 14 + StepFinish(StepFinishPart), 15 + Snapshot(SnapshotPart), 16 + Patch(PatchPart), 17 + Agent(AgentPart), 18 + Subtask(SubtaskPart), 19 + Compaction(CompactionPart), 20 + Retry(RetryPart), 21 + } 22 + 23 + impl Part { 24 + pub fn id(&self) -> &PartId { 25 + match self { 26 + Part::Text(p) => &p.base.id, 27 + Part::Reasoning(p) => &p.base.id, 28 + Part::Tool(p) => &p.base.id, 29 + Part::File(p) => &p.base.id, 30 + Part::StepStart(p) => &p.base.id, 31 + Part::StepFinish(p) => &p.base.id, 32 + Part::Snapshot(p) => &p.base.id, 33 + Part::Patch(p) => &p.base.id, 34 + Part::Agent(p) => &p.base.id, 35 + Part::Subtask(p) => &p.base.id, 36 + Part::Compaction(p) => &p.base.id, 37 + Part::Retry(p) => &p.base.id, 38 + } 39 + } 40 + 41 + pub fn message_id(&self) -> &MessageId { 42 + match self { 43 + Part::Text(p) => &p.base.message_id, 44 + Part::Reasoning(p) => &p.base.message_id, 45 + Part::Tool(p) => &p.base.message_id, 46 + Part::File(p) => &p.base.message_id, 47 + Part::StepStart(p) => &p.base.message_id, 48 + Part::StepFinish(p) => &p.base.message_id, 49 + Part::Snapshot(p) => &p.base.message_id, 50 + Part::Patch(p) => &p.base.message_id, 51 + Part::Agent(p) => &p.base.message_id, 52 + Part::Subtask(p) => &p.base.message_id, 53 + Part::Compaction(p) => &p.base.message_id, 54 + Part::Retry(p) => &p.base.message_id, 55 + } 56 + } 57 + } 58 + 59 + #[derive(Debug, Clone, Serialize, Deserialize)] 60 + pub struct PartBase { 61 + pub id: PartId, 62 + #[serde(rename = "sessionID")] 63 + pub session_id: SessionId, 64 + #[serde(rename = "messageID")] 65 + pub message_id: MessageId, 66 + } 67 + 68 + #[derive(Debug, Clone, Serialize, Deserialize)] 69 + pub struct TextPart { 70 + #[serde(flatten)] 71 + pub base: PartBase, 72 + pub text: String, 73 + #[serde(skip_serializing_if = "Option::is_none")] 74 + pub synthetic: Option<bool>, 75 + #[serde(skip_serializing_if = "Option::is_none")] 76 + pub ignored: Option<bool>, 77 + #[serde(skip_serializing_if = "Option::is_none")] 78 + pub time: Option<PartTime>, 79 + #[serde(skip_serializing_if = "Option::is_none")] 80 + pub metadata: Option<serde_json::Map<String, serde_json::Value>>, 81 + } 82 + 83 + #[derive(Debug, Clone, Serialize, Deserialize)] 84 + pub struct ReasoningPart { 85 + #[serde(flatten)] 86 + pub base: PartBase, 87 + pub text: String, 88 + pub time: PartTime, 89 + #[serde(skip_serializing_if = "Option::is_none")] 90 + pub metadata: Option<serde_json::Map<String, serde_json::Value>>, 91 + } 92 + 93 + #[derive(Debug, Clone, Serialize, Deserialize)] 94 + pub struct ToolPart { 95 + #[serde(flatten)] 96 + pub base: PartBase, 97 + #[serde(rename = "callID")] 98 + pub call_id: String, 99 + pub tool: String, 100 + pub state: ToolState, 101 + #[serde(skip_serializing_if = "Option::is_none")] 102 + pub metadata: Option<serde_json::Map<String, serde_json::Value>>, 103 + } 104 + 105 + #[derive(Debug, Clone, Serialize, Deserialize)] 106 + #[serde(tag = "status", rename_all = "lowercase")] 107 + pub enum ToolState { 108 + Pending(ToolStatePending), 109 + Running(ToolStateRunning), 110 + Completed(ToolStateCompleted), 111 + Error(ToolStateError), 112 + } 113 + 114 + #[derive(Debug, Clone, Serialize, Deserialize)] 115 + pub struct ToolStatePending { 116 + pub input: serde_json::Map<String, serde_json::Value>, 117 + pub raw: String, 118 + } 119 + 120 + #[derive(Debug, Clone, Serialize, Deserialize)] 121 + pub struct ToolStateRunning { 122 + pub input: serde_json::Map<String, serde_json::Value>, 123 + #[serde(skip_serializing_if = "Option::is_none")] 124 + pub title: Option<String>, 125 + pub time: ToolTimeStart, 126 + #[serde(skip_serializing_if = "Option::is_none")] 127 + pub metadata: Option<serde_json::Map<String, serde_json::Value>>, 128 + } 129 + 130 + #[derive(Debug, Clone, Serialize, Deserialize)] 131 + pub struct ToolStateCompleted { 132 + pub input: serde_json::Map<String, serde_json::Value>, 133 + pub output: String, 134 + pub title: String, 135 + pub time: ToolTimeComplete, 136 + #[serde(skip_serializing_if = "Option::is_none")] 137 + pub metadata: Option<serde_json::Map<String, serde_json::Value>>, 138 + #[serde(skip_serializing_if = "Option::is_none")] 139 + pub attachments: Option<Vec<FilePart>>, 140 + } 141 + 142 + #[derive(Debug, Clone, Serialize, Deserialize)] 143 + pub struct ToolStateError { 144 + pub input: serde_json::Map<String, serde_json::Value>, 145 + pub error: String, 146 + pub time: ToolTimeComplete, 147 + #[serde(skip_serializing_if = "Option::is_none")] 148 + pub metadata: Option<serde_json::Map<String, serde_json::Value>>, 149 + } 150 + 151 + #[derive(Debug, Clone, Serialize, Deserialize)] 152 + pub struct ToolTimeStart { 153 + pub start: i64, 154 + } 155 + 156 + #[derive(Debug, Clone, Serialize, Deserialize)] 157 + pub struct ToolTimeComplete { 158 + pub start: i64, 159 + pub end: i64, 160 + #[serde(skip_serializing_if = "Option::is_none")] 161 + pub compacted: Option<i64>, 162 + } 163 + 164 + #[derive(Debug, Clone, Serialize, Deserialize)] 165 + pub struct FilePart { 166 + #[serde(flatten)] 167 + pub base: PartBase, 168 + pub mime: String, 169 + #[serde(skip_serializing_if = "Option::is_none")] 170 + pub filename: Option<String>, 171 + pub url: String, 172 + #[serde(skip_serializing_if = "Option::is_none")] 173 + pub source: Option<FilePartSource>, 174 + } 175 + 176 + #[derive(Debug, Clone, Serialize, Deserialize)] 177 + #[serde(tag = "type", rename_all = "lowercase")] 178 + pub enum FilePartSource { 179 + File { 180 + path: std::path::PathBuf, 181 + }, 182 + Symbol { 183 + path: std::path::PathBuf, 184 + range: LspRange, 185 + name: String, 186 + kind: i32, 187 + }, 188 + Resource { 189 + client_name: String, 190 + uri: String, 191 + }, 192 + } 193 + 194 + #[derive(Debug, Clone, Serialize, Deserialize)] 195 + pub struct LspRange { 196 + pub start: LspPosition, 197 + pub end: LspPosition, 198 + } 199 + 200 + #[derive(Debug, Clone, Serialize, Deserialize)] 201 + pub struct LspPosition { 202 + pub line: u32, 203 + pub character: u32, 204 + } 205 + 206 + #[derive(Debug, Clone, Serialize, Deserialize)] 207 + pub struct StepStartPart { 208 + #[serde(flatten)] 209 + pub base: PartBase, 210 + #[serde(skip_serializing_if = "Option::is_none")] 211 + pub snapshot: Option<String>, 212 + } 213 + 214 + #[derive(Debug, Clone, Serialize, Deserialize)] 215 + pub struct StepFinishPart { 216 + #[serde(flatten)] 217 + pub base: PartBase, 218 + pub reason: String, 219 + #[serde(skip_serializing_if = "Option::is_none")] 220 + pub snapshot: Option<String>, 221 + pub cost: f64, 222 + pub tokens: crate::types::message::TokenUsage, 223 + } 224 + 225 + #[derive(Debug, Clone, Serialize, Deserialize)] 226 + pub struct SnapshotPart { 227 + #[serde(flatten)] 228 + pub base: PartBase, 229 + pub snapshot: String, 230 + } 231 + 232 + #[derive(Debug, Clone, Serialize, Deserialize)] 233 + pub struct PatchPart { 234 + #[serde(flatten)] 235 + pub base: PartBase, 236 + pub hash: String, 237 + pub files: Vec<String>, 238 + } 239 + 240 + #[derive(Debug, Clone, Serialize, Deserialize)] 241 + pub struct AgentPart { 242 + #[serde(flatten)] 243 + pub base: PartBase, 244 + pub name: String, 245 + #[serde(skip_serializing_if = "Option::is_none")] 246 + pub source: Option<AgentSource>, 247 + } 248 + 249 + #[derive(Debug, Clone, Serialize, Deserialize)] 250 + pub struct AgentSource { 251 + pub value: String, 252 + pub start: usize, 253 + pub end: usize, 254 + } 255 + 256 + #[derive(Debug, Clone, Serialize, Deserialize)] 257 + pub struct SubtaskPart { 258 + #[serde(flatten)] 259 + pub base: PartBase, 260 + pub prompt: String, 261 + pub description: String, 262 + pub agent: String, 263 + #[serde(skip_serializing_if = "Option::is_none")] 264 + pub model: Option<crate::types::message::ModelRef>, 265 + #[serde(skip_serializing_if = "Option::is_none")] 266 + pub command: Option<String>, 267 + } 268 + 269 + #[derive(Debug, Clone, Serialize, Deserialize)] 270 + pub struct CompactionPart { 271 + #[serde(flatten)] 272 + pub base: PartBase, 273 + pub auto: bool, 274 + } 275 + 276 + #[derive(Debug, Clone, Serialize, Deserialize)] 277 + pub struct RetryPart { 278 + #[serde(flatten)] 279 + pub base: PartBase, 280 + pub attempt: u32, 281 + pub error: crate::types::message::MessageError, 282 + pub time: PartTimeOnly, 283 + } 284 + 285 + #[derive(Debug, Clone, Serialize, Deserialize)] 286 + pub struct PartTime { 287 + pub start: i64, 288 + #[serde(skip_serializing_if = "Option::is_none")] 289 + pub end: Option<i64>, 290 + } 291 + 292 + #[derive(Debug, Clone, Serialize, Deserialize)] 293 + pub struct PartTimeOnly { 294 + pub created: i64, 295 + } 296 + 297 + #[cfg(test)] 298 + mod tests { 299 + use super::*; 300 + 301 + #[test] 302 + fn test_parse_text_part() { 303 + let json = r#"{ 304 + "id": "prt_c723b04e2002o7gz2xZ1EHmJkx", 305 + "sessionID": "ses_38dc4fb1fffe2pRA7IbF4oxToZ", 306 + "messageID": "msg_c723b04e2001hTCN7PTiAwtfdF", 307 + "type": "text", 308 + "text": "Hello, world!" 309 + }"#; 310 + 311 + let part: Part = serde_json::from_str(json).unwrap(); 312 + match part { 313 + Part::Text(p) => { 314 + assert_eq!(p.text, "Hello, world!"); 315 + } 316 + _ => panic!("expected text part"), 317 + } 318 + } 319 + }
+87
src/types/session.rs
··· 1 + use crate::id::SessionId; 2 + use serde::{Deserialize, Serialize}; 3 + use std::path::PathBuf; 4 + 5 + #[derive(Debug, Clone, Serialize, Deserialize)] 6 + pub struct SessionInfo { 7 + pub id: SessionId, 8 + pub slug: String, 9 + #[serde(rename = "projectID")] 10 + pub project_id: String, 11 + pub directory: PathBuf, 12 + #[serde(rename = "parentID", skip_serializing_if = "Option::is_none")] 13 + pub parent_id: Option<SessionId>, 14 + pub title: String, 15 + pub version: String, 16 + pub time: SessionTime, 17 + #[serde(skip_serializing_if = "Option::is_none")] 18 + pub summary: Option<SessionSummary>, 19 + #[serde(skip_serializing_if = "Option::is_none")] 20 + pub share: Option<SessionShare>, 21 + #[serde(skip_serializing_if = "Option::is_none")] 22 + pub revert: Option<RevertState>, 23 + } 24 + 25 + #[derive(Debug, Clone, Serialize, Deserialize)] 26 + pub struct SessionTime { 27 + pub created: i64, 28 + pub updated: i64, 29 + #[serde(skip_serializing_if = "Option::is_none")] 30 + pub compacting: Option<i64>, 31 + #[serde(skip_serializing_if = "Option::is_none")] 32 + pub archived: Option<i64>, 33 + } 34 + 35 + #[derive(Debug, Clone, Serialize, Deserialize)] 36 + pub struct SessionSummary { 37 + pub additions: u64, 38 + pub deletions: u64, 39 + pub files: u64, 40 + } 41 + 42 + #[derive(Debug, Clone, Serialize, Deserialize)] 43 + pub struct SessionShare { 44 + pub url: String, 45 + } 46 + 47 + #[derive(Debug, Clone, Serialize, Deserialize)] 48 + pub struct RevertState { 49 + #[serde(rename = "messageID")] 50 + pub message_id: String, 51 + #[serde(rename = "partID", skip_serializing_if = "Option::is_none")] 52 + pub part_id: Option<String>, 53 + #[serde(skip_serializing_if = "Option::is_none")] 54 + pub snapshot: Option<String>, 55 + #[serde(skip_serializing_if = "Option::is_none")] 56 + pub diff: Option<String>, 57 + } 58 + 59 + #[cfg(test)] 60 + mod tests { 61 + use super::*; 62 + 63 + #[test] 64 + fn test_parse_session() { 65 + let json = r#"{ 66 + "id": "ses_3975b29b7ffeDyjus9LjxKUoeX", 67 + "slug": "abc123", 68 + "projectID": "project-hash", 69 + "directory": "/home/user/project", 70 + "title": "Test Session", 71 + "version": "1.0.0", 72 + "time": { 73 + "created": 1700000000000, 74 + "updated": 1700000100000 75 + }, 76 + "summary": { 77 + "additions": 10, 78 + "deletions": 5, 79 + "files": 2 80 + } 81 + }"#; 82 + 83 + let session: SessionInfo = serde_json::from_str(json).unwrap(); 84 + assert_eq!(session.title, "Test Session"); 85 + assert_eq!(session.summary.as_ref().unwrap().additions, 10); 86 + } 87 + }