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    /// Limit deletion reconciliation to part of this tool's indexed rows. When
98    /// `Some(prefix)`, only indexed rows whose key starts with `prefix` are
99    /// considered for archiving after this adapter runs. Use it when the
100    /// adapter's store holds only part of a tool's sessions - for example
101    /// several installations of the same tool sharing one tool name, each
102    /// listing only its own keys - so that one installation's sync cannot
103    /// archive another's rows. Default `None` = the adapter speaks for every
104    /// row of its tool.
105    fn reconcile_scope(&self) -> Option<String> {
106        None
107    }
108}
109
110pub fn all() -> Vec<Box<dyn Adapter>> {
111    vec![
112        Box::new(claude_code::ClaudeCode),
113        Box::new(codex::Codex),
114        Box::new(gemini::Gemini),
115        Box::new(opencode::OpenCode),
116        Box::new(cline::Cline),
117        Box::new(cline::RooCode),
118        Box::new(cline::KiloCode),
119        Box::new(gajae::GajaeCode),
120        Box::new(continue_dev::Continue),
121        Box::new(gptme::Gptme),
122        Box::new(aider::Aider::default()),
123        Box::new(prodex::Prodex),
124    ]
125}
126
127pub fn by_name(name: &str) -> Option<Box<dyn Adapter>> {
128    all().into_iter().find(|a| a.name() == name)
129}
130
131/// Filesystem-only summary of a store, used by `scan`. No parsing involved.
132pub fn report(adapter: &dyn Adapter) -> Option<StoreReport> {
133    let root = adapter.root()?;
134    // Shared store (e.g. SQLite): presence comes from the store itself (the db
135    // can live outside `root` via OPENCODE_DB), so this is checked before the
136    // root-exists gate. Size is the backing files, count is the sessions, and
137    // the time span comes from the per-session change-tokens.
138    if let Some(store) = adapter.store() {
139        if store.keys.is_empty() {
140            return None; // present but no sessions yet - not worth a scan row
141        }
142        let bytes = store
143            .files
144            .iter()
145            .filter_map(|f| f.metadata().ok())
146            .map(|m| m.len())
147            .sum();
148        // Tokens are last-activity ms; drop the 0 sentinel (a missing timestamp)
149        // so it cannot backdate the span to 1970.
150        let oldest = store
151            .keys
152            .iter()
153            .map(|(_, t)| *t)
154            .filter(|t| *t > 0)
155            .min()
156            .and_then(DateTime::from_timestamp_millis);
157        let newest = store
158            .keys
159            .iter()
160            .map(|(_, t)| *t)
161            .filter(|t| *t > 0)
162            .max()
163            .and_then(DateTime::from_timestamp_millis);
164        return Some(StoreReport {
165            tool: adapter.name(),
166            root,
167            files: store.keys.len(),
168            bytes,
169            oldest,
170            newest,
171        });
172    }
173    if !root.exists() {
174        return None;
175    }
176    let files = adapter.discover().files;
177    let mut bytes: u64 = 0;
178    let mut oldest: Option<DateTime<Utc>> = None;
179    let mut newest: Option<DateTime<Utc>> = None;
180    let mut count = 0usize;
181    for f in &files {
182        let Ok(meta) = f.metadata() else { continue };
183        count += 1;
184        bytes += meta.len();
185        if let Ok(modified) = meta.modified() {
186            let t: DateTime<Utc> = modified.into();
187            if oldest.is_none_or(|o| t < o) {
188                oldest = Some(t);
189            }
190            if newest.is_none_or(|n| t > n) {
191                newest = Some(t);
192            }
193        }
194    }
195    if count == 0 {
196        return None; // root exists but holds no session files
197    }
198    Some(StoreReport {
199        tool: adapter.name(),
200        root,
201        files: count,
202        bytes,
203        oldest,
204        newest,
205    })
206}
207
208/// Shared helper: pick a session title from messages when the tool does not
209/// store one. First user message that is not harness boilerplate wins.
210pub(crate) fn title_from_messages(messages: &[crate::model::Message]) -> String {
211    messages
212        .iter()
213        .find(|m| {
214            m.role == crate::model::Role::User
215                && !m.text.trim_start().starts_with('<')
216                && !m.text.trim().is_empty()
217        })
218        .map(|m| redacted_truncate(&m.text, 80))
219        .unwrap_or_else(|| "(no user prompt)".into())
220}
221
222/// Redact the complete logical value before bounding a derived field. The
223/// order matters: truncating first can leave a credential prefix too short for
224/// the redactor to recognize.
225pub(crate) fn redacted_truncate(text: &str, max: usize) -> String {
226    crate::util::truncate(&crate::redact::redact(text), max)
227}
228
229/// First-line title variant for adapters whose existing format uses a hard
230/// character cap without an ellipsis. Redaction happens before line selection
231/// so a multi-line credential is considered as one logical value.
232pub(crate) fn redacted_first_line(text: &str, max: usize) -> String {
233    let redacted = crate::redact::redact(text);
234    redacted
235        .lines()
236        .next()
237        .unwrap_or("")
238        .chars()
239        .take(max)
240        .collect()
241}
242
243pub(crate) fn parse_ts(s: &str) -> Option<DateTime<Utc>> {
244    DateTime::parse_from_rfc3339(s)
245        .ok()
246        .map(|t| t.with_timezone(&Utc))
247}
248
249/// Path hygiene shared by `touched` and `edits`: trim, and reject the shapes
250/// that are not a real edited path (empty, embedded newline, absurdly long).
251/// Returns the cleaned path or None to drop it, so the two provenance layers
252/// apply IDENTICAL rules and never diverge.
253pub(crate) fn clean_path(p: &str) -> Option<String> {
254    let p = p.trim();
255    (!p.is_empty() && !p.contains('\n') && p.len() <= 4096).then(|| p.to_string())
256}
257
258/// Tidy the list of files a session touched: `clean_path` each, then de-duplicate
259/// while preserving first-seen order. A session edits the same file many times;
260/// the link cares only that it did.
261pub(crate) fn dedup_paths(paths: Vec<String>) -> Vec<String> {
262    let mut seen = std::collections::HashSet::new();
263    paths
264        .into_iter()
265        .filter_map(|p| clean_path(&p))
266        .filter(|p| seen.insert(p.clone()))
267        .collect()
268}