···11# opencode-session-rs
2233-> Reading and watching opencode session data in rust
33+Read opencode session storage with typed Rust APIs, an indexed metadata layer, and lazy file loading.
44+55+## What this crate does
66+77+- Detects opencode storage paths (`$XDG_DATA_HOME/opencode/storage` by default).
88+- Builds an in-memory index of projects, sessions, messages, and parts.
99+- Loads session/message/part JSON lazily through a memory-mapped file cache.
1010+- Lets you run workflows at different levels: index-only, per-entity load, or full session tree materialization.
1111+1212+The primary high-level entrypoint is [`SessionMaterializer`](/src/materializer.rs).
1313+1414+## Install
1515+1616+```toml
1717+[dependencies]
1818+opencode-session = "0.1"
1919+```
2020+2121+## Quick start
2222+2323+```rust
2424+use opencode_session::{SessionId, SessionMaterializer};
2525+use std::str::FromStr;
2626+2727+fn main() -> opencode_session::Result<()> {
2828+ let materializer = SessionMaterializer::new()?;
2929+3030+ for project_id in materializer.project_ids() {
3131+ let session_ids = materializer.session_ids_for_project(project_id);
3232+ println!("project={project_id} sessions={}", session_ids.len());
3333+ }
3434+3535+ let session_id = SessionId::from_str("ses_3975b29b7ffeDyjus9LjxKUoeX")?;
3636+ let tree = materializer.load_session_tree(&session_id)?;
3737+ println!("loaded {} messages", tree.messages.len());
3838+3939+ Ok(())
4040+}
4141+```
4242+4343+## API surfaces
4444+4545+### 1. Structural index access
4646+4747+Use this when you want fast metadata queries without loading full JSON payloads.
4848+4949+```rust
5050+let materializer = SessionMaterializer::new()?;
5151+5252+let session_meta = materializer.session_meta(&session_id);
5353+let message_ids = materializer.message_ids_for_session(&session_id);
5454+```
5555+5656+### 2. Piece-by-piece loading
5757+5858+Use this when you want to deconstruct control flow and run explicit stages.
5959+6060+```rust
6161+let message_ids = materializer.message_ids_for_session(&session_id);
6262+6363+for message_id in message_ids {
6464+ let message = materializer.load_message(message_id)?;
6565+ let parts = materializer.load_parts_for_message(message_id)?;
6666+ println!("message={} parts={}", message.id(), parts.len());
6767+}
6868+```
6969+7070+### 3. Full tree materialization
7171+7272+Use this when you want one call returning session + messages + parts.
7373+7474+```rust
7575+let tree = materializer.load_session_tree(&session_id)?;
7676+println!("session={} messages={}", tree.session.info.id, tree.messages.len());
7777+```
7878+7979+### 4. Incremental index construction
8080+8181+Use this when you want to control indexing scope (for example, one project at a time).
8282+8383+```rust
8484+use opencode_session::{SessionIndex, StoragePaths};
8585+8686+let paths = StoragePaths::detect()?;
8787+let mut builder = SessionIndex::builder(paths);
8888+builder.index_project("05fc47d0307a3740bf2ac963190b7c5b029b6ab1")?;
8989+let index = builder.finish();
9090+9191+println!("indexed sessions={}", index.session_count());
9292+```
9393+9494+## Core types
9595+9696+- [`SessionIndex`](/src/index.rs): metadata graph (projects -> sessions -> messages -> parts)
9797+- [`SessionMaterializer`](/src/materializer.rs): high-level indexed read API
9898+- [`SessionLoader`](/src/loader.rs): lower-level loader over `FileReader`
9999+- [`FileReader`](/src/storage/reader.rs): path-based typed JSON reads + listing helpers
100100+- [`MappedFileCache`](/src/storage/mmap.rs): memory-mapped file cache
101101+102102+## Notes
103103+104104+- This crate is pre-1.0 and API shaping is active.
105105+- Backward compatibility between minor revisions is not guaranteed yet.
106106+- `watch` and `watch-fallback` features are reserved for live change tracking integration.
+351
doc/discovery/watchman.md
···888888| **C. Two-tier** | Medium-High | Hot=none, Cold=mtime check | HashMap upsert (no I/O) | Hot=none, Cold=until access |
889889| **D. Epoch** | Low | 1 atomic load | 1 atomic tick per dir | Until next access (over-invalidates) |
890890| **E. Arc eviction** | Low | Cache miss on evicted | Hash remove | Until next access |
891891+892892+---
893893+894894+# Proposed Hybrid Design: Event-Driven Hot Index + Generation-Gated Cold Cache
895895+896896+This design combines the strongest parts of the current "Generation" model and alternative "Two-tier" model:
897897+898898+- Keep a **hot metadata index** updated directly from watch events so structural queries are always current.
899899+- Keep **cold content caches** lazy, but gate freshness with **generation stamps** (not mtime alone).
900900+- Preserve stale-on-error behavior by only replacing old cached content after a successful reload.
901901+902902+In short: watch events keep metadata accurate immediately, while actual JSON parse and mmap work remains on-demand.
903903+904904+## Why this hybrid
905905+906906+The pure generation model has excellent correctness but does not expose "always fresh" structural metadata cheaply. The pure two-tier mtime model has good shape but risks same-second write misses on coarse filesystems.
907907+908908+This hybrid resolves both:
909909+910910+1. **Structural correctness now**: index membership/count/list queries are updated at event time.
911911+2. **Content correctness on access**: generation stamps avoid ABA and mtime granularity issues.
912912+3. **Operational resilience**: stale cache entry remains available if reload fails.
913913+914914+## Architecture (refined)
915915+916916+```mermaid
917917+flowchart LR
918918+ Watchman[Watchman/notify/manual source] --> Batcher[Event batcher]
919919+ Batcher --> Classifier[Path classifier]
920920+ Classifier --> HotIndex[HotIndex metadata tables]
921921+ Classifier --> GenTracker[EntityGenerationTracker]
922922+923923+ Reader[SessionMaterializer read APIs] --> HotIndex
924924+ Reader --> ColdCache[ColdContentCache and mmap cache]
925925+ ColdCache --> GenTracker
926926+ ColdCache --> Disk[(JSON files)]
927927+```
928928+929929+## Core data model
930930+931931+### Entity identity
932932+933933+Use logical keys for dirtiness, not only raw paths:
934934+935935+```rust
936936+pub enum EntityKey {
937937+ Session { project_id: String, session_id: SessionId },
938938+ Message { session_id: SessionId, message_id: MessageId },
939939+ Part { message_id: MessageId, part_id: PartId },
940940+ SessionDiff { session_id: SessionId },
941941+}
942942+```
943943+944944+### Hot metadata index (event-driven)
945945+946946+`HotIndex` stores only identity + filesystem metadata + reverse indexes. It is updated immediately on `exists/mtime/size` event payloads.
947947+948948+Key invariant: if a file is deleted (`exists == false`), membership in hot indexes is removed immediately, including cascades.
949949+950950+### Generation tracker (entity-scoped)
951951+952952+Track dirtiness by `EntityKey` and by `PathBuf` for mmap-level invalidation:
953953+954954+```rust
955955+pub struct EntityGenerationTracker {
956956+ clock: Arc<GenerationClock>,
957957+ // entity-level stale checks for parsed JSON objects
958958+ entity_dirty: RwLock<HashMap<EntityKey, u64>>,
959959+ // path-level stale checks for mmap entries
960960+ path_dirty: RwLock<HashMap<PathBuf, u64>>,
961961+}
962962+```
963963+964964+`mark_batch_dirty()` ticks once per batch and stamps all touched keys/paths with that generation.
965965+966966+### Cold caches (lazy)
967967+968968+Cold entries store loaded generation:
969969+970970+```rust
971971+pub struct ColdEntry<T> {
972972+ data: Arc<T>,
973973+ loaded_gen: u64,
974974+}
975975+```
976976+977977+On access:
978978+979979+- if `tracker.entity_dirty_gen(key) <= loaded_gen`: serve cached value
980980+- else: reload, parse, swap cache entry, and clear dirty if current
981981+982982+## Event pipeline
983983+984984+Each watch batch goes through four deterministic steps:
985985+986986+1. **Classify** path -> `EntityKey` + operation (`Changed` or `Deleted`)
987987+2. **Apply hot metadata update** (upsert/remove + reverse index maintenance)
988988+3. **Mark dirty generation** for entity and path
989989+4. **Process deletion side effects** (evict cold caches and mmap entries; cascade children)
990990+991991+This makes metadata updates eager and content updates lazy, without losing coherence.
992992+993993+## Read path contract
994994+995995+Read APIs split into two categories:
996996+997997+- **Structural reads** (`list sessions/messages/parts`, counts): read from `HotIndex` only.
998998+- **Content reads** (`session info`, `message body`, `part payload`): resolve key from `HotIndex`, then fetch from generation-gated `ColdCache`.
999999+10001000+Pseudo-flow:
10011001+10021002+```rust
10031003+fn get_session_info(&self, key: &EntityKey) -> Result<Option<Arc<SessionInfo>>> {
10041004+ // 1) key existence from hot index
10051005+ if !self.hot_index.contains(key) {
10061006+ return Ok(None);
10071007+ }
10081008+10091009+ // 2) cold cache fast path
10101010+ if let Some(entry) = self.cold.sessions.get(key) {
10111011+ if self.tracker.is_entity_clean(key, entry.loaded_gen) {
10121012+ return Ok(Some(Arc::clone(&entry.data)));
10131013+ }
10141014+ }
10151015+10161016+ // 3) reload path (do not discard old cache until success)
10171017+ let parsed = Arc::new(self.reader.read_session_info(key)?);
10181018+ let gen = self.clock.current();
10191019+ self.cold.sessions.insert(key.clone(), ColdEntry { data: Arc::clone(&parsed), loaded_gen: gen });
10201020+ self.tracker.clear_entity_if_current(key, gen);
10211021+ Ok(Some(parsed))
10221022+}
10231023+```
10241024+10251025+## `is_fresh_instance` and recovery
10261026+10271027+When watchman reports `is_fresh_instance`, treat incremental continuity as broken.
10281028+10291029+Hybrid policy:
10301030+10311031+1. Mark a **global recovery generation** (all entities considered potentially stale).
10321032+2. Rebuild `HotIndex` via targeted scan or watchman snapshot payload.
10331033+3. Keep cold caches lazy; stale checks force reload on access.
10341034+10351035+This avoids eager full content parsing while restoring structural correctness immediately.
10361036+10371037+## Concurrency and locking
10381038+10391039+- One writer task owns event application (`HotIndex` mutation + dirty stamping).
10401040+- Read paths use shared locks and mostly hit cache fast paths.
10411041+- Reload path performs I/O outside write lock; write lock is only taken to swap entry.
10421042+10431043+This prevents long lock hold times under burst writes.
10441044+10451045+## Feature-gated backend abstraction
10461046+10471047+Keep watch source pluggable behind one trait:
10481048+10491049+```rust
10501050+pub trait ChangeFeed {
10511051+ async fn next_batch(&mut self) -> Result<Vec<FileChange>, Error>;
10521052+}
10531053+```
10541054+10551055+Implementations:
10561056+10571057+- `WatchmanFeed` (default with `watch` feature)
10581058+- `NotifyFeed` (`watch-fallback` feature)
10591059+- `ManualRefreshFeed` (explicit refresh API)
10601060+10611061+All feeds emit the same `FileChange` model so the pipeline remains identical.
10621062+10631063+## Refined rollout plan
10641064+10651065+### Step 1: Introduce shared generation primitives
10661066+10671067+Add/retain `GenerationClock` and tracker APIs in [`/src/storage/generation.rs`](/src/storage/generation.rs).
10681068+10691069+### Step 2: Add hot metadata index layer
10701070+10711071+Split structural metadata from content cache in [`/src/index.rs`](/src/index.rs) and wire structural reads in [`/src/materializer.rs`](/src/materializer.rs).
10721072+10731073+### Step 3: Convert caches to generation-gated lazy reload
10741074+10751075+Add `loaded_gen` semantics to mmap/content caches in [`/src/storage/mmap.rs`](/src/storage/mmap.rs) and materializer-owned cold stores.
10761076+10771077+### Step 4: Route watch events through classifier pipeline
10781078+10791079+Implement event apply pipeline in [`/src/watch/watchman.rs`](/src/watch/watchman.rs) and expose backend abstraction in [`/src/watch/mod.rs`](/src/watch/mod.rs).
10801080+10811081+### Step 5: Recovery and fallback hardening
10821082+10831083+Add `is_fresh_instance` recovery path and `notify/manual` feeds, keeping identical dirty semantics.
10841084+10851085+## Acceptance criteria
10861086+10871087+1. After create/update/delete, structural queries reflect changes without materializer rebuild.
10881088+2. Content reads reload only on first access after dirty event.
10891089+3. Two rapid writes before a read never regress to stale content (ABA-safe).
10901090+4. Reload failure keeps prior cache entry readable and retries on subsequent access.
10911091+5. Watchman outage degrades to fallback/manual mode without API shape changes.
10921092+10931093+## Observability and Change Data Capture (CDC)
10941094+10951095+This subsystem should expose changes as **structured events** carried over an **async stream**. The async stream gives transport/backpressure semantics; the event envelope gives domain structure and versioning.
10961096+10971097+### Recommended model
10981098+10991099+Use a dual-surface API:
11001100+11011101+1. **Low-level entity stream** for infra consumers (replication, cache invalidation, analytics).
11021102+2. **High-level session stream** for product consumers (UIs, SDK state sync) that only care "which session changed".
11031103+11041104+Both are produced from the same internal event hub.
11051105+11061106+```rust
11071107+pub trait ChangePublisher {
11081108+ fn subscribe_events(&self) -> Pin<Box<dyn Stream<Item = CdcEvent> + Send>>;
11091109+ fn subscribe_sessions(&self) -> Pin<Box<dyn Stream<Item = SessionUpdate> + Send>>;
11101110+ fn current_generation(&self) -> u64;
11111111+}
11121112+```
11131113+11141114+### Event envelope (structured, versioned)
11151115+11161116+```rust
11171117+pub struct EventCursor {
11181118+ pub generation: u64,
11191119+ pub seq_in_generation: u32,
11201120+}
11211121+11221122+pub enum ChangeOp {
11231123+ Upsert,
11241124+ Delete,
11251125+ Reloaded,
11261126+ ReloadFailed,
11271127+ ResyncStarted,
11281128+ ResyncCompleted,
11291129+}
11301130+11311131+pub enum ChangeEntity {
11321132+ Session { project_id: String, session_id: SessionId },
11331133+ Message { session_id: SessionId, message_id: MessageId },
11341134+ Part { message_id: MessageId, part_id: PartId },
11351135+ SessionDiff { session_id: SessionId },
11361136+}
11371137+11381138+pub struct CdcEvent {
11391139+ pub cursor: EventCursor,
11401140+ pub entity: ChangeEntity,
11411141+ pub op: ChangeOp,
11421142+ pub path: Option<PathBuf>,
11431143+ pub watch_clock: Option<String>,
11441144+ pub emitted_at_unix_ms: i64,
11451145+}
11461146+```
11471147+11481148+Notes:
11491149+11501150+- `generation` is the coarse ordering/version boundary.
11511151+- `seq_in_generation` disambiguates multiple events in the same generation.
11521152+- `cursor` (`generation`, `seq_in_generation`) is the consumer-facing event id.
11531153+11541154+### Primary keys to emit
11551155+11561156+Emit keys that let consumers join without reading payload bodies:
11571157+11581158+- **Session key:** `(project_id, session_id)`
11591159+- **Message key:** `(session_id, message_id)`
11601160+- **Part key:** `(message_id, part_id)`
11611161+- **Diff key:** `session_id`
11621162+11631163+`generation` is not an entity primary key; it is a **version/order key**. Consumers should index both:
11641164+11651165+1. entity key for identity
11661166+2. cursor for ordering + dedupe
11671167+11681168+### What should "message ids" look like?
11691169+11701170+There are two distinct ids to keep separate:
11711171+11721172+1. **Domain message id**: existing `MessageId` from storage paths (identity of the message entity).
11731173+2. **CDC event message id**: `EventCursor` (`generation`, `seq_in_generation`) for ordering/replay.
11741174+11751175+If a single string id is needed for external systems, encode cursor as `<generation>-<seq>` (for example, `1842-7`).
11761176+11771177+### Emitting updated sessions to consuming libraries
11781178+11791179+For most consumers, emit a compact session-level projection event:
11801180+11811181+```rust
11821182+pub struct SessionUpdate {
11831183+ pub project_id: String,
11841184+ pub session_id: SessionId,
11851185+ pub generation: u64,
11861186+ pub deleted: bool,
11871187+ pub changed_messages: Vec<MessageId>,
11881188+ pub changed_parts: Vec<PartId>,
11891189+}
11901190+```
11911191+11921192+Guidance:
11931193+11941194+- Emit `SessionUpdate` once per session per generation (coalesced), not once per file.
11951195+- Keep payload key-first; consumers fetch full content lazily from materializer APIs.
11961196+- Include `deleted=true` tombstones so mirrors can remove local state.
11971197+11981198+### Delivery semantics
11991199+12001200+Recommended semantics for in-process stream subscribers:
12011201+12021202+- **At-least-once** delivery.
12031203+- **In-order per process** by `EventCursor`.
12041204+- **Idempotent consumption** required (dedupe by cursor).
12051205+12061206+Backpressure policy should be explicit:
12071207+12081208+- Small bounded channel for low latency.
12091209+- On overflow, emit `ResyncStarted/ResyncCompleted` and require consumers to re-sync from materializer state.
12101210+12111211+### Observability signals
12121212+12131213+Expose metrics and traces around three points: ingest, apply, consume.
12141214+12151215+Suggested metrics:
12161216+12171217+- `watch_events_total{backend,entity,op}`
12181218+- `cdc_events_emitted_total{entity,op}`
12191219+- `cdc_events_dropped_total{reason}`
12201220+- `hot_index_apply_latency_seconds`
12211221+- `cold_reload_latency_seconds{entity,outcome}`
12221222+- `cold_reload_failures_total{entity,error_kind}`
12231223+- `subscriber_lag_generations{subscriber}`
12241224+- `dirty_entities{entity}` (gauge)
12251225+12261226+Suggested tracing spans:
12271227+12281228+- `watch.batch` with `generation`, `batch_size`, `is_fresh_instance`
12291229+- `index.apply_event` with `entity`, `op`, `path`
12301230+- `cache.reload` with `entity`, `result`, `generation`
12311231+- `cdc.emit` with `cursor`, `entity`, `op`
12321232+12331233+### Optional durable CDC log
12341234+12351235+If downstream consumers need replay across process restarts, add an optional append-only local log keyed by `EventCursor`.
12361236+12371237+- Keep this behind a feature flag (for example, `cdc-log`).
12381238+- Persist only envelopes/keys, not full session payloads.
12391239+- On startup, stream historical events from last acknowledged cursor, then switch to live stream.
12401240+12411241+This keeps the core subsystem lightweight while enabling stronger integration modes when needed.
···98989999 for entry in entries {
100100 let entry = entry.map_err(Error::Io)?;
101101- if entry.file_type().map_err(Error::Io)?.is_dir() {
102102- if let Some(name) = entry.file_name().to_str() {
103103- if !name.starts_with('.') {
104104- projects.push(name.to_string());
105105- }
106106- }
101101+ if let Some(name) = Self::visible_project_dir(&entry)? {
102102+ projects.push(name);
107103 }
108104 }
109105106106+ projects.sort();
110107 Ok(projects)
108108+ }
109109+110110+ fn visible_project_dir(entry: &std::fs::DirEntry) -> Result<Option<String>> {
111111+ if !entry.file_type().map_err(Error::Io)?.is_dir() {
112112+ return Ok(None);
113113+ }
114114+115115+ let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
116116+ return Ok(None);
117117+ };
118118+119119+ if name.starts_with('.') {
120120+ return Ok(None);
121121+ }
122122+123123+ Ok(Some(name))
111124 }
112125113126 pub fn migration_version(&self) -> Result<u32> {
+45-64
src/storage/reader.rs
···44use crate::types::message::FileDiff;
55use crate::types::{Message, Part, SessionInfo};
66use crate::{Error, Result};
77+use serde::de::DeserializeOwned;
78use std::path::Path;
89use std::sync::Arc;
910···3738 self.cache.get(path)
3839 }
39404141+ pub fn read_json<T: DeserializeOwned>(&self, path: &Path) -> Result<T> {
4242+ let mapped = self.read_mapped(path)?;
4343+ serde_json::from_slice(mapped.as_bytes()).map_err(Error::Json)
4444+ }
4545+4046 pub fn read_session(&self, project_id: &str, id: &SessionId) -> Result<SessionInfo> {
4147 let path = self.paths.session_file(project_id, id);
4242- let mapped = self.read_mapped(&path)?;
4343- let session: SessionInfo =
4444- serde_json::from_slice(mapped.as_bytes()).map_err(Error::Json)?;
4545- Ok(session)
4848+ self.read_json(&path)
4649 }
47504851 pub fn read_message(&self, session_id: &SessionId, id: &MessageId) -> Result<Message> {
4952 let path = self.paths.message_file(session_id, id);
5050- let mapped = self.read_mapped(&path)?;
5151- let message: Message = serde_json::from_slice(mapped.as_bytes()).map_err(Error::Json)?;
5252- Ok(message)
5353+ self.read_json(&path)
5354 }
54555556 pub fn read_part(&self, message_id: &MessageId, id: &PartId) -> Result<Part> {
5657 let path = self.paths.part_file(message_id, id);
5757- let mapped = self.read_mapped(&path)?;
5858- let part: Part = serde_json::from_slice(mapped.as_bytes()).map_err(Error::Json)?;
5959- Ok(part)
5858+ self.read_json(&path)
6059 }
61606261 pub fn read_diff(&self, session_id: &SessionId) -> Result<Vec<FileDiff>> {
···6463 if !path.exists() {
6564 return Ok(Vec::new());
6665 }
6767- let mapped = self.read_mapped(&path)?;
6868- let diffs: Vec<FileDiff> =
6969- serde_json::from_slice(mapped.as_bytes()).map_err(Error::Json)?;
7070- Ok(diffs)
6666+ self.read_json(&path)
7167 }
72687373- pub fn list_sessions(&self, project_id: &str) -> Result<Vec<SessionId>> {
7474- let dir = self.paths.session_dir(project_id);
6969+ fn list_ids_in_dir<T, F>(&self, dir: &Path, parse_id: F, descending: bool) -> Result<Vec<T>>
7070+ where
7171+ T: AsRef<str>,
7272+ F: Fn(&str) -> Option<T>,
7373+ {
7574 if !dir.exists() {
7675 return Ok(Vec::new());
7776 }
78777979- let mut sessions = Vec::new();
8080- for entry in std::fs::read_dir(&dir)? {
7878+ let mut ids = Vec::new();
7979+ for entry in std::fs::read_dir(dir)? {
8180 let entry = entry?;
8181+ if !entry.file_type()?.is_file() {
8282+ continue;
8383+ }
8484+8285 let path = entry.path();
8383- if path.extension().map(|e| e == "json").unwrap_or(false) {
8484- if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
8585- if let Some(id) = SessionId::from_filename(filename) {
8686- sessions.push(id);
8787- }
8888- }
8686+ if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
8787+ continue;
8888+ }
8989+9090+ let Some(filename) = path.file_name().and_then(|name| name.to_str()) else {
9191+ continue;
9292+ };
9393+9494+ if let Some(id) = parse_id(filename) {
9595+ ids.push(id);
8996 }
9097 }
91989292- sessions.sort_by(|a, b| b.as_str().cmp(a.as_str()));
9393- Ok(sessions)
9999+ if descending {
100100+ ids.sort_by(|a, b| b.as_ref().cmp(a.as_ref()));
101101+ } else {
102102+ ids.sort_by(|a, b| a.as_ref().cmp(b.as_ref()));
103103+ }
104104+105105+ Ok(ids)
106106+ }
107107+108108+ pub fn list_sessions(&self, project_id: &str) -> Result<Vec<SessionId>> {
109109+ let dir = self.paths.session_dir(project_id);
110110+ self.list_ids_in_dir(&dir, SessionId::from_filename, true)
94111 }
9511296113 pub fn list_messages(&self, session_id: &SessionId) -> Result<Vec<MessageId>> {
97114 let dir = self.paths.message_dir(session_id);
9898- if !dir.exists() {
9999- return Ok(Vec::new());
100100- }
101101-102102- let mut messages = Vec::new();
103103- for entry in std::fs::read_dir(&dir)? {
104104- let entry = entry?;
105105- let path = entry.path();
106106- if path.extension().map(|e| e == "json").unwrap_or(false) {
107107- if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
108108- if let Some(id) = MessageId::from_filename(filename) {
109109- messages.push(id);
110110- }
111111- }
112112- }
113113- }
114114-115115- messages.sort_by(|a, b| a.as_str().cmp(b.as_str()));
116116- Ok(messages)
115115+ self.list_ids_in_dir(&dir, MessageId::from_filename, false)
117116 }
118117119118 pub fn list_parts(&self, message_id: &MessageId) -> Result<Vec<PartId>> {
120119 let dir = self.paths.part_dir(message_id);
121121- if !dir.exists() {
122122- return Ok(Vec::new());
123123- }
124124-125125- let mut parts = Vec::new();
126126- for entry in std::fs::read_dir(&dir)? {
127127- let entry = entry?;
128128- let path = entry.path();
129129- if path.extension().map(|e| e == "json").unwrap_or(false) {
130130- if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
131131- if let Some(id) = PartId::from_filename(filename) {
132132- parts.push(id);
133133- }
134134- }
135135- }
136136- }
137137-138138- parts.sort_by(|a, b| a.as_str().cmp(b.as_str()));
139139- Ok(parts)
120120+ self.list_ids_in_dir(&dir, PartId::from_filename, false)
140121 }
141122}
142123
+4-4
src/types/mod.rs
···11-pub mod session;
21pub mod message;
32pub mod part;
33+pub mod session;
4455-pub use session::SessionInfo;
66-pub use message::{Message, UserMessage, AssistantMessage};
77-pub use part::Part;
55+pub use message::{AssistantMessage, FileDiff, Message, MessageError, UserMessage};
66+pub use part::{Part, PartBase};
77+pub use session::{SessionInfo, SessionShare, SessionSummary, SessionTime};