sessionwiki 0.34.0

Find, search, and read every AI coding session you've ever had - across Claude Code, Codex, Gemini CLI, OpenCode, Cline, and more.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
//! Aider adapter: read-only index of per-repo `.aider.chat.history.md`.
//! One file accumulates many runs (one per aider launch) delimited by
//! `# aider chat started at` headers. Markdown-derived, so roles are
//! reconstructed from line prefixes (lower fidelity than the JSONL adapters);
//! an assistant `#### ` heading or `> ` blockquote is a known misclassification.
//! No per-message timestamps; `started` is the run header (local time, assumed
//! UTC). Reads are size-capped; discovery is bounded and logs nothing.

use crate::adapters::{redacted_first_line, Adapter, Store};
use crate::model::{Message, Role, Session};
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use std::path::{Path, PathBuf};
use std::time::Instant;
use walkdir::WalkDir;

struct Run {
    started: Option<DateTime<Utc>>,
    body: String,
}

/// Parse aider's header timestamp `%Y-%m-%d %H:%M:%S` (local naive, no tz) and
/// assume UTC. `parse_ts` in mod.rs is RFC3339-only and returns None for these.
fn parse_aider_ts(s: &str) -> Option<DateTime<Utc>> {
    chrono::NaiveDateTime::parse_from_str(s.trim(), "%Y-%m-%d %H:%M:%S")
        .ok()
        .map(|n| DateTime::<Utc>::from_naive_utc_and_offset(n, Utc))
}

/// Split a history file into runs on the `# aider chat started at ` header (the
/// only single-`#` line aider writes). Bytes before the first header belong to
/// no run and are dropped. A header with no body keeps its slot, so positional
/// run indices never renumber when new runs are appended.
fn split_runs(content: &str) -> Vec<Run> {
    let mut runs: Vec<Run> = Vec::new();
    let mut cur: Option<Run> = None;
    for line in content.lines() {
        if let Some(ts) = line.strip_prefix("# aider chat started at ") {
            if let Some(r) = cur.take() {
                runs.push(r);
            }
            cur = Some(Run {
                started: parse_aider_ts(ts),
                body: String::new(),
            });
        } else if let Some(r) = cur.as_mut() {
            r.body.push_str(line);
            r.body.push('\n');
        }
        // lines before the first header (cur == None) are dropped
    }
    if let Some(r) = cur.take() {
        runs.push(r);
    }
    runs
}

fn push_unique(v: &mut Vec<String>, s: &str) {
    let s = s.trim().to_string();
    if !s.is_empty() && !v.contains(&s) {
        v.push(s);
    }
}

fn flush(messages: &mut Vec<Message>, role: Option<Role>, buf: &mut Vec<String>) {
    if let Some(role) = role {
        let text = buf.join("\n").trim().to_string();
        // Keep empty user turns (aider writes `#### ` for empty input); drop
        // empty assistant/tool noise.
        if !text.is_empty() || role == Role::User {
            messages.push(Message {
                role,
                text,
                ts: None,
                tool: None,
            });
        }
    }
    buf.clear();
}

/// Reconstruct turns from one run body. `#### ` = user, `> ` = tool (every aider
/// tool/warning/error line is blockquoted), blank lines continue the current
/// turn, everything else is assistant (the default container). Edited files come
/// from `> Applied edit to` / `> Creating empty file` (relative paths). Known
/// limitation: an assistant `#### ` heading or `> ` blockquote is misclassified.
fn parse_turns(body: &str) -> (Vec<Message>, Vec<String>) {
    let mut messages: Vec<Message> = Vec::new();
    let mut touched: Vec<String> = Vec::new();
    let mut cur_role: Option<Role> = None;
    let mut buf: Vec<String> = Vec::new();

    for raw in body.lines() {
        let (line_role, content): (Option<Role>, String) =
            if let Some(r) = raw.strip_prefix("#### ") {
                (Some(Role::User), r.trim_end().to_string())
            } else if raw == "####" {
                (Some(Role::User), String::new())
            } else if let Some(r) = raw.strip_prefix("> ") {
                if let Some(p) = r.strip_prefix("Applied edit to ") {
                    push_unique(&mut touched, p);
                } else if let Some(p) = r.strip_prefix("Creating empty file ") {
                    push_unique(&mut touched, p);
                }
                (Some(Role::Tool), r.to_string())
            } else if raw == ">" {
                (Some(Role::Tool), String::new())
            } else if raw.trim().is_empty() {
                (None, String::new()) // blank: continue the current turn
            } else {
                (Some(Role::Assistant), raw.to_string())
            };

        match line_role {
            Some(role) => {
                if cur_role != Some(role) {
                    flush(&mut messages, cur_role, &mut buf);
                    cur_role = Some(role);
                }
                buf.push(content);
            }
            None => {
                if cur_role.is_some() {
                    buf.push(String::new());
                }
            }
        }
    }
    flush(&mut messages, cur_role, &mut buf);
    (messages, touched)
}

const HISTORY_FILE: &str = ".aider.chat.history.md";
const MAX_DEPTH: usize = 4;
const MAX_DIRS: usize = 50_000;
const MAX_FILES: usize = 5_000;
const WALK_BUDGET_SECS: u64 = 2;
const SKIP_DIRS: &[&str] = &[
    ".git",
    "node_modules",
    "target",
    ".cache",
    "Library",
    "go",
    ".cargo",
    ".rustup",
    ".npm",
    ".pnpm-store",
    ".gradle",
    ".m2",
    "vendor",
    "dist",
    "build",
    ".venv",
    "venv",
    "__pycache__",
    ".svn",
    ".hg",
];

/// Folders directly under home that macOS guards with a consent prompt and
/// where no code lives: Photos via Pictures, Media Library via Music, Movies,
/// and the Trash. The default walk skips them, so it never raises a dialog for
/// a folder that cannot hold an aider history. Desktop, Documents and
/// Downloads are walked: projects live there, and since macOS binaries are
/// signed with one stable identity, the grant given once outlives upgrades at
/// the same install path. SESSIONWIKI_AIDER_ROOTS still walks what it lists.
const MACOS_GUARDED_HOME_DIRS: &[&str] = &["Pictures", "Movies", "Music", ".Trash"];

/// The guarded folders to skip for these roots: only on macOS, and only for
/// the default walk of home - roots the user listed are walked as given.
fn guarded_dirs() -> &'static [&'static str] {
    if cfg!(target_os = "macos") && std::env::var_os("SESSIONWIKI_AIDER_ROOTS").is_none() {
        MACOS_GUARDED_HOME_DIRS
    } else {
        &[]
    }
}

fn aider_roots() -> Vec<PathBuf> {
    if let Some(v) = std::env::var_os("SESSIONWIKI_AIDER_ROOTS") {
        return std::env::split_paths(&v)
            .filter_map(|p| std::fs::canonicalize(p).ok())
            .collect();
    }
    // Default: a bounded, capped walk of the home directory (NOT unbounded - the
    // depth/dir/file/time caps below keep it cheap; an over-cap sets had_error).
    dirs::home_dir().into_iter().collect()
}

fn is_skipped(entry: &walkdir::DirEntry) -> bool {
    entry.file_type().is_dir()
        && entry
            .file_name()
            .to_str()
            .is_some_and(|n| SKIP_DIRS.contains(&n))
}

/// Find `.aider.chat.history.md` files under `roots`, bounded and reading
/// nothing but matching files. Returns (files, had_error). `had_error` is set on
/// ANY cap-hit or walk error so the indexer skips deletion reconciliation on a
/// partial result (the only safe guard for this rootless adapter). Logs nothing.
fn discover_history(roots: &[PathBuf], guarded: &[&str]) -> (Vec<PathBuf>, bool) {
    let mut files = Vec::new();
    let mut had_error = false;
    let mut dirs = 0usize;
    let start = Instant::now();
    'outer: for root in roots {
        let walker = WalkDir::new(root)
            .max_depth(MAX_DEPTH)
            .follow_links(false) // never escape via symlinks (first walk over user space)
            .into_iter()
            .filter_entry(|e| {
                !(is_skipped(e)
                    || (e.depth() == 1
                        && e.file_type().is_dir()
                        && e.file_name().to_str().is_some_and(|n| guarded.contains(&n))))
            });
        for entry in walker {
            if start.elapsed().as_secs() >= WALK_BUDGET_SECS {
                had_error = true;
                break 'outer;
            }
            match entry {
                Ok(e) => {
                    if e.file_type().is_dir() {
                        dirs += 1;
                        if dirs > MAX_DIRS {
                            had_error = true;
                            break 'outer;
                        }
                    } else if e.file_name() == HISTORY_FILE {
                        files.push(e.into_path());
                        if files.len() > MAX_FILES {
                            had_error = true;
                            break 'outer;
                        }
                    }
                }
                Err(_) => had_error = true, // permission etc.: incomplete walk
            }
        }
    }
    (files, had_error)
}

/// U+001F, matching the shared-store key separator used by the OpenCode adapter,
/// so display rendering stays uniform. Never appears in a path.
const KEY_SEP: char = '\u{1f}';

fn make_key(path: &str, idx: usize) -> String {
    format!("{path}{KEY_SEP}{idx}")
}

/// Render a store key as `<path>#<idx>` for human/`show`/web output.
fn display_path(key: &str) -> String {
    match key.split_once(KEY_SEP) {
        Some((p, i)) => format!("{p}#{i}"),
        None => key.to_string(),
    }
}

fn title_from(messages: &[Message]) -> String {
    messages
        .iter()
        .find(|m| m.role == Role::User && !m.text.trim().is_empty())
        .map(|m| redacted_first_line(m.text.trim(), 80))
        .unwrap_or_default()
}

/// Assemble one already-split run into a Session.
fn session_from_run(path: &Path, idx: usize, run: &Run) -> Result<Session> {
    let (messages, touched) = parse_turns(&run.body);
    let key = make_key(&path.to_string_lossy(), idx);
    let project = path
        .parent()
        .map(|p| p.to_string_lossy().into_owned())
        .unwrap_or_default();
    Ok(Session {
        id: crate::util::short_id(&key),
        tool: "aider",
        path: std::path::PathBuf::from(display_path(&key)),
        project,
        started: run.started,
        ended: None,
        title: title_from(&messages),
        subagent: false,
        messages,
        touched,
        edits: Vec::new(),
    })
}

/// Read a history file, split it, and assemble the run at `idx` into a Session.
/// The indexer goes through the cached `parse_key` path instead; this direct
/// road is for tests.
#[cfg(test)]
fn build_session(path: &Path, idx: usize) -> Result<Session> {
    let content = crate::util::read_to_string_capped(path)
        .with_context(|| format!("read {}", path.display()))?;
    let runs = split_runs(&content);
    let run = runs
        .get(idx)
        .with_context(|| format!("no run {idx} in {}", path.display()))?;
    session_from_run(path, idx, run)
}

/// The last split history file, so indexing N runs of one file costs one read
/// and one split instead of N (the indexer asks for a file's runs one key at a
/// time, consecutively - re-reading the whole file per run made a long history
/// O(runs^2)). Keyed by (path, mtime): any rewrite invalidates it.
struct RunCache {
    path: PathBuf,
    mtime_ms: i64,
    runs: Vec<Run>,
}

#[derive(Default)]
pub struct Aider {
    cache: std::sync::Mutex<Option<RunCache>>,
}

impl Adapter for Aider {
    fn name(&self) -> &'static str {
        "aider"
    }

    /// Rootless: returns home (or the first configured root) so the tool appears
    /// in `scan`/`report`. Because that always exists, `store_present` is always
    /// true, so `Store.had_error` (not `store_present`) guards against archiving
    /// the corpus on a partial walk.
    fn root(&self) -> Option<PathBuf> {
        aider_roots().into_iter().next()
    }

    fn discover(&self) -> crate::adapters::Discovered {
        Vec::new().into() // shared-store adapter; see `store`
    }

    fn parse(&self, _path: &Path) -> Result<Session> {
        anyhow::bail!("aider is a shared-store adapter; use parse_key")
    }

    fn store(&self) -> Option<Store> {
        let (history_files, mut had_error) = discover_history(&aider_roots(), guarded_dirs());
        let mut keys: Vec<(String, i64)> = Vec::new();
        for path in &history_files {
            // token = file mtime (ms), shared across all runs of the file, so any
            // write re-parses the whole file (one capped read).
            let token = std::fs::metadata(path)
                .and_then(|m| m.modified())
                .ok()
                .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
                .map(|d| d.as_millis() as i64)
                .unwrap_or(0);
            let content = match crate::util::read_to_string_capped(path) {
                Ok(content) => content,
                Err(_) => {
                    // Discovery saw the history, but its complete run list
                    // could not be read. An empty key set is therefore partial
                    // and must not drive deletion reconciliation.
                    had_error = true;
                    continue;
                }
            };
            let runs = split_runs(&content);
            for idx in 0..runs.len() {
                keys.push((make_key(&path.to_string_lossy(), idx), token));
            }
        }
        Some(Store {
            keys,
            files: history_files,
            had_error,
        })
    }

    fn parse_key(&self, key: &str) -> Result<Session> {
        let (path, idx) = key.split_once(KEY_SEP).context("malformed aider key")?;
        let idx: usize = idx.parse().context("bad run index")?;
        let path = Path::new(path);
        let mtime_ms = std::fs::metadata(path)
            .and_then(|m| m.modified())
            .ok()
            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
            .map(|d| d.as_millis() as i64)
            .unwrap_or(0);
        let mut guard = self.cache.lock().unwrap();
        let hit = guard
            .as_ref()
            .is_some_and(|c| c.path == path && c.mtime_ms == mtime_ms);
        if !hit {
            let content = crate::util::read_to_string_capped(path)
                .with_context(|| format!("read {}", path.display()))?;
            *guard = Some(RunCache {
                path: path.to_path_buf(),
                mtime_ms,
                runs: split_runs(&content),
            });
        }
        let cache = guard.as_ref().expect("cache filled above");
        let run = cache
            .runs
            .get(idx)
            .with_context(|| format!("no run {idx} in {}", path.display()))?;
        session_from_run(path, idx, run)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn split_runs_keeps_empty_and_drops_preamble() {
        let c = "junk before any header\n\
                 # aider chat started at 2026-06-09 14:01:00\n\
                 #### hi\n\
                 answer\n\
                 # aider chat started at 2026-06-09 15:00:00\n\
                 # aider chat started at 2026-06-09 16:00:00\n\
                 #### again\n";
        let runs = split_runs(c);
        assert_eq!(runs.len(), 3, "empty middle run keeps its slot");
        assert_eq!(runs[0].started, parse_aider_ts("2026-06-09 14:01:00"));
        assert!(
            runs[1].body.trim().is_empty(),
            "header-only run has empty body"
        );
        assert!(runs[0].body.contains("#### hi"));
    }

    #[test]
    fn parse_aider_ts_handles_naive_local_as_utc_and_rejects_garbage() {
        assert!(parse_aider_ts("2026-06-09 14:01:00").is_some());
        assert!(parse_aider_ts("not a date").is_none());
    }

    #[test]
    fn assistant_markdown_and_tool_lines_classified() {
        let body = "#### fix the bug\n\
                    Here is the fix.\n\
                    Some prose.\n\
                    > Applied edit to src/a.py\n\
                    > Applied edit to src/a.py\n\
                    > Creating empty file src/b.py\n\
                    > Did not apply edit to src/c.py (--dry-run)\n";
        let (msgs, touched) = parse_turns(body);
        let roles: Vec<Role> = msgs.iter().map(|m| m.role).collect();
        assert_eq!(roles, vec![Role::User, Role::Assistant, Role::Tool]);
        assert_eq!(msgs[0].text, "fix the bug");
        assert!(msgs[1].text.contains("Here is the fix."));
        assert_eq!(touched, vec!["src/a.py", "src/b.py"]); // dedup; dry-run ignored
        assert!(msgs.iter().all(|m| m.ts.is_none()));
    }

    #[test]
    fn blank_lines_do_not_split_an_assistant_message() {
        let body = "#### q\n\
                    para one\n\
                    \n\
                    para two\n";
        let (msgs, _) = parse_turns(body);
        assert_eq!(msgs.len(), 2);
        assert_eq!(msgs[1].role, Role::Assistant);
        assert!(msgs[1].text.contains("para one") && msgs[1].text.contains("para two"));
    }

    #[test]
    fn build_session_from_a_fixture_file() {
        use std::io::Write;
        let dir = std::env::temp_dir().join("sw-aider-t3");
        let _ = std::fs::remove_dir_all(&dir);
        let repo = dir.join("myrepo");
        std::fs::create_dir_all(&repo).unwrap();
        let path = repo.join(".aider.chat.history.md");
        let mut f = std::fs::File::create(&path).unwrap();
        write!(
            f,
            "# aider chat started at 2026-06-09 14:01:00\n#### add retry\nDone.\n> Applied edit to src/x.py\n"
        )
        .unwrap();

        let s = build_session(&path, 0).unwrap();
        assert_eq!(s.tool, "aider");
        assert_eq!(s.project, repo.to_string_lossy());
        assert_eq!(s.title, "add retry");
        assert_eq!(s.touched, vec!["src/x.py"]);
        assert!(s.started.is_some());
        assert!(!s.subagent);
        // id is stable for the same (path, idx)
        assert_eq!(s.id, build_session(&path, 0).unwrap().id);
    }

    #[test]
    fn discover_finds_history_and_skips_node_modules() {
        let dir = tempfile::tempdir().unwrap();
        let repo = dir.path().join("proj");
        std::fs::create_dir_all(repo.join("node_modules/skipme")).unwrap();
        std::fs::write(repo.join(".aider.chat.history.md"), "x\n").unwrap();
        std::fs::write(repo.join("README.md"), "x\n").unwrap();
        std::fs::write(
            repo.join("node_modules/skipme/.aider.chat.history.md"),
            "x\n",
        )
        .unwrap();

        let (files, had_error) = discover_history(&[dir.path().to_path_buf()], &[]);
        assert_eq!(files.len(), 1, "found the repo file, skipped node_modules");
        assert!(files[0].ends_with(".aider.chat.history.md"));
        assert!(!had_error);
    }

    /// macOS asks before any process reads Pictures (Photos), Music or Movies,
    /// and the default walk of home entered them: on one Mac, 23 Photos
    /// prompts in three days for an adapter that found nothing there. Those
    /// media folders are skipped directly under the walked home; Documents and
    /// other folders where projects live are still walked.
    #[test]
    fn the_default_walk_skips_folders_macos_guards() {
        let home = tempfile::tempdir().unwrap();
        for guarded in MACOS_GUARDED_HOME_DIRS.iter().chain(&["Documents"]) {
            let repo = home.path().join(guarded).join("proj");
            std::fs::create_dir_all(&repo).unwrap();
            std::fs::write(repo.join(HISTORY_FILE), "x\n").unwrap();
        }
        let code = home.path().join("code/proj");
        std::fs::create_dir_all(&code).unwrap();
        std::fs::write(code.join(HISTORY_FILE), "x\n").unwrap();
        // A folder with a guarded name deeper down is just a folder.
        let nested = home.path().join("code/Pictures");
        std::fs::create_dir_all(&nested).unwrap();
        std::fs::write(nested.join(HISTORY_FILE), "x\n").unwrap();

        let (files, had_error) =
            discover_history(&[home.path().to_path_buf()], MACOS_GUARDED_HOME_DIRS);
        let mut found: Vec<_> = files
            .iter()
            .map(|f| f.strip_prefix(home.path()).unwrap().to_path_buf())
            .collect();
        found.sort();
        assert_eq!(
            found,
            [
                std::path::PathBuf::from("Documents/proj").join(HISTORY_FILE),
                std::path::PathBuf::from("code/Pictures").join(HISTORY_FILE),
                std::path::PathBuf::from("code/proj").join(HISTORY_FILE),
            ]
        );
        assert!(!had_error);
    }
}