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}