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}