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 claude_code::ClaudeCode;
13pub use codex::Codex;
14pub use prodex::thread_url_for_task as prodex_thread_url;
15
16use crate::model::{Session, StoreReport};
17use anyhow::Result;
18use chrono::{DateTime, Utc};
19use std::path::{Path, PathBuf};
20
21/// One supported agent tool. An adapter knows where the tool keeps its
22/// session files on disk and how to parse one file into a `Session`.
23///
24/// Adding support for a new tool means implementing this trait in a new
25/// module and registering it in `all()`. PRs for new adapters are the main
26/// way this project grows.
27/// A store that holds many sessions in one place (e.g. a SQLite database)
28/// rather than one file per session. Returned by [`Adapter::store`] when the
29/// file-per-session model does not fit; the indexer then enumerates sessions
30/// from `keys` (re-parsing only changed ones) instead of `discover`/`parse`.
31pub struct Store {
32 /// `(stable key, change-token)` for every session, cheap to compute without
33 /// a full parse. The change-token (e.g. the session's updated-time in ms)
34 /// drives incremental re-indexing; the key identifies the session for
35 /// [`Adapter::parse_key`]. The key doubles as the session's stored path.
36 pub keys: Vec<(String, i64)>,
37 /// The backing files (the database), for `scan` size accounting.
38 pub files: Vec<PathBuf>,
39 /// True if a backing store existed but could not be read this run (locked,
40 /// half-written, permissions). The indexer then skips deletion
41 /// reconciliation, so a transient read failure cannot archive the whole
42 /// corpus off an incomplete key set.
43 pub had_error: bool,
44}
45
46/// The result of file discovery. `had_error` means the listing is PARTIAL: a
47/// directory that exists could not be read (permissions, transient IO). The
48/// indexer still indexes what was found but skips deletion reconciliation, so
49/// a partial walk cannot archive live sessions off an incomplete listing - the
50/// same guard [`Store::had_error`] gives shared-store adapters. A root that
51/// simply does not exist on this machine is normal, not an error.
52pub struct Discovered {
53 pub files: Vec<PathBuf>,
54 pub had_error: bool,
55}
56
57impl From<Vec<PathBuf>> for Discovered {
58 fn from(files: Vec<PathBuf>) -> Self {
59 Discovered {
60 files,
61 had_error: false,
62 }
63 }
64}
65
66/// `result.ok()` that records the failure in `had_error` instead of dropping
67/// it silently - for walk loops that must report a partial listing.
68pub(crate) fn ok_or_flag<T, E>(r: std::result::Result<T, E>, had_error: &mut bool) -> Option<T> {
69 match r {
70 Ok(v) => Some(v),
71 Err(_) => {
72 *had_error = true;
73 None
74 }
75 }
76}
77
78pub trait Adapter {
79 fn name(&self) -> &'static str;
80 /// Store root, e.g. ~/.claude/projects. May not exist on this machine.
81 fn root(&self) -> Option<PathBuf>;
82 /// All session files under the root, with a partial-walk flag the indexer
83 /// uses to protect deletion reconciliation (see [`Discovered`]).
84 fn discover(&self) -> Discovered;
85 /// Parse one session file. Must never panic on malformed input;
86 /// skip bad lines and return what could be read.
87 fn parse(&self, path: &Path) -> Result<Session>;
88 /// A shared store (many sessions in one database) for tools that do not use
89 /// one file per session. When `Some`, the indexer uses it instead of
90 /// `discover`/`parse`. Default `None` = ordinary file-per-session adapter.
91 fn store(&self) -> Option<Store> {
92 None
93 }
94 /// Parse one session out of a shared store by its key (from `store().keys`).
95 /// Only called for adapters that return a [`Store`].
96 fn parse_key(&self, _key: &str) -> Result<Session> {
97 anyhow::bail!("this adapter is not a shared store")
98 }
99 /// Limit deletion reconciliation to part of this tool's indexed rows. When
100 /// `Some(prefix)`, only indexed rows whose key starts with `prefix` are
101 /// considered for archiving after this adapter runs. Use it when the
102 /// adapter's store holds only part of a tool's sessions - for example
103 /// several installations of the same tool sharing one tool name, each
104 /// listing only its own keys - so that one installation's sync cannot
105 /// archive another's rows. Default `None` = the adapter speaks for every
106 /// row of its tool.
107 fn reconcile_scope(&self) -> Option<String> {
108 None
109 }
110}
111
112/// The [`Adapter::reconcile_scope`] for an adapter pinned to `root`: every key
113/// under that directory, as a path prefix. `None` (stock location) keeps the
114/// adapter speaking for every row of its tool.
115pub(crate) fn root_scope(root: Option<&Path>) -> Option<String> {
116 let root = root?;
117 let mut prefix = root.to_string_lossy().into_owned();
118 if !prefix.ends_with(std::path::MAIN_SEPARATOR) {
119 prefix.push(std::path::MAIN_SEPARATOR);
120 }
121 Some(prefix)
122}
123
124pub fn all() -> Vec<Box<dyn Adapter>> {
125 vec![
126 Box::new(ClaudeCode::default()),
127 Box::new(Codex::default()),
128 Box::new(gemini::Gemini),
129 Box::new(opencode::OpenCode),
130 Box::new(cline::Cline),
131 Box::new(cline::RooCode),
132 Box::new(cline::KiloCode),
133 Box::new(gajae::GajaeCode),
134 Box::new(continue_dev::Continue),
135 Box::new(gptme::Gptme),
136 Box::new(aider::Aider::default()),
137 Box::new(prodex::Prodex),
138 ]
139}
140
141pub fn by_name(name: &str) -> Option<Box<dyn Adapter>> {
142 all().into_iter().find(|a| a.name() == name)
143}
144
145/// Filesystem-only summary of a store, used by `scan`. No parsing involved.
146pub fn report(adapter: &dyn Adapter) -> Option<StoreReport> {
147 let root = adapter.root()?;
148 // Shared store (e.g. SQLite): presence comes from the store itself (the db
149 // can live outside `root` via OPENCODE_DB), so this is checked before the
150 // root-exists gate. Size is the backing files, count is the sessions, and
151 // the time span comes from the per-session change-tokens.
152 if let Some(store) = adapter.store() {
153 if store.keys.is_empty() {
154 return None; // present but no sessions yet - not worth a scan row
155 }
156 let bytes = store
157 .files
158 .iter()
159 .filter_map(|f| f.metadata().ok())
160 .map(|m| m.len())
161 .sum();
162 // Tokens are last-activity ms; drop the 0 sentinel (a missing timestamp)
163 // so it cannot backdate the span to 1970.
164 let oldest = store
165 .keys
166 .iter()
167 .map(|(_, t)| *t)
168 .filter(|t| *t > 0)
169 .min()
170 .and_then(DateTime::from_timestamp_millis);
171 let newest = store
172 .keys
173 .iter()
174 .map(|(_, t)| *t)
175 .filter(|t| *t > 0)
176 .max()
177 .and_then(DateTime::from_timestamp_millis);
178 return Some(StoreReport {
179 tool: adapter.name(),
180 root,
181 files: store.keys.len(),
182 bytes,
183 oldest,
184 newest,
185 });
186 }
187 if !root.exists() {
188 return None;
189 }
190 let files = adapter.discover().files;
191 let mut bytes: u64 = 0;
192 let mut oldest: Option<DateTime<Utc>> = None;
193 let mut newest: Option<DateTime<Utc>> = None;
194 let mut count = 0usize;
195 for f in &files {
196 let Ok(meta) = f.metadata() else { continue };
197 count += 1;
198 bytes += meta.len();
199 if let Ok(modified) = meta.modified() {
200 let t: DateTime<Utc> = modified.into();
201 if oldest.is_none_or(|o| t < o) {
202 oldest = Some(t);
203 }
204 if newest.is_none_or(|n| t > n) {
205 newest = Some(t);
206 }
207 }
208 }
209 if count == 0 {
210 return None; // root exists but holds no session files
211 }
212 Some(StoreReport {
213 tool: adapter.name(),
214 root,
215 files: count,
216 bytes,
217 oldest,
218 newest,
219 })
220}
221
222/// Shared helper: pick a session title from messages when the tool does not
223/// store one. First user message that is not harness boilerplate wins.
224pub(crate) fn title_from_messages(messages: &[crate::model::Message]) -> String {
225 messages
226 .iter()
227 .find(|m| {
228 m.role == crate::model::Role::User
229 && !m.text.trim_start().starts_with('<')
230 && !m.text.trim().is_empty()
231 })
232 .map(|m| redacted_truncate(&m.text, 80))
233 .unwrap_or_else(|| "(no user prompt)".into())
234}
235
236/// Redact the complete logical value before bounding a derived field. The
237/// order matters: truncating first can leave a credential prefix too short for
238/// the redactor to recognize.
239pub(crate) fn redacted_truncate(text: &str, max: usize) -> String {
240 crate::util::truncate(&crate::redact::redact(text), max)
241}
242
243/// First-line title variant for adapters whose existing format uses a hard
244/// character cap without an ellipsis. Redaction happens before line selection
245/// so a multi-line credential is considered as one logical value.
246pub(crate) fn redacted_first_line(text: &str, max: usize) -> String {
247 let redacted = crate::redact::redact(text);
248 redacted
249 .lines()
250 .next()
251 .unwrap_or("")
252 .chars()
253 .take(max)
254 .collect()
255}
256
257pub(crate) fn parse_ts(s: &str) -> Option<DateTime<Utc>> {
258 DateTime::parse_from_rfc3339(s)
259 .ok()
260 .map(|t| t.with_timezone(&Utc))
261}
262
263/// Path hygiene shared by `touched` and `edits`: trim, and reject the shapes
264/// that are not a real edited path (empty, embedded newline, absurdly long).
265/// Returns the cleaned path or None to drop it, so the two provenance layers
266/// apply IDENTICAL rules and never diverge.
267pub(crate) fn clean_path(p: &str) -> Option<String> {
268 let p = p.trim();
269 (!p.is_empty() && !p.contains('\n') && p.len() <= 4096).then(|| p.to_string())
270}
271
272/// Tidy the list of files a session touched: `clean_path` each, then de-duplicate
273/// while preserving first-seen order. A session edits the same file many times;
274/// the link cares only that it did.
275pub(crate) fn dedup_paths(paths: Vec<String>) -> Vec<String> {
276 let mut seen = std::collections::HashSet::new();
277 paths
278 .into_iter()
279 .filter_map(|p| clean_path(&p))
280 .filter(|p| seen.insert(p.clone()))
281 .collect()
282}