Skip to main content

sessionwiki/adapters/
mod.rs

1mod aider;
2mod claude_code;
3mod cline;
4mod codex;
5mod continue_dev;
6mod gajae;
7mod gemini;
8mod gptme;
9pub mod harness;
10mod opencode;
11mod prodex;
12pub use prodex::thread_url_for_task as prodex_thread_url;
13
14use crate::model::{Session, StoreReport};
15use anyhow::Result;
16use chrono::{DateTime, Utc};
17use std::path::{Path, PathBuf};
18
19/// One supported agent tool. An adapter knows where the tool keeps its
20/// session files on disk and how to parse one file into a `Session`.
21///
22/// Adding support for a new tool means implementing this trait in a new
23/// module and registering it in `all()`. PRs for new adapters are the main
24/// way this project grows.
25/// A store that holds many sessions in one place (e.g. a SQLite database)
26/// rather than one file per session. Returned by [`Adapter::store`] when the
27/// file-per-session model does not fit; the indexer then enumerates sessions
28/// from `keys` (re-parsing only changed ones) instead of `discover`/`parse`.
29pub struct Store {
30    /// `(stable key, change-token)` for every session, cheap to compute without
31    /// a full parse. The change-token (e.g. the session's updated-time in ms)
32    /// drives incremental re-indexing; the key identifies the session for
33    /// [`Adapter::parse_key`]. The key doubles as the session's stored path.
34    pub keys: Vec<(String, i64)>,
35    /// The backing files (the database), for `scan` size accounting.
36    pub files: Vec<PathBuf>,
37    /// True if a backing store existed but could not be read this run (locked,
38    /// half-written, permissions). The indexer then skips deletion
39    /// reconciliation, so a transient read failure cannot archive the whole
40    /// corpus off an incomplete key set.
41    pub had_error: bool,
42}
43
44/// The result of file discovery. `had_error` means the listing is PARTIAL: a
45/// directory that exists could not be read (permissions, transient IO). The
46/// indexer still indexes what was found but skips deletion reconciliation, so
47/// a partial walk cannot archive live sessions off an incomplete listing - the
48/// same guard [`Store::had_error`] gives shared-store adapters. A root that
49/// simply does not exist on this machine is normal, not an error.
50pub struct Discovered {
51    pub files: Vec<PathBuf>,
52    pub had_error: bool,
53}
54
55impl From<Vec<PathBuf>> for Discovered {
56    fn from(files: Vec<PathBuf>) -> Self {
57        Discovered {
58            files,
59            had_error: false,
60        }
61    }
62}
63
64/// `result.ok()` that records the failure in `had_error` instead of dropping
65/// it silently - for walk loops that must report a partial listing.
66pub(crate) fn ok_or_flag<T, E>(r: std::result::Result<T, E>, had_error: &mut bool) -> Option<T> {
67    match r {
68        Ok(v) => Some(v),
69        Err(_) => {
70            *had_error = true;
71            None
72        }
73    }
74}
75
76pub trait Adapter {
77    fn name(&self) -> &'static str;
78    /// Store root, e.g. ~/.claude/projects. May not exist on this machine.
79    fn root(&self) -> Option<PathBuf>;
80    /// All session files under the root, with a partial-walk flag the indexer
81    /// uses to protect deletion reconciliation (see [`Discovered`]).
82    fn discover(&self) -> Discovered;
83    /// Parse one session file. Must never panic on malformed input;
84    /// skip bad lines and return what could be read.
85    fn parse(&self, path: &Path) -> Result<Session>;
86    /// A shared store (many sessions in one database) for tools that do not use
87    /// one file per session. When `Some`, the indexer uses it instead of
88    /// `discover`/`parse`. Default `None` = ordinary file-per-session adapter.
89    fn store(&self) -> Option<Store> {
90        None
91    }
92    /// Parse one session out of a shared store by its key (from `store().keys`).
93    /// Only called for adapters that return a [`Store`].
94    fn parse_key(&self, _key: &str) -> Result<Session> {
95        anyhow::bail!("this adapter is not a shared store")
96    }
97}
98
99pub fn all() -> Vec<Box<dyn Adapter>> {
100    vec![
101        Box::new(claude_code::ClaudeCode),
102        Box::new(codex::Codex),
103        Box::new(gemini::Gemini),
104        Box::new(opencode::OpenCode),
105        Box::new(cline::Cline),
106        Box::new(cline::RooCode),
107        Box::new(cline::KiloCode),
108        Box::new(gajae::GajaeCode),
109        Box::new(continue_dev::Continue),
110        Box::new(gptme::Gptme),
111        Box::new(aider::Aider::default()),
112        Box::new(prodex::Prodex),
113    ]
114}
115
116pub fn by_name(name: &str) -> Option<Box<dyn Adapter>> {
117    all().into_iter().find(|a| a.name() == name)
118}
119
120/// Filesystem-only summary of a store, used by `scan`. No parsing involved.
121pub fn report(adapter: &dyn Adapter) -> Option<StoreReport> {
122    let root = adapter.root()?;
123    // Shared store (e.g. SQLite): presence comes from the store itself (the db
124    // can live outside `root` via OPENCODE_DB), so this is checked before the
125    // root-exists gate. Size is the backing files, count is the sessions, and
126    // the time span comes from the per-session change-tokens.
127    if let Some(store) = adapter.store() {
128        if store.keys.is_empty() {
129            return None; // present but no sessions yet - not worth a scan row
130        }
131        let bytes = store
132            .files
133            .iter()
134            .filter_map(|f| f.metadata().ok())
135            .map(|m| m.len())
136            .sum();
137        // Tokens are last-activity ms; drop the 0 sentinel (a missing timestamp)
138        // so it cannot backdate the span to 1970.
139        let oldest = store
140            .keys
141            .iter()
142            .map(|(_, t)| *t)
143            .filter(|t| *t > 0)
144            .min()
145            .and_then(DateTime::from_timestamp_millis);
146        let newest = store
147            .keys
148            .iter()
149            .map(|(_, t)| *t)
150            .filter(|t| *t > 0)
151            .max()
152            .and_then(DateTime::from_timestamp_millis);
153        return Some(StoreReport {
154            tool: adapter.name(),
155            root,
156            files: store.keys.len(),
157            bytes,
158            oldest,
159            newest,
160        });
161    }
162    if !root.exists() {
163        return None;
164    }
165    let files = adapter.discover().files;
166    let mut bytes: u64 = 0;
167    let mut oldest: Option<DateTime<Utc>> = None;
168    let mut newest: Option<DateTime<Utc>> = None;
169    let mut count = 0usize;
170    for f in &files {
171        let Ok(meta) = f.metadata() else { continue };
172        count += 1;
173        bytes += meta.len();
174        if let Ok(modified) = meta.modified() {
175            let t: DateTime<Utc> = modified.into();
176            if oldest.is_none_or(|o| t < o) {
177                oldest = Some(t);
178            }
179            if newest.is_none_or(|n| t > n) {
180                newest = Some(t);
181            }
182        }
183    }
184    if count == 0 {
185        return None; // root exists but holds no session files
186    }
187    Some(StoreReport {
188        tool: adapter.name(),
189        root,
190        files: count,
191        bytes,
192        oldest,
193        newest,
194    })
195}
196
197/// Shared helper: pick a session title from messages when the tool does not
198/// store one. First user message that is not harness boilerplate wins.
199pub(crate) fn title_from_messages(messages: &[crate::model::Message]) -> String {
200    messages
201        .iter()
202        .find(|m| {
203            m.role == crate::model::Role::User
204                && !m.text.trim_start().starts_with('<')
205                && !m.text.trim().is_empty()
206        })
207        .map(|m| crate::util::truncate(&m.text, 80))
208        .unwrap_or_else(|| "(no user prompt)".into())
209}
210
211pub(crate) fn parse_ts(s: &str) -> Option<DateTime<Utc>> {
212    DateTime::parse_from_rfc3339(s)
213        .ok()
214        .map(|t| t.with_timezone(&Utc))
215}
216
217/// Tidy the list of files a session touched: trim, drop empties and obvious
218/// non-paths, and de-duplicate while preserving first-seen order. A session
219/// edits the same file many times; the link cares only that it did.
220pub(crate) fn dedup_paths(paths: Vec<String>) -> Vec<String> {
221    let mut seen = std::collections::HashSet::new();
222    paths
223        .into_iter()
224        .map(|p| p.trim().to_string())
225        .filter(|p| !p.is_empty() && !p.contains('\n') && p.len() <= 4096)
226        .filter(|p| seen.insert(p.clone()))
227        .collect()
228}