1use crate::adapters::{self, Adapter};
2use anyhow::{Context, Result};
3use rusqlite::{params, Connection};
4use serde::Serialize;
5use std::collections::HashMap;
6use std::io::{IsTerminal, Write};
7use std::path::PathBuf;
8
9fn legacy_candidates(dir: &std::path::Path) -> Vec<PathBuf> {
18 let Some(parent) = dir.parent() else {
19 return Vec::new();
20 };
21 ["sessiondex", "session-atlas"]
22 .iter()
23 .map(|n| parent.join(n))
24 .collect()
25}
26
27pub fn db_path() -> Result<PathBuf> {
30 let dir = std::env::var_os("SESSIONWIKI_DATA")
31 .map(PathBuf::from)
32 .or_else(|| dirs::data_dir().map(|d| d.join("sessionwiki")))
33 .context("cannot determine a data directory")?;
34 if !dir.exists() {
38 {
39 for old in legacy_candidates(&dir) {
40 if old.exists() {
41 if let Err(e) = std::fs::rename(&old, &dir) {
47 if !dir.exists() && old.exists() {
48 eprintln!(
49 "warning: could not migrate {} -> {} ({e}); \
50 starting a fresh index. Move it manually to \
51 keep your tags, notes, and archive.",
52 old.display(),
53 dir.display()
54 );
55 }
56 }
57 break;
58 }
59 }
60 }
61 }
62 std::fs::create_dir_all(&dir)?;
63 Ok(dir.join("index.db"))
64}
65
66pub fn existing_db_path() -> Option<PathBuf> {
70 let dir = std::env::var_os("SESSIONWIKI_DATA")
71 .map(PathBuf::from)
72 .or_else(|| dirs::data_dir().map(|d| d.join("sessionwiki")))?;
73 let db = dir.join("index.db");
74 db.exists().then_some(db)
75}
76
77pub const SCHEMA_VERSION: i64 = 8; const BASELINE_DURABLE_VERSION: i64 = 1;
90
91fn read_or_init_durable_version(conn: &Connection) -> Result<i64> {
96 conn.execute(
97 "INSERT OR IGNORE INTO meta(key, value) VALUES ('durable_version', ?1)",
98 params![BASELINE_DURABLE_VERSION.to_string()],
99 )?;
100 let v: String = conn.query_row(
101 "SELECT value FROM meta WHERE key = 'durable_version'",
102 [],
103 |r| r.get(0),
104 )?;
105 Ok(v.parse().unwrap_or(BASELINE_DURABLE_VERSION))
106}
107
108enum MigrationStep {
112 #[allow(dead_code)]
115 Sql(&'static str),
116 Fix(fn(&Connection) -> Result<()>),
117}
118
119struct Migration {
120 version: i64,
121 step: MigrationStep,
122}
123
124const DURABLE_MIGRATIONS: &[Migration] = &[
128 Migration {
132 version: 2,
133 step: MigrationStep::Fix(normalize_stored_tags),
134 },
135];
136
137fn normalize_stored_tags(conn: &Connection) -> Result<()> {
140 let rows: Vec<(String, String)> = conn
141 .prepare("SELECT session_id, tag FROM tags")?
142 .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
143 .collect::<rusqlite::Result<_>>()?;
144 for (sid, tag) in rows {
145 let norm = norm_tag(&tag);
146 if norm != tag {
147 conn.execute(
148 "INSERT OR IGNORE INTO tags(session_id, tag) VALUES (?1, ?2)",
149 params![sid, norm],
150 )?;
151 conn.execute(
152 "DELETE FROM tags WHERE session_id = ?1 AND tag = ?2",
153 params![sid, tag],
154 )?;
155 }
156 }
157 Ok(())
158}
159
160fn run_durable_migrations(conn: &Connection, migrations: &[Migration]) -> Result<()> {
166 conn.execute_batch("BEGIN IMMEDIATE")?;
167 let outcome = (|| -> Result<()> {
168 let current: i64 = conn
169 .query_row(
170 "SELECT value FROM meta WHERE key = 'durable_version'",
171 [],
172 |r| r.get::<_, String>(0),
173 )?
174 .parse()
175 .unwrap_or(BASELINE_DURABLE_VERSION);
176 for m in migrations.iter().filter(|m| m.version > current) {
177 match m.step {
178 MigrationStep::Sql(sql) => conn.execute_batch(sql)?,
179 MigrationStep::Fix(f) => f(conn)?,
180 }
181 conn.execute(
182 "UPDATE meta SET value = ?1 WHERE key = 'durable_version'",
183 params![m.version.to_string()],
184 )?;
185 }
186 Ok(())
187 })();
188 match outcome {
189 Ok(()) => conn.execute_batch("COMMIT")?,
190 Err(e) => {
191 let _ = conn.execute_batch("ROLLBACK");
192 return Err(e);
193 }
194 }
195 Ok(())
196}
197
198fn create_cache_schema(conn: &Connection) -> Result<()> {
201 conn.execute_batch(
202 "CREATE TABLE IF NOT EXISTS files(
203 path TEXT PRIMARY KEY,
204 mtime INTEGER NOT NULL,
205 size INTEGER NOT NULL,
206 session_id TEXT NOT NULL,
207 tool TEXT NOT NULL,
208 project TEXT NOT NULL DEFAULT '',
209 title TEXT NOT NULL DEFAULT '',
210 started TEXT,
211 ended TEXT,
212 msg_count INTEGER NOT NULL DEFAULT 0,
213 kind TEXT NOT NULL DEFAULT 'main',
214 -- Set when the tool deleted the original session file but we kept
215 -- the indexed copy (archive mode). NULL for live sessions.
216 archived_at TEXT
217 );
218 CREATE INDEX IF NOT EXISTS idx_files_session ON files(session_id);
219 -- Plain rows + external-content FTS. Deleting a session is an
220 -- indexed lookup here; with session_id stored UNINDEXED inside the
221 -- FTS table it was a full scan per file, which made re-index runs
222 -- quadratic in practice.
223 CREATE TABLE IF NOT EXISTS messages(
224 id INTEGER PRIMARY KEY,
225 session_id TEXT NOT NULL,
226 role TEXT NOT NULL,
227 text TEXT NOT NULL
228 );
229 CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id);
230 CREATE VIRTUAL TABLE IF NOT EXISTS msgs USING fts5(
231 text,
232 content='messages',
233 content_rowid='id',
234 tokenize='trigram'
235 );
236 CREATE TABLE IF NOT EXISTS summaries(
237 session_id TEXT PRIMARY KEY,
238 summary TEXT NOT NULL,
239 created TEXT NOT NULL
240 );
241 -- Curation layer (the editable 'wiki' part). Like summaries, these
242 -- are user-authored and survive index rebuilds: only files/messages/
243 -- msgs are dropped on a schema bump, never these.
244 CREATE TABLE IF NOT EXISTS tags(
245 session_id TEXT NOT NULL,
246 tag TEXT NOT NULL,
247 PRIMARY KEY (session_id, tag)
248 );
249 CREATE INDEX IF NOT EXISTS idx_tags_tag ON tags(tag);
250 CREATE TABLE IF NOT EXISTS notes(
251 session_id TEXT PRIMARY KEY,
252 note TEXT NOT NULL,
253 updated TEXT NOT NULL
254 );
255 -- Archive (durable, never dropped on a schema bump). When the tool
256 -- deletes a session's original file, we keep a self-contained copy
257 -- here - the distilled transcript and provenance plus the metadata
258 -- needed to reconstruct the files row. This is the only table that is
259 -- not re-derivable from disk, so on a schema bump the cache tables are
260 -- rehydrated from it. Live sessions are NOT stored here.
261 CREATE TABLE IF NOT EXISTS archive(
262 session_id TEXT PRIMARY KEY,
263 path TEXT NOT NULL,
264 mtime INTEGER NOT NULL,
265 size INTEGER NOT NULL,
266 tool TEXT NOT NULL,
267 project TEXT NOT NULL DEFAULT '',
268 title TEXT NOT NULL DEFAULT '',
269 started TEXT,
270 ended TEXT,
271 msg_count INTEGER NOT NULL DEFAULT 0,
272 kind TEXT NOT NULL DEFAULT 'main',
273 transcript TEXT NOT NULL, -- JSON [[role,text],...] in order
274 touched TEXT NOT NULL, -- JSON [path,...]
275 archived_at TEXT NOT NULL
276 );
277 -- Provenance: which files each session edited or created, from its
278 -- tool calls. Rebuilt from the sessions on sync, so it is dropped on a
279 -- schema bump like messages - not curated. The path index powers
280 -- `trace` (sessions for a file) and shared-file relatedness.
281 CREATE TABLE IF NOT EXISTS touched(
282 session_id TEXT NOT NULL,
283 path TEXT NOT NULL,
284 PRIMARY KEY (session_id, path)
285 );
286 CREATE INDEX IF NOT EXISTS idx_touched_path ON touched(path);
287 -- Durable key/value scratchpad. Holds `durable_version` (the durable-
288 -- schema version, separate from user_version). Never dropped.
289 -- Evidence layer over `touched`: the concrete edits (kind + a bounded
290 -- snippet of the change) behind each touched path. A log - multiple rows
291 -- per (session, path) - so the whole change history of a file survives.
292 -- Derived from the sessions on sync, dropped on a schema bump like
293 -- touched. Powers `edits_for` and the file-history page.
294 CREATE TABLE IF NOT EXISTS edits(
295 session_id TEXT NOT NULL,
296 path TEXT NOT NULL,
297 kind TEXT NOT NULL,
298 ts TEXT,
299 snippet TEXT NOT NULL
300 );
301 CREATE INDEX IF NOT EXISTS idx_edits_path ON edits(path);
302 CREATE INDEX IF NOT EXISTS idx_edits_session ON edits(session_id);
303 CREATE TABLE IF NOT EXISTS meta(
304 key TEXT PRIMARY KEY,
305 value TEXT NOT NULL
306 );",
307 )?;
308 Ok(())
309}
310
311#[cfg(test)]
312mod migration_tests {
313 use super::*;
314
315 fn mem() -> Connection {
316 let c = Connection::open_in_memory().unwrap();
317 c.execute_batch(
318 "CREATE TABLE meta(key TEXT PRIMARY KEY, value TEXT NOT NULL);
319 CREATE TABLE notes(session_id TEXT PRIMARY KEY, note TEXT NOT NULL, updated TEXT NOT NULL);
320 INSERT INTO meta VALUES('durable_version','1');
321 INSERT INTO notes VALUES('s1','keep me','t');",
322 )
323 .unwrap();
324 c
325 }
326
327 #[test]
328 fn runner_applies_gated_and_is_idempotent() {
329 let c = mem();
330 let migs = [Migration {
331 version: 2,
332 step: MigrationStep::Sql("ALTER TABLE notes ADD COLUMN pinned INTEGER"),
333 }];
334 run_durable_migrations(&c, &migs).unwrap();
335 let cols: Vec<String> = c
336 .prepare("SELECT name FROM pragma_table_info('notes')")
337 .unwrap()
338 .query_map([], |r| r.get(0))
339 .unwrap()
340 .map(|r| r.unwrap())
341 .collect();
342 assert!(cols.contains(&"pinned".to_string()), "column added");
343 let v: String = c
344 .query_row(
345 "SELECT value FROM meta WHERE key='durable_version'",
346 [],
347 |r| r.get(0),
348 )
349 .unwrap();
350 assert_eq!(v, "2", "version advanced");
351 let note: String = c
352 .query_row("SELECT note FROM notes WHERE session_id='s1'", [], |r| {
353 r.get(0)
354 })
355 .unwrap();
356 assert_eq!(note, "keep me", "existing durable row preserved");
357 run_durable_migrations(&c, &migs).unwrap();
359 }
360}
361
362pub fn open_readonly() -> Result<Connection> {
366 use rusqlite::OpenFlags;
367 let conn = Connection::open_with_flags(
368 db_path()?,
369 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
370 )?;
371 conn.busy_timeout(std::time::Duration::from_millis(5000))?;
372 Ok(conn)
373}
374
375pub fn open() -> Result<Connection> {
376 let conn = Connection::open(db_path()?)?;
377 conn.pragma_update(None, "journal_mode", "WAL")?;
378 conn.pragma_update(None, "synchronous", "NORMAL")?;
379 conn.busy_timeout(std::time::Duration::from_millis(5000))?;
380 let version: i64 = conn.pragma_query_value(None, "user_version", |r| r.get(0))?;
381 let bumped = version != SCHEMA_VERSION;
382 if bumped {
383 conn.execute_batch(
388 "DROP TABLE IF EXISTS msgs;
389 DROP TABLE IF EXISTS messages;
390 DROP TABLE IF EXISTS touched;
391 DROP TABLE IF EXISTS files;
392 DROP TABLE IF EXISTS edits;",
393 )?;
394 conn.pragma_update(None, "user_version", SCHEMA_VERSION)?;
395 }
396 create_cache_schema(&conn)?;
397 let durable = read_or_init_durable_version(&conn)?;
400 let latest = DURABLE_MIGRATIONS
401 .iter()
402 .map(|m| m.version)
403 .max()
404 .unwrap_or(BASELINE_DURABLE_VERSION);
405 if durable < latest {
406 let bak = db_path()?.with_extension(format!("db.bak-v{durable}"));
408 let _ = std::fs::remove_file(&bak);
409 conn.execute("VACUUM INTO ?1", params![bak.to_string_lossy()])?;
410 run_durable_migrations(&conn, DURABLE_MIGRATIONS)?;
411 }
412 let arch_total: i64 = conn.query_row("SELECT count(*) FROM archive", [], |r| r.get(0))?;
416 let arch_live: i64 = conn.query_row(
417 "SELECT count(*) FROM files WHERE archived_at IS NOT NULL",
418 [],
419 |r| r.get(0),
420 )?;
421 if arch_total > arch_live {
422 rehydrate_archive(&conn)?;
423 }
424 Ok(conn)
425}
426
427fn rehydrate_archive(conn: &Connection) -> Result<()> {
433 let mut stmt = conn.prepare(
434 "SELECT session_id, path, tool, project, title, started, ended,
435 kind, transcript, touched, archived_at FROM archive",
436 )?;
437 let rows: Vec<ArchiveRow> = stmt
438 .query_map([], |r| {
439 Ok(ArchiveRow {
440 session_id: r.get(0)?,
441 path: r.get(1)?,
442 tool: r.get(2)?,
443 project: r.get(3)?,
444 title: r.get(4)?,
445 started: r.get(5)?,
446 ended: r.get(6)?,
447 kind: r.get(7)?,
448 transcript: r.get(8)?,
449 touched: r.get(9)?,
450 archived_at: r.get(10)?,
451 })
452 })?
453 .collect::<rusqlite::Result<_>>()?;
454
455 for a in rows {
456 let msgs: Vec<(String, String)> = match serde_json::from_str(&a.transcript) {
460 Ok(m) => m,
461 Err(e) => {
462 eprintln!(
463 "archive: skipping {} - unreadable transcript ({e})",
464 a.session_id
465 );
466 continue;
467 }
468 };
469 let paths: Vec<String> = serde_json::from_str(&a.touched).unwrap_or_else(|e| {
470 eprintln!("archive: {} has unreadable provenance ({e})", a.session_id);
471 Vec::new()
472 });
473
474 delete_session_msgs(conn, &a.session_id)?;
477 delete_session_provenance(conn, &a.session_id)?;
478 conn.execute(
479 "DELETE FROM files WHERE session_id = ?1",
480 params![a.session_id],
481 )?;
482
483 conn.execute(
488 "INSERT INTO files
489 (path, mtime, size, session_id, tool, project, title, started, ended,
490 msg_count, kind, archived_at)
491 VALUES (?1,0,0,?2,?3,?4,?5,?6,?7,?8,?9,?10)",
492 params![
493 a.path,
494 a.session_id,
495 a.tool,
496 crate::util::nfc(&a.project),
497 a.title,
498 a.started,
499 a.ended,
500 msgs.len() as i64,
501 a.kind,
502 a.archived_at,
503 ],
504 )?;
505 {
506 let mut ins_row = conn
507 .prepare_cached("INSERT INTO messages(session_id, role, text) VALUES (?1,?2,?3)")?;
508 let mut ins_fts =
509 conn.prepare_cached("INSERT INTO msgs(rowid, text) VALUES (?1,?2)")?;
510 for (role, text) in &msgs {
511 let text = crate::redact::redact(&crate::util::nfc(text)).into_owned();
515 ins_row.execute(params![a.session_id, role, text])?;
516 ins_fts.execute(params![conn.last_insert_rowid(), text])?;
517 }
518 }
519 let mut ins_touched =
520 conn.prepare_cached("INSERT OR IGNORE INTO touched(session_id, path) VALUES (?1,?2)")?;
521 for p in &paths {
522 ins_touched.execute(params![a.session_id, crate::util::nfc(p)])?;
523 }
524 }
525 Ok(())
526}
527
528struct ArchiveRow {
529 session_id: String,
530 path: String,
531 tool: String,
532 project: String,
533 title: String,
534 started: Option<String>,
535 ended: Option<String>,
536 kind: String,
537 transcript: String,
538 touched: String,
539 archived_at: String,
540}
541
542fn index_one(
549 tx: &rusqlite::Transaction,
550 session: &crate::model::Session,
551 key: &str,
552 mtime: i64,
553 size: i64,
554) -> Result<()> {
555 delete_session_msgs(tx, &session.id)?;
556 delete_session_provenance(tx, &session.id)?;
557 tx.execute(
560 "DELETE FROM archive WHERE session_id = ?1",
561 params![session.id],
562 )?;
563 tx.execute(
564 "INSERT OR REPLACE INTO files
565 (path, mtime, size, session_id, tool, project, title, started, ended, msg_count, kind)
566 VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11)",
567 params![
568 key,
569 mtime,
570 size,
571 session.id,
572 session.tool,
573 crate::util::nfc(&session.project),
574 crate::redact::redact(&session.title).as_ref(),
575 session.started.map(|t| t.to_rfc3339()),
576 session.ended.map(|t| t.to_rfc3339()),
577 session.messages.len() as i64,
578 if session.subagent { "sub" } else { "main" },
579 ],
580 )?;
581 {
585 let mut ins_row =
586 tx.prepare_cached("INSERT INTO messages(session_id, role, text) VALUES (?1,?2,?3)")?;
587 let mut ins_fts = tx.prepare_cached("INSERT INTO msgs(rowid, text) VALUES (?1,?2)")?;
588 for m in &session.messages {
589 let text = crate::redact::redact(&crate::util::nfc(&m.text)).into_owned();
594 ins_row.execute(params![session.id, m.role.label(), text])?;
595 ins_fts.execute(params![tx.last_insert_rowid(), text])?;
596 }
597 let mut ins_touched =
598 tx.prepare_cached("INSERT OR IGNORE INTO touched(session_id, path) VALUES (?1,?2)")?;
599 for p in &session.touched {
600 ins_touched.execute(params![session.id, crate::util::nfc(p)])?;
601 }
602 let mut ins_edit = tx.prepare_cached(
605 "INSERT INTO edits(session_id, path, kind, ts, snippet) VALUES (?1,?2,?3,?4,?5)",
606 )?;
607 for e in &session.edits {
608 ins_edit.execute(params![
609 session.id,
610 crate::util::nfc(&e.path),
611 e.kind.as_str(),
612 e.ts.map(|t| t.to_rfc3339()),
613 crate::redact::redact(&e.snippet).as_ref(),
614 ])?;
615 }
616 }
617 if matches!(session.tool, "claude-code" | "codex" | "opencode") {
620 if let Some(h) = crate::adapters::harness::detect(&session.project) {
621 add_tag(tx, &session.id, h)?;
622 }
623 }
624 Ok(())
625}
626
627fn report_indexed(tool: &str, total: usize, failed: usize) {
630 if failed > 0 {
631 eprintln!(
632 "\r[{tool}] indexed {}/{total} ({failed} failed to parse) ",
633 total - failed
634 );
635 } else {
636 eprintln!("\r[{tool}] indexed {total}/{total} ");
637 }
638}
639
640pub fn sync(conn: &mut Connection, only_tool: Option<&str>) -> Result<()> {
641 sync_bounded(conn, only_tool, None)
642}
643
644pub fn sync_bounded(
651 conn: &mut Connection,
652 only_tool: Option<&str>,
653 since: Option<i64>,
654) -> Result<()> {
655 let adapters: Vec<Box<dyn Adapter>> = match only_tool {
656 Some(t) => adapters::by_name(t).into_iter().collect(),
657 None => adapters::all(),
658 };
659 sync_with(conn, &adapters, since)
660}
661
662pub fn sync_with(
668 conn: &mut Connection,
669 adapters: &[Box<dyn Adapter>],
670 since: Option<i64>,
671) -> Result<()> {
672 let progress = std::io::stderr().is_terminal();
677 let mut known: HashMap<String, (i64, i64)> = HashMap::new();
678 {
679 let mut stmt = conn.prepare("SELECT path, mtime, size FROM files")?;
680 let rows = stmt.query_map([], |r| {
681 Ok((
682 r.get::<_, String>(0)?,
683 (r.get::<_, i64>(1)?, r.get::<_, i64>(2)?),
684 ))
685 })?;
686 for row in rows {
687 let (path, ms) = row?;
688 known.insert(path, ms);
689 }
690 }
691
692 let mut archived_total = 0usize;
693 for adapter in adapters {
694 let tool = adapter.name();
698 let store_present = adapter.root().is_some_and(|r| r.exists());
699
700 if let Some(store) = adapter.store() {
705 let mut seen: Vec<String> = Vec::with_capacity(store.keys.len());
706 let mut pending: Vec<String> = Vec::new();
707 for (key, token) in &store.keys {
708 if known.get(key) != Some(&(*token, 0)) && since.is_none_or(|s| *token >= s) {
709 pending.push(key.clone());
710 }
711 seen.push(key.clone());
712 }
713 if store.had_error {
718 eprintln!(
724 "[{tool}] the store could not be read in full; \
725 skipping deletion reconciliation this run"
726 );
727 } else {
728 archived_total += archive_or_prune(
729 conn,
730 tool,
731 &seen,
732 store_present,
733 adapter.reconcile_scope().as_deref(),
734 )?;
735 }
736
737 if !pending.is_empty() {
738 let token_of: HashMap<&str, i64> =
739 store.keys.iter().map(|(k, t)| (k.as_str(), *t)).collect();
740 let total = pending.len();
741 let mut failed = 0usize;
742 let tx = conn.transaction()?;
743 for (i, key) in pending.iter().enumerate() {
744 if progress {
745 eprint!("\r[{tool}] indexing {}/{total}", i + 1);
746 std::io::stderr().flush().ok();
747 }
748 let session = match adapter.parse_key(key) {
751 Ok(s) => s,
752 Err(e) => {
753 failed += 1;
754 eprintln!("\r[{tool}] failed to parse {key}: {e:#}");
755 continue;
756 }
757 };
758 let token = token_of.get(key.as_str()).copied().unwrap_or(0);
759 index_one(&tx, &session, key, token, 0)?;
760 }
761 tx.commit()?;
762 report_indexed(tool, total, failed);
763 }
764 continue;
765 }
766
767 let discovered = adapter.discover();
768 let mut seen: Vec<String> = Vec::with_capacity(discovered.files.len());
769 let mut pending: Vec<(PathBuf, i64, i64)> = Vec::new();
770
771 for f in discovered.files {
772 let meta = match f.metadata() {
773 Ok(m) => m,
774 Err(e) => {
775 eprintln!("\r[{tool}] failed to stat {}: {e}", f.display());
780 seen.push(f.to_string_lossy().into_owned());
781 continue;
782 }
783 };
784 let mtime = meta
785 .modified()
786 .ok()
787 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
788 .map(|d| d.as_secs() as i64)
789 .unwrap_or(0);
790 let size = meta.len() as i64;
791 let key = f.to_string_lossy().into_owned();
792 if known.get(&key) != Some(&(mtime, size)) && since.is_none_or(|s| mtime >= s) {
793 pending.push((f, mtime, size));
794 }
795 seen.push(key);
796 }
797
798 if discovered.had_error {
802 eprintln!(
803 "[{tool}] some session directories could not be read; \
804 skipping deletion reconciliation this run"
805 );
806 } else {
807 archived_total += archive_or_prune(
808 conn,
809 tool,
810 &seen,
811 store_present,
812 adapter.reconcile_scope().as_deref(),
813 )?;
814 }
815
816 if pending.is_empty() {
817 continue;
818 }
819 let total = pending.len();
820 let mut done = 0usize;
821 let mut failed = 0usize;
822 let tx = conn.transaction()?;
823 for (path, mtime, size) in pending {
824 done += 1;
825 if progress {
826 eprint!("\r[{tool}] indexing {done}/{total}");
827 std::io::stderr().flush().ok();
828 }
829
830 let session = match adapter.parse(&path) {
833 Ok(s) => s,
834 Err(e) => {
835 failed += 1;
836 eprintln!("\r[{tool}] failed to parse {}: {e:#}", path.display());
837 continue;
838 }
839 };
840 let key = path.to_string_lossy();
841 index_one(&tx, &session, &key, mtime, size)?;
842 }
843 tx.commit()?;
844 report_indexed(tool, total, failed);
845 }
846
847 if archived_total > 0 {
850 let kept: i64 = conn.query_row(
851 "SELECT count(*) FROM files WHERE archived_at IS NOT NULL",
852 [],
853 |r| r.get(0),
854 )?;
855 eprintln!(
856 "archived {archived_total} session(s) the tool removed ({kept} kept that your tools have deleted)"
857 );
858 }
859 Ok(())
860}
861
862fn delete_session_msgs(conn: &Connection, session_id: &str) -> Result<()> {
864 conn.execute(
865 "INSERT INTO msgs(msgs, rowid, text)
866 SELECT 'delete', id, text FROM messages WHERE session_id = ?1",
867 params![session_id],
868 )?;
869 conn.execute(
870 "DELETE FROM messages WHERE session_id = ?1",
871 params![session_id],
872 )?;
873 Ok(())
874}
875
876fn delete_session_provenance(conn: &Connection, session_id: &str) -> Result<()> {
880 conn.execute(
881 "DELETE FROM touched WHERE session_id = ?1",
882 params![session_id],
883 )?;
884 conn.execute(
885 "DELETE FROM edits WHERE session_id = ?1",
886 params![session_id],
887 )?;
888 Ok(())
889}
890
891fn archive_or_prune(
902 conn: &Connection,
903 tool: &str,
904 seen: &[String],
905 store_present: bool,
906 scope: Option<&str>,
907) -> Result<usize> {
908 let no_archive = std::env::var_os("SESSIONWIKI_NO_ARCHIVE").is_some();
909 let in_scope = |key: &str| scope.is_none_or(|p| key.starts_with(p));
914 let seen_set: std::collections::HashSet<&str> = seen
915 .iter()
916 .map(String::as_str)
917 .filter(|k| in_scope(k))
918 .collect();
919
920 let mut stmt =
921 conn.prepare("SELECT path, session_id FROM files WHERE tool = ?1 AND archived_at IS NULL")?;
922 let all_live: Vec<(String, String)> = stmt
923 .query_map(params![tool], |r| Ok((r.get(0)?, r.get(1)?)))?
924 .collect::<rusqlite::Result<_>>()?;
925 let live: Vec<(String, String)> = all_live.into_iter().filter(|(p, _)| in_scope(p)).collect();
926 let gone: Vec<(String, String)> = live
927 .into_iter()
928 .filter(|(p, _)| !seen_set.contains(p.as_str()))
929 .collect();
930 if gone.is_empty() {
931 return Ok(0);
932 }
933 if !store_present {
934 eprintln!(
935 "[{tool}] store not found - skipping ({} indexed session(s) left untouched, not archived)",
936 gone.len()
937 );
938 return Ok(0);
939 }
940 if seen_set.is_empty() {
945 eprintln!(
946 "[{tool}] no sessions found on disk but {} were indexed - archiving them; \
947 if the store is just unreadable right now, they will un-archive on the next sync",
948 gone.len()
949 );
950 }
951
952 let mut archived = 0usize;
953 for (path, sid) in gone {
954 if no_archive {
955 conn.execute("DELETE FROM files WHERE path = ?1", params![path])?;
956 delete_session_msgs(conn, &sid)?;
957 delete_session_provenance(conn, &sid)?;
958 } else {
959 archive_session(conn, &path, &sid)?;
960 archived += 1;
961 }
962 }
963 Ok(archived)
964}
965
966fn archive_session(conn: &Connection, path: &str, sid: &str) -> Result<()> {
971 let mut s =
972 conn.prepare("SELECT role, text FROM messages WHERE session_id = ?1 ORDER BY id")?;
973 let transcript: Vec<(String, String)> = s
974 .query_map(params![sid], |r| Ok((r.get(0)?, r.get(1)?)))?
975 .collect::<rusqlite::Result<_>>()?;
976 drop(s);
977 let mut s = conn.prepare("SELECT path FROM touched WHERE session_id = ?1 ORDER BY rowid")?;
978 let touched: Vec<String> = s
979 .query_map(params![sid], |r| r.get(0))?
980 .collect::<rusqlite::Result<_>>()?;
981 drop(s);
982 let transcript_json = serde_json::to_string(&transcript)?;
983 let touched_json = serde_json::to_string(&touched)?;
984 conn.execute(
985 "INSERT OR REPLACE INTO archive
986 (session_id, path, mtime, size, tool, project, title, started, ended,
987 msg_count, kind, transcript, touched, archived_at)
988 SELECT session_id, path, mtime, size, tool, project, title, started, ended,
989 msg_count, kind, ?2, ?3, datetime('now')
990 FROM files WHERE path = ?1",
991 params![path, transcript_json, touched_json],
992 )?;
993 conn.execute(
994 "UPDATE files SET archived_at = datetime('now') WHERE path = ?1",
995 params![path],
996 )?;
997 Ok(())
998}
999
1000#[derive(Serialize)]
1007pub struct SessionRow {
1008 #[serde(rename = "id")]
1009 pub session_id: String,
1010 pub tool: String,
1011 #[serde(rename = "native_id", serialize_with = "ser_native_id")]
1015 pub path: String,
1016 pub project: String,
1017 pub title: String,
1018 pub started: Option<String>,
1019 #[serde(rename = "msgs")]
1020 pub msg_count: i64,
1021 pub kind: String,
1022 pub preview: Option<String>,
1025 pub summary: Option<String>,
1027 #[serde(serialize_with = "ser_tags")]
1029 pub tags: Option<String>,
1030 pub archived: bool,
1032 pub account: Option<String>,
1036}
1037
1038fn ser_tags<S: serde::Serializer>(tags: &Option<String>, s: S) -> Result<S::Ok, S::Error> {
1041 match tags {
1042 Some(t) => s.collect_seq(t.split(',')),
1043 None => s.serialize_none(),
1044 }
1045}
1046
1047fn ser_native_id<S: serde::Serializer>(path: &str, s: S) -> Result<S::Ok, S::Error> {
1051 match native_id_of(path) {
1052 Some(id) => s.serialize_some(&id),
1053 None => s.serialize_none(),
1054 }
1055}
1056
1057pub fn native_id_of(path: &str) -> Option<String> {
1066 let name = std::path::Path::new(path).file_name()?.to_string_lossy();
1067 find_uuid(&name)
1068}
1069
1070fn find_uuid(s: &str) -> Option<String> {
1073 let b = s.as_bytes();
1074 if b.len() < 36 {
1075 return None;
1076 }
1077 for start in 0..=b.len() - 36 {
1078 if is_uuid_bytes(&b[start..start + 36]) {
1079 return Some(s[start..start + 36].to_ascii_lowercase());
1080 }
1081 }
1082 None
1083}
1084
1085fn is_uuid_bytes(b: &[u8]) -> bool {
1088 b.len() == 36
1089 && b.iter().enumerate().all(|(i, &c)| match i {
1090 8 | 13 | 18 | 23 => c == b'-',
1091 _ => c.is_ascii_hexdigit(),
1092 })
1093}
1094
1095fn looks_like_native_prefix(q: &str) -> bool {
1101 let len = q.len();
1102 if !(4..=36).contains(&len) {
1103 return false;
1104 }
1105 if !q.bytes().all(|c| c.is_ascii_hexdigit() || c == b'-') {
1106 return false;
1107 }
1108 q.contains('-') || len <= 8
1110}
1111
1112const PREVIEW_SQL: &str = "(SELECT substr(m2.text, 1, 280) FROM messages m2
1115 WHERE m2.session_id = f.session_id AND m2.role = 'assistant'
1116 ORDER BY m2.id DESC LIMIT 1)";
1117
1118const SUMMARY_SQL: &str = "(SELECT s.summary FROM summaries s WHERE s.session_id = f.session_id)";
1119
1120const TAGS_SQL: &str =
1121 "(SELECT group_concat(t.tag, ',') FROM tags t WHERE t.session_id = f.session_id)";
1122
1123pub fn recent(
1124 conn: &Connection,
1125 limit: usize,
1126 tool: Option<&str>,
1127 project: Option<&str>,
1128 tag: Option<&str>,
1129 include_subagents: bool,
1130) -> Result<Vec<SessionRow>> {
1131 let mut sql = format!(
1132 "SELECT session_id, tool, path, project, title, started, msg_count, kind, {PREVIEW_SQL}, {SUMMARY_SQL}, {TAGS_SQL}, (archived_at IS NOT NULL)
1133 FROM files f WHERE 1=1",
1134 );
1135 let mut args: Vec<String> = Vec::new();
1136 if !include_subagents && tag.is_none() {
1140 sql.push_str(" AND kind = 'main'");
1141 }
1142 if let Some(t) = tool {
1143 sql.push_str(" AND tool = ?");
1144 args.push(t.to_string());
1145 }
1146 if let Some(p) = project {
1147 sql.push_str(" AND project LIKE ?");
1148 args.push(format!("%{}%", crate::util::nfc(p)));
1149 }
1150 if let Some(t) = tag {
1151 sql.push_str(
1152 " AND EXISTS (SELECT 1 FROM tags g WHERE g.session_id = f.session_id AND g.tag = ?)",
1153 );
1154 args.push(norm_tag(t)); }
1156 sql.push_str(&format!(" ORDER BY started DESC LIMIT {limit}"));
1157
1158 let mut stmt = conn.prepare(&sql)?;
1159 let rows = stmt.query_map(rusqlite::params_from_iter(args), |r| {
1160 Ok(SessionRow {
1161 session_id: r.get(0)?,
1162 tool: r.get(1)?,
1163 path: r.get(2)?,
1164 project: r.get(3)?,
1165 title: r.get(4)?,
1166 started: r.get(5)?,
1167 msg_count: r.get(6)?,
1168 kind: r.get(7)?,
1169 preview: r.get(8)?,
1170 summary: r.get(9)?,
1171 tags: r.get(10)?,
1172 archived: r.get(11)?,
1173 account: None,
1174 })
1175 })?;
1176 let mut out = rows.collect::<rusqlite::Result<Vec<_>>>()?;
1177 crate::account_link::annotate(out.iter_mut());
1178 Ok(out)
1179}
1180
1181pub fn project_brief(conn: &Connection, project: &str, limit: usize) -> Result<Vec<SessionRow>> {
1185 let p = crate::util::nfc(project.trim_end_matches('/'));
1186 let mut stmt = conn.prepare(&format!(
1187 "SELECT session_id, tool, path, project, title, started, msg_count, kind, {PREVIEW_SQL}, {SUMMARY_SQL}, {TAGS_SQL}, (archived_at IS NOT NULL)
1188 FROM files f
1189 WHERE f.project = ?1 AND f.kind = 'main'
1190 ORDER BY f.started DESC, f.session_id
1191 LIMIT ?2"
1192 ))?;
1193 let rows = stmt.query_map(params![p, limit as i64], |r| {
1194 Ok(SessionRow {
1195 session_id: r.get(0)?,
1196 tool: r.get(1)?,
1197 path: r.get(2)?,
1198 project: r.get(3)?,
1199 title: r.get(4)?,
1200 started: r.get(5)?,
1201 msg_count: r.get(6)?,
1202 kind: r.get(7)?,
1203 preview: r.get(8)?,
1204 summary: r.get(9)?,
1205 tags: r.get(10)?,
1206 archived: r.get(11)?,
1207 account: None,
1208 })
1209 })?;
1210 let mut out = rows.collect::<rusqlite::Result<Vec<_>>>()?;
1211 crate::account_link::annotate(out.iter_mut());
1212 Ok(out)
1213}
1214
1215pub struct Hit {
1216 pub row: SessionRow,
1217 pub role: String,
1218 pub snippet: String,
1219}
1220
1221pub fn search(
1224 conn: &Connection,
1225 query: &str,
1226 limit: usize,
1227 tool: Option<&str>,
1228 project: Option<&str>,
1229) -> Result<Vec<Hit>> {
1230 let fts_query = format!("\"{}\"", crate::util::nfc(query).replace('"', "\"\""));
1235
1236 let mut sql = String::from(
1244 "SELECT f.session_id, f.tool, f.path, f.project, f.title, f.started, f.msg_count, f.kind,
1245 m.role, x.snip, min(x.rank) AS best, (f.archived_at IS NOT NULL)
1246 FROM (SELECT rowid AS mid,
1247 snippet(msgs, 0, char(2), char(3), char(8230), 18) AS snip,
1248 rank
1249 FROM msgs WHERE msgs MATCH ? ORDER BY rank LIMIT 4000) x
1250 JOIN messages m ON m.id = x.mid
1251 JOIN files f ON f.session_id = m.session_id
1252 WHERE 1=1",
1253 );
1254 let mut args: Vec<String> = vec![fts_query];
1255 if let Some(t) = tool {
1256 sql.push_str(" AND f.tool = ?");
1257 args.push(t.to_string());
1258 }
1259 if let Some(p) = project {
1260 sql.push_str(" AND f.project LIKE ?");
1261 args.push(format!("%{}%", crate::util::nfc(p)));
1262 }
1263 sql.push_str(&format!(
1264 " GROUP BY f.session_id ORDER BY best LIMIT {limit}"
1265 ));
1266
1267 let mut stmt = conn.prepare(&sql)?;
1268 let rows = stmt.query_map(rusqlite::params_from_iter(args), |r| {
1269 Ok(Hit {
1270 row: SessionRow {
1271 session_id: r.get(0)?,
1272 tool: r.get(1)?,
1273 path: r.get(2)?,
1274 project: r.get(3)?,
1275 title: r.get(4)?,
1276 started: r.get(5)?,
1277 msg_count: r.get(6)?,
1278 kind: r.get(7)?,
1279 preview: None,
1280 summary: None,
1281 tags: None,
1282 archived: r.get(11)?,
1283 account: None,
1284 },
1285 role: r.get(8)?,
1286 snippet: r.get(9)?,
1287 })
1288 })?;
1289 let mut out = rows.collect::<rusqlite::Result<Vec<_>>>()?;
1290 crate::account_link::annotate(out.iter_mut().map(|h| &mut h.row));
1291 Ok(out)
1292}
1293
1294pub fn search_like(
1307 conn: &Connection,
1308 query: &str,
1309 limit: usize,
1310 tool: Option<&str>,
1311 project: Option<&str>,
1312) -> Result<Vec<Hit>> {
1313 const SCAN_CAP: i64 = 50_000;
1314
1315 let q = crate::util::nfc(query.trim());
1318 let pattern = format!(
1319 "%{}%",
1320 q.replace('\\', "\\\\")
1321 .replace('%', "\\%")
1322 .replace('_', "\\_")
1323 );
1324
1325 let mut sql = String::from(
1326 "SELECT f.session_id, f.tool, f.path, f.project, f.title, f.started, f.msg_count, f.kind,
1327 x.role, x.text, (f.archived_at IS NOT NULL)
1328 FROM (SELECT m.session_id AS sid, m.role AS role, m.text AS text, m.id AS mid
1329 FROM messages m
1330 WHERE m.text LIKE ?1 ESCAPE '\\'
1331 ORDER BY m.id DESC LIMIT ?2) x
1332 JOIN files f ON f.session_id = x.sid
1333 WHERE 1=1",
1334 );
1335 let mut args: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(pattern), Box::new(SCAN_CAP)];
1336 if let Some(t) = tool {
1337 sql.push_str(" AND f.tool = ?");
1338 args.push(Box::new(t.to_string()));
1339 }
1340 if let Some(p) = project {
1341 sql.push_str(" AND f.project LIKE ?");
1342 args.push(Box::new(format!("%{}%", crate::util::nfc(p))));
1343 }
1344 sql.push_str(&format!(
1346 " GROUP BY f.session_id ORDER BY max(x.mid) DESC LIMIT {limit}"
1347 ));
1348
1349 let mut stmt = conn.prepare(&sql)?;
1350 let rows = stmt.query_map(
1351 rusqlite::params_from_iter(args.iter().map(|b| b.as_ref())),
1352 |r| {
1353 let role: String = r.get(8)?;
1354 let text: String = r.get(9)?;
1355 Ok(Hit {
1356 row: SessionRow {
1357 session_id: r.get(0)?,
1358 tool: r.get(1)?,
1359 path: r.get(2)?,
1360 project: r.get(3)?,
1361 title: r.get(4)?,
1362 started: r.get(5)?,
1363 msg_count: r.get(6)?,
1364 kind: r.get(7)?,
1365 preview: None,
1366 summary: None,
1367 tags: None,
1368 archived: r.get(10)?,
1369 account: None,
1370 },
1371 role,
1372 snippet: snippet_around(&text, &q),
1373 })
1374 },
1375 )?;
1376 let mut out = rows.collect::<rusqlite::Result<Vec<_>>>()?;
1377 crate::account_link::annotate(out.iter_mut().map(|h| &mut h.row));
1378 Ok(out)
1379}
1380
1381fn snippet_around(text: &str, needle: &str) -> String {
1387 const WINDOW: usize = 36;
1388 let hay_chars: Vec<char> = text.to_lowercase().chars().collect();
1389 let nee_chars: Vec<char> = needle.to_lowercase().chars().collect();
1390 let chars: Vec<char> = text.chars().collect();
1391
1392 let match_at = if nee_chars.is_empty() {
1393 None
1394 } else {
1395 hay_chars
1396 .windows(nee_chars.len())
1397 .position(|w| w == nee_chars.as_slice())
1398 };
1399 let Some(start) = match_at else {
1400 return chars
1401 .iter()
1402 .take(WINDOW * 2)
1403 .collect::<String>()
1404 .replace('\n', " ");
1405 };
1406 let end = start + nee_chars.len();
1407 let lo = start.saturating_sub(WINDOW);
1408 let hi = (end + WINDOW).min(chars.len());
1409
1410 let mut out = String::new();
1411 if lo > 0 {
1412 out.push('\u{2026}');
1413 }
1414 out.extend(&chars[lo..start]);
1415 out.push('\u{2}');
1416 out.extend(&chars[start..end]);
1417 out.push('\u{3}');
1418 out.extend(&chars[end..hi]);
1419 if hi < chars.len() {
1420 out.push('\u{2026}');
1421 }
1422 out.replace('\n', " ")
1423}
1424
1425fn escape_like(s: &str) -> String {
1428 s.replace('\\', "\\\\")
1429 .replace('%', "\\%")
1430 .replace('_', "\\_")
1431}
1432
1433const RESOLVE_COLS: &str = "session_id, tool, path, project, title, started, msg_count, kind";
1436
1437fn map_resolve_row(r: &rusqlite::Row) -> rusqlite::Result<SessionRow> {
1440 Ok(SessionRow {
1441 session_id: r.get(0)?,
1442 tool: r.get(1)?,
1443 path: r.get(2)?,
1444 project: r.get(3)?,
1445 title: r.get(4)?,
1446 started: r.get(5)?,
1447 msg_count: r.get(6)?,
1448 kind: r.get(7)?,
1449 preview: None,
1450 summary: r.get(8)?,
1451 tags: r.get(9)?,
1452 archived: r.get(10)?,
1453 account: None,
1454 })
1455}
1456
1457pub fn resolve(conn: &Connection, id_prefix: &str) -> Result<Vec<SessionRow>> {
1465 let mut out = resolve_by_short_id(conn, id_prefix)?;
1466
1467 let native_ok =
1471 looks_like_native_prefix(id_prefix) && (out.is_empty() || id_prefix.contains('-'));
1472 if native_ok {
1473 for row in resolve_by_native_id(conn, id_prefix)? {
1474 if out.len() >= 10 {
1475 break;
1476 }
1477 if out.iter().all(|r| r.session_id != row.session_id) {
1478 out.push(row);
1479 }
1480 }
1481 }
1482 Ok(out)
1483}
1484
1485fn resolve_by_short_id(conn: &Connection, id_prefix: &str) -> Result<Vec<SessionRow>> {
1487 let mut stmt = conn.prepare(&format!(
1488 "SELECT {RESOLVE_COLS}, {SUMMARY_SQL}, {TAGS_SQL}, (archived_at IS NOT NULL)
1489 FROM files f WHERE session_id LIKE ?1 ESCAPE '\\' LIMIT 10",
1490 ))?;
1491 let pattern = format!("{}%", escape_like(id_prefix));
1492 let rows = stmt.query_map(params![pattern], map_resolve_row)?;
1493 Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
1494}
1495
1496fn resolve_by_native_id(conn: &Connection, prefix: &str) -> Result<Vec<SessionRow>> {
1501 let mut stmt = conn.prepare(&format!(
1502 "SELECT {RESOLVE_COLS}, {SUMMARY_SQL}, {TAGS_SQL}, (archived_at IS NOT NULL)
1503 FROM files f WHERE path LIKE ?1 ESCAPE '\\' LIMIT 500",
1504 ))?;
1505 let pattern = format!("%{}%", escape_like(prefix));
1506 let want = prefix.to_ascii_lowercase();
1507 let rows = stmt.query_map(params![pattern], map_resolve_row)?;
1508 let mut out = Vec::new();
1509 for row in rows {
1510 let row = row?;
1511 if native_id_of(&row.path).is_some_and(|n| n.starts_with(&want)) {
1512 out.push(row);
1513 if out.len() >= 10 {
1514 break;
1515 }
1516 }
1517 }
1518 Ok(out)
1519}
1520
1521pub fn locate_by_native_id(prefix: &str) -> Option<(String, PathBuf)> {
1530 if !looks_like_native_prefix(prefix) {
1531 return None;
1532 }
1533 let want = prefix.to_ascii_lowercase();
1534 for name in ["claude-code", "codex"] {
1535 let Some(adapter) = adapters::by_name(name) else {
1536 continue;
1537 };
1538 let Some(root) = adapter.root() else { continue };
1539 if !root.exists() {
1540 continue;
1541 }
1542 let hit = walkdir::WalkDir::new(&root)
1543 .into_iter()
1544 .filter_map(std::result::Result::ok)
1545 .find(|e| {
1546 e.file_type().is_file()
1547 && e.path().extension().is_some_and(|x| x == "jsonl")
1548 && native_id_of(&e.path().to_string_lossy())
1549 .is_some_and(|n| n.starts_with(&want))
1550 });
1551 if let Some(e) = hit {
1552 return Some((name.to_string(), e.into_path()));
1553 }
1554 }
1555 None
1556}
1557
1558pub fn live_row(tool: String, path: PathBuf) -> SessionRow {
1564 let path = path.to_string_lossy().into_owned();
1565 SessionRow {
1566 session_id: crate::util::short_id(&path),
1567 tool,
1568 path,
1569 project: String::new(),
1570 title: String::new(),
1571 started: None,
1572 msg_count: 0,
1573 kind: "main".into(),
1574 preview: None,
1575 summary: None,
1576 tags: None,
1577 archived: false,
1578 account: None,
1579 }
1580}
1581
1582pub fn set_summary(conn: &Connection, session_id: &str, summary: &str) -> Result<()> {
1586 conn.execute(
1587 "INSERT OR REPLACE INTO summaries(session_id, summary, created)
1588 VALUES (?1, ?2, datetime('now'))",
1589 params![session_id, crate::redact::redact(summary).as_ref()],
1590 )?;
1591 Ok(())
1592}
1593
1594pub fn unsummarized(
1596 conn: &Connection,
1597 limit: usize,
1598 tool: Option<&str>,
1599) -> Result<Vec<SessionRow>> {
1600 let mut sql = format!(
1601 "SELECT session_id, tool, path, project, title, started, msg_count, kind, {PREVIEW_SQL}, {SUMMARY_SQL}, {TAGS_SQL}, (archived_at IS NOT NULL)
1602 FROM files f
1603 WHERE kind = 'main' AND NOT EXISTS
1604 (SELECT 1 FROM summaries s WHERE s.session_id = f.session_id)",
1605 );
1606 let mut args: Vec<String> = Vec::new();
1607 if let Some(t) = tool {
1608 sql.push_str(" AND tool = ?");
1609 args.push(t.to_string());
1610 }
1611 sql.push_str(&format!(" ORDER BY started DESC LIMIT {limit}"));
1612 let mut stmt = conn.prepare(&sql)?;
1613 let rows = stmt.query_map(rusqlite::params_from_iter(args), |r| {
1614 Ok(SessionRow {
1615 session_id: r.get(0)?,
1616 tool: r.get(1)?,
1617 path: r.get(2)?,
1618 project: r.get(3)?,
1619 title: r.get(4)?,
1620 started: r.get(5)?,
1621 msg_count: r.get(6)?,
1622 kind: r.get(7)?,
1623 preview: r.get(8)?,
1624 summary: r.get(9)?,
1625 tags: r.get(10)?,
1626 archived: r.get(11)?,
1627 account: None,
1628 })
1629 })?;
1630 Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
1631}
1632
1633fn norm_tag(tag: &str) -> String {
1639 crate::util::nfc(&tag.trim().to_lowercase())
1640}
1641
1642pub fn add_tag(conn: &Connection, session_id: &str, tag: &str) -> Result<()> {
1643 let tag = norm_tag(tag);
1647 if tag.is_empty() || tag.contains(',') {
1648 anyhow::bail!("a tag must be non-empty and contain no commas");
1649 }
1650 conn.execute(
1651 "INSERT OR IGNORE INTO tags(session_id, tag) VALUES (?1, ?2)",
1652 params![session_id, tag],
1653 )?;
1654 Ok(())
1655}
1656
1657pub fn remove_tag(conn: &Connection, session_id: &str, tag: &str) -> Result<usize> {
1658 Ok(conn.execute(
1659 "DELETE FROM tags WHERE session_id = ?1 AND tag = ?2",
1660 params![session_id, norm_tag(tag)],
1661 )?)
1662}
1663
1664pub fn set_note(conn: &Connection, session_id: &str, note: &str) -> Result<()> {
1665 conn.execute(
1666 "INSERT OR REPLACE INTO notes(session_id, note, updated)
1667 VALUES (?1, ?2, datetime('now'))",
1668 params![session_id, note],
1669 )?;
1670 Ok(())
1671}
1672
1673pub fn note_for(conn: &Connection, session_id: &str) -> Result<Option<String>> {
1674 Ok(conn
1675 .query_row(
1676 "SELECT note FROM notes WHERE session_id = ?1",
1677 params![session_id],
1678 |r| r.get(0),
1679 )
1680 .ok())
1681}
1682
1683pub fn tag_counts(conn: &Connection) -> Result<Vec<(String, i64)>> {
1685 let mut stmt =
1686 conn.prepare("SELECT tag, count(*) FROM tags GROUP BY tag ORDER BY count(*) DESC, tag")?;
1687 let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?;
1688 Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
1689}
1690
1691pub fn files_for(conn: &Connection, session_id: &str) -> Result<Vec<String>> {
1695 let mut stmt = conn.prepare("SELECT path FROM touched WHERE session_id = ?1 ORDER BY rowid")?;
1696 let rows = stmt.query_map(params![session_id], |r| r.get(0))?;
1697 Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
1698}
1699
1700#[derive(Debug, Serialize)]
1703pub struct FileEdit {
1704 pub session_id: String,
1705 pub kind: String,
1706 pub ts: Option<String>,
1707 pub snippet: String,
1708 pub matched_path: String,
1709}
1710
1711fn sql_limit(n: usize) -> i64 {
1718 i64::try_from(n).unwrap_or(i64::MAX)
1719}
1720
1721pub fn edits_for(conn: &Connection, query: &str, limit: usize) -> Result<Vec<FileEdit>> {
1722 let q = crate::util::nfc(query.trim().trim_start_matches("./"));
1723 let esc = q
1725 .replace('\\', "\\\\")
1726 .replace('%', "\\%")
1727 .replace('_', "\\_");
1728 let suffix = format!("%/{esc}");
1729 let mut stmt = conn.prepare(
1730 "SELECT session_id, kind, ts, snippet, path
1731 FROM edits
1732 WHERE path = ?1 OR path LIKE ?2 ESCAPE '\\'
1733 OR (length(?1) > length(path)
1734 AND substr(?1, -length(path)) = path
1735 AND substr(?1, -length(path)-1, 1) = '/')
1736 ORDER BY ts DESC, rowid DESC LIMIT ?3",
1737 )?;
1738 let rows = stmt.query_map(params![q, suffix, sql_limit(limit)], |r| {
1739 Ok(FileEdit {
1740 session_id: r.get(0)?,
1741 kind: r.get(1)?,
1742 ts: r.get(2)?,
1743 snippet: r.get(3)?,
1744 matched_path: r.get(4)?,
1745 })
1746 })?;
1747 Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
1748}
1749
1750pub fn edits_for_session(
1756 conn: &Connection,
1757 session_id: &str,
1758 query: &str,
1759 limit: usize,
1760) -> Result<Vec<FileEdit>> {
1761 let q = crate::util::nfc(query.trim().trim_start_matches("./"));
1762 let esc = q
1763 .replace('\\', "\\\\")
1764 .replace('%', "\\%")
1765 .replace('_', "\\_");
1766 let suffix = format!("%/{esc}");
1767 let mut stmt = conn.prepare(
1768 "SELECT session_id, kind, ts, snippet, path
1769 FROM edits
1770 WHERE session_id = ?1
1771 AND (path = ?2 OR path LIKE ?3 ESCAPE '\\'
1772 OR (length(?2) > length(path)
1773 AND substr(?2, -length(path)) = path
1774 AND substr(?2, -length(path)-1, 1) = '/'))
1775 ORDER BY ts DESC, rowid DESC LIMIT ?4",
1776 )?;
1777 let rows = stmt.query_map(params![session_id, q, suffix, sql_limit(limit)], |r| {
1778 Ok(FileEdit {
1779 session_id: r.get(0)?,
1780 kind: r.get(1)?,
1781 ts: r.get(2)?,
1782 snippet: r.get(3)?,
1783 matched_path: r.get(4)?,
1784 })
1785 })?;
1786 Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
1787}
1788
1789#[derive(Serialize)]
1792pub struct SessionEvidence {
1793 pub session: SessionRow,
1794 pub edits: Vec<FileEdit>,
1795}
1796
1797#[derive(Serialize)]
1800pub struct FileHistory {
1801 pub path: String,
1802 pub sessions: Vec<SessionEvidence>,
1803}
1804
1805pub fn evidence_for(conn: &Connection, path: &str, limit: usize) -> Result<FileHistory> {
1810 let sessions = sessions_for_file(conn, path, limit)?;
1811 const PER_SESSION_EDIT_CAP: usize = 500;
1815 let sessions = sessions
1816 .into_iter()
1817 .map(|(session, _matched)| {
1818 let edits = edits_for_session(conn, &session.session_id, path, PER_SESSION_EDIT_CAP)?;
1819 Ok(SessionEvidence { session, edits })
1820 })
1821 .collect::<Result<Vec<_>>>()?;
1822 Ok(FileHistory {
1823 path: path.to_string(),
1824 sessions,
1825 })
1826}
1827
1828pub fn basename_fallback(query: &str) -> Option<String> {
1843 let q = query.trim();
1844 if q.is_empty() || q.ends_with('/') {
1845 return None;
1846 }
1847 let (head, name) = q.rsplit_once('/')?;
1848 (!head.is_empty() && !name.is_empty()).then(|| name.to_string())
1849}
1850
1851pub fn sessions_for_file(
1852 conn: &Connection,
1853 query: &str,
1854 limit: usize,
1855) -> Result<Vec<(SessionRow, String)>> {
1856 let q = crate::util::nfc(query.trim().trim_start_matches("./"));
1857 let esc = q
1861 .replace('\\', "\\\\")
1862 .replace('%', "\\%")
1863 .replace('_', "\\_");
1864 let suffix = format!("%/{esc}");
1865 let mut stmt = conn.prepare(&format!(
1866 "SELECT f.session_id, f.tool, f.path, f.project, f.title, f.started, f.msg_count, f.kind,
1867 {SUMMARY_SQL}, {TAGS_SQL}, t.path, (f.archived_at IS NOT NULL)
1868 FROM touched t JOIN files f ON f.session_id = t.session_id
1869 WHERE t.path = ?1 OR t.path LIKE ?2 ESCAPE '\\'
1870 OR (length(?1) > length(t.path)
1871 AND substr(?1, -length(t.path)) = t.path
1872 AND substr(?1, -length(t.path)-1, 1) = '/')
1873 GROUP BY f.session_id
1874 ORDER BY f.started DESC, f.session_id LIMIT ?3"
1875 ))?;
1876 let rows = stmt.query_map(params![q, suffix, sql_limit(limit)], |r| {
1877 Ok((
1878 SessionRow {
1879 session_id: r.get(0)?,
1880 tool: r.get(1)?,
1881 path: r.get(2)?,
1882 project: r.get(3)?,
1883 title: r.get(4)?,
1884 started: r.get(5)?,
1885 msg_count: r.get(6)?,
1886 kind: r.get(7)?,
1887 preview: None,
1888 summary: r.get(8)?,
1889 tags: r.get(9)?,
1890 archived: r.get(11)?,
1891 account: None,
1892 },
1893 r.get::<_, String>(10)?,
1894 ))
1895 })?;
1896 let mut out = rows.collect::<rusqlite::Result<Vec<_>>>()?;
1897 crate::account_link::annotate(out.iter_mut().map(|(r, _)| r));
1898 Ok(out)
1899}
1900
1901pub fn sessions_touching(
1907 conn: &Connection,
1908 query: &str,
1909) -> Result<Vec<crate::blame::TouchingSession>> {
1910 let q = crate::util::nfc(query.trim().trim_start_matches("./"));
1911 let suffix = format!("%/{q}");
1912 let mut stmt = conn.prepare(
1913 "SELECT f.session_id, f.tool, f.title, f.project, f.started, f.ended, (f.archived_at IS NOT NULL)
1914 FROM touched t JOIN files f ON f.session_id = t.session_id
1915 WHERE t.path = ?1 OR t.path LIKE ?2
1916 GROUP BY f.session_id",
1917 )?;
1918 let rows = stmt.query_map(params![q, suffix], |r| {
1919 let started: Option<String> = r.get(4)?;
1920 let ended: Option<String> = r.get(5)?;
1921 Ok(crate::blame::TouchingSession {
1922 session_id: r.get(0)?,
1923 tool: r.get(1)?,
1924 title: r.get(2)?,
1925 project: r.get(3)?,
1926 started: started.as_deref().and_then(to_epoch),
1927 ended: ended.as_deref().and_then(to_epoch),
1928 archived: r.get(6)?,
1929 })
1930 })?;
1931 Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
1932}
1933
1934fn to_epoch(s: &str) -> Option<i64> {
1936 chrono::DateTime::parse_from_rfc3339(s)
1937 .ok()
1938 .map(|d| d.timestamp())
1939}
1940
1941fn interned_tool(name: &str) -> &'static str {
1952 use std::collections::HashSet;
1953 use std::sync::{Mutex, OnceLock};
1954 static NAMES: OnceLock<Mutex<HashSet<&'static str>>> = OnceLock::new();
1955 let mut names = NAMES
1956 .get_or_init(|| Mutex::new(HashSet::new()))
1957 .lock()
1958 .unwrap_or_else(std::sync::PoisonError::into_inner);
1959 if let Some(existing) = names.get(name) {
1960 return existing;
1961 }
1962 let leaked: &'static str = Box::leak(name.to_owned().into_boxed_str());
1963 names.insert(leaked);
1964 leaked
1965}
1966
1967pub fn session_from_index(conn: &Connection, row: &SessionRow) -> Result<crate::model::Session> {
1971 use crate::model::{Message, Role};
1972 let mut stmt =
1973 conn.prepare("SELECT role, text FROM messages WHERE session_id = ?1 ORDER BY id")?;
1974 let messages: Vec<Message> = stmt
1975 .query_map(params![row.session_id], |r| {
1976 let role: String = r.get(0)?;
1977 let text: String = r.get(1)?;
1978 Ok(Message {
1979 role: match role.as_str() {
1980 "user" => Role::User,
1981 "assistant" => Role::Assistant,
1982 _ => Role::Tool,
1983 },
1984 text,
1985 ts: None,
1986 })
1987 })?
1988 .collect::<rusqlite::Result<_>>()?;
1989 drop(stmt);
1990
1991 let tool = adapters::by_name(&row.tool)
1992 .map(|a| a.name())
1993 .unwrap_or_else(|| interned_tool(&row.tool));
1994 let started = row
1995 .started
1996 .as_deref()
1997 .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
1998 .map(|t| t.with_timezone(&chrono::Utc));
1999 Ok(crate::model::Session {
2000 id: row.session_id.clone(),
2001 tool,
2002 path: std::path::PathBuf::from(row.path.replace('\u{1f}', "#")),
2007 project: row.project.clone(),
2008 started,
2009 ended: started,
2010 title: row.title.clone(),
2011 subagent: row.kind == "sub",
2012 messages,
2013 touched: files_for(conn, &row.session_id)?,
2014 edits: Vec::new(),
2015 })
2016}
2017
2018pub fn forget(conn: &mut Connection, session_id: &str) -> Result<()> {
2024 let tx = conn.transaction()?;
2025 delete_session_msgs(&tx, session_id)?;
2026 for table in [
2027 "files",
2028 "touched",
2029 "edits",
2030 "archive",
2031 "summaries",
2032 "tags",
2033 "notes",
2034 ] {
2035 tx.execute(
2036 &format!("DELETE FROM {table} WHERE session_id = ?1"),
2037 params![session_id],
2038 )?;
2039 }
2040 tx.commit()?;
2041 Ok(())
2042}
2043
2044pub fn related(conn: &Connection, session_id: &str, limit: usize) -> Result<Vec<SessionRow>> {
2053 let Some(target) = resolve(conn, session_id)?.into_iter().next() else {
2054 return Ok(vec![]);
2055 };
2056 let target_tags: Vec<String> = target
2057 .tags
2058 .as_deref()
2059 .map(|t| t.split(',').map(String::from).collect())
2060 .unwrap_or_default();
2061
2062 let mut out: Vec<SessionRow> = Vec::new();
2063 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
2064 seen.insert(target.session_id.clone());
2065
2066 if !target.project.is_empty() {
2068 let sql = format!(
2069 "SELECT session_id, tool, path, project, title, started, msg_count, kind,
2070 {PREVIEW_SQL}, {SUMMARY_SQL}, {TAGS_SQL}, (archived_at IS NOT NULL)
2071 FROM files f
2072 WHERE kind = 'main' AND project = ?1 AND session_id != ?2
2073 ORDER BY started DESC LIMIT ?3"
2074 );
2075 let mut stmt = conn.prepare(&sql)?;
2076 let rows = stmt.query_map(
2077 params![target.project, target.session_id, limit as i64 + 1],
2078 map_row,
2079 )?;
2080 for row in rows {
2081 let row = row?;
2082 if seen.insert(row.session_id.clone()) {
2083 out.push(row);
2084 }
2085 }
2086 }
2087
2088 if out.len() < limit {
2092 let sql = format!(
2093 "SELECT DISTINCT f.session_id, f.tool, f.path, f.project, f.title, f.started,
2094 f.msg_count, f.kind, {PREVIEW_SQL}, {SUMMARY_SQL}, {TAGS_SQL}, (f.archived_at IS NOT NULL)
2095 FROM touched a
2096 JOIN touched b ON a.path = b.path AND b.session_id != a.session_id
2097 JOIN files f ON f.session_id = b.session_id
2098 WHERE a.session_id = ?1 AND f.kind = 'main'
2099 ORDER BY f.started DESC LIMIT 50"
2100 );
2101 let mut stmt = conn.prepare(&sql)?;
2102 let rows = stmt.query_map(params![target.session_id], map_row)?;
2103 for row in rows {
2104 let row = row?;
2105 if seen.insert(row.session_id.clone()) {
2106 out.push(row);
2107 if out.len() >= limit {
2108 break;
2109 }
2110 }
2111 }
2112 }
2113
2114 if out.len() < limit && !target_tags.is_empty() {
2116 let placeholders = target_tags
2117 .iter()
2118 .map(|_| "?")
2119 .collect::<Vec<_>>()
2120 .join(",");
2121 let sql = format!(
2122 "SELECT DISTINCT f.session_id, f.tool, f.path, f.project, f.title, f.started,
2123 f.msg_count, f.kind, {PREVIEW_SQL}, {SUMMARY_SQL}, {TAGS_SQL}, (f.archived_at IS NOT NULL)
2124 FROM files f JOIN tags t ON t.session_id = f.session_id
2125 WHERE f.kind = 'main' AND t.tag IN ({placeholders})
2126 ORDER BY f.started DESC LIMIT 50"
2127 );
2128 let mut stmt = conn.prepare(&sql)?;
2129 let rows = stmt.query_map(rusqlite::params_from_iter(&target_tags), map_row)?;
2130 for row in rows {
2131 let row = row?;
2132 if seen.insert(row.session_id.clone()) {
2133 out.push(row);
2134 if out.len() >= limit {
2135 break;
2136 }
2137 }
2138 }
2139 }
2140
2141 out.truncate(limit);
2142 crate::account_link::annotate(out.iter_mut());
2143 Ok(out)
2144}
2145
2146fn map_row(r: &rusqlite::Row) -> rusqlite::Result<SessionRow> {
2148 Ok(SessionRow {
2149 session_id: r.get(0)?,
2150 tool: r.get(1)?,
2151 path: r.get(2)?,
2152 project: r.get(3)?,
2153 title: r.get(4)?,
2154 started: r.get(5)?,
2155 msg_count: r.get(6)?,
2156 kind: r.get(7)?,
2157 preview: r.get(8)?,
2158 summary: r.get(9)?,
2159 tags: r.get(10)?,
2160 archived: r.get(11)?,
2161 account: None,
2162 })
2163}
2164
2165pub struct ProjectRow {
2168 pub project: String,
2169 pub sessions: i64,
2170 pub messages: i64,
2171 pub oldest: Option<String>,
2172 pub newest: Option<String>,
2173}
2174
2175pub fn projects(conn: &Connection) -> Result<Vec<ProjectRow>> {
2177 let mut stmt = conn.prepare(
2178 "SELECT project, count(*), coalesce(sum(msg_count), 0), min(started), max(started)
2179 FROM files WHERE kind = 'main' AND project != ''
2180 GROUP BY project ORDER BY count(*) DESC, max(started) DESC",
2181 )?;
2182 let rows = stmt.query_map([], |r| {
2183 Ok(ProjectRow {
2184 project: r.get(0)?,
2185 sessions: r.get(1)?,
2186 messages: r.get(2)?,
2187 oldest: r.get(3)?,
2188 newest: r.get(4)?,
2189 })
2190 })?;
2191 Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
2192}
2193
2194pub struct Stats {
2195 pub per_tool: Vec<(String, i64, i64)>, pub per_month: Vec<(String, i64)>, pub total_sessions: i64,
2198 pub total_messages: i64,
2199 pub projects: i64,
2200 pub tags: i64,
2201 pub summarized: i64,
2202 pub files: i64,
2204 pub archived: i64,
2206}
2207
2208pub fn stats(conn: &Connection) -> Result<Stats> {
2209 let mut per_tool_stmt = conn.prepare(
2210 "SELECT tool, count(*), coalesce(sum(msg_count),0) FROM files WHERE kind='main'
2211 GROUP BY tool ORDER BY count(*) DESC",
2212 )?;
2213 let per_tool = per_tool_stmt
2214 .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?
2215 .collect::<rusqlite::Result<Vec<_>>>()?;
2216
2217 let mut per_month_stmt = conn.prepare(
2218 "SELECT substr(started,1,7) AS ym, count(*) FROM files
2219 WHERE kind='main' AND started IS NOT NULL
2220 GROUP BY ym ORDER BY ym DESC LIMIT 12",
2221 )?;
2222 let per_month = per_month_stmt
2223 .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
2224 .collect::<rusqlite::Result<Vec<_>>>()?;
2225
2226 let one = |sql: &str| -> Result<i64> { Ok(conn.query_row(sql, [], |r| r.get(0))?) };
2227 Ok(Stats {
2228 per_tool,
2229 per_month,
2230 total_sessions: one("SELECT count(*) FROM files WHERE kind='main'")?,
2231 total_messages: one("SELECT coalesce(sum(msg_count),0) FROM files WHERE kind='main'")?,
2232 projects: one(
2233 "SELECT count(DISTINCT project) FROM files WHERE kind='main' AND project!=''",
2234 )?,
2235 tags: one("SELECT count(DISTINCT tag) FROM tags")?,
2236 summarized: one("SELECT count(*) FROM summaries")?,
2237 files: one("SELECT count(DISTINCT path) FROM touched")?,
2238 archived: one("SELECT count(*) FROM files WHERE archived_at IS NOT NULL")?,
2239 })
2240}
2241
2242#[cfg(test)]
2243mod native_id_tests {
2244 use super::*;
2245
2246 const CODEX: &str = "/home/u/.codex/sessions/2025/05/13/rollout-2025-05-13T18-19-30-0a000000-0000-4000-8000-000000000001.jsonl";
2249 const CODEX_UUID: &str = "0a000000-0000-4000-8000-000000000001";
2250 const CLAUDE: &str =
2251 "/home/u/.claude/projects/-home-u-proj/1b111111-1111-4111-8111-111111111111.jsonl";
2252 const CLAUDE_UUID: &str = "1b111111-1111-4111-8111-111111111111";
2253 const SUBAGENT: &str = "/home/u/.claude/projects/-x/1b111111-1111-4111-8111-111111111111/subagents/agent-2c222222-2222-4222-8222-222222222222.jsonl";
2254 const SUBAGENT_UUID: &str = "2c222222-2222-4222-8222-222222222222";
2255
2256 fn mem() -> Connection {
2257 let c = Connection::open_in_memory().unwrap();
2258 c.execute_batch(
2259 "CREATE TABLE files(
2260 path TEXT PRIMARY KEY, mtime INTEGER NOT NULL DEFAULT 0,
2261 size INTEGER NOT NULL DEFAULT 0, session_id TEXT NOT NULL,
2262 tool TEXT NOT NULL, project TEXT NOT NULL DEFAULT '',
2263 title TEXT NOT NULL DEFAULT '', started TEXT, ended TEXT,
2264 msg_count INTEGER NOT NULL DEFAULT 0,
2265 kind TEXT NOT NULL DEFAULT 'main', archived_at TEXT);
2266 CREATE TABLE summaries(session_id TEXT PRIMARY KEY, summary TEXT NOT NULL, created TEXT NOT NULL);
2267 CREATE TABLE tags(session_id TEXT NOT NULL, tag TEXT NOT NULL, PRIMARY KEY(session_id, tag));",
2268 )
2269 .unwrap();
2270 c
2271 }
2272
2273 fn seed(c: &Connection, tool: &str, path: &str) -> String {
2275 let sid = crate::util::short_id(path);
2276 c.execute(
2277 "INSERT INTO files(path, session_id, tool, msg_count) VALUES(?1,?2,?3,3)",
2278 params![path, sid, tool],
2279 )
2280 .unwrap();
2281 sid
2282 }
2283
2284 #[test]
2285 fn native_id_extracted_per_tool() {
2286 assert_eq!(native_id_of(CODEX).as_deref(), Some(CODEX_UUID));
2287 assert_eq!(native_id_of(CLAUDE).as_deref(), Some(CLAUDE_UUID));
2288 assert_eq!(native_id_of(SUBAGENT).as_deref(), Some(SUBAGENT_UUID));
2291 assert_eq!(native_id_of("/x/opencode.db#session-42"), None);
2294 }
2295
2296 #[test]
2297 fn native_id_uppercase_is_normalized_to_lowercase() {
2298 let p = "/a/b/AB000000-0000-4000-8000-0000000000FF.jsonl";
2299 assert_eq!(
2300 native_id_of(p).as_deref(),
2301 Some("ab000000-0000-4000-8000-0000000000ff")
2302 );
2303 }
2304
2305 #[test]
2306 fn resolve_by_full_native_id() {
2307 let c = mem();
2308 let sid = seed(&c, "codex", CODEX);
2309 let hits = resolve(&c, CODEX_UUID).unwrap();
2310 assert_eq!(hits.len(), 1, "full native uuid resolves");
2311 assert_eq!(hits[0].session_id, sid);
2312 }
2313
2314 #[test]
2315 fn resolve_by_native_prefix_hex_and_dashed() {
2316 let c = mem();
2317 let sid = seed(&c, "claude-code", CLAUDE);
2318 let a = resolve(&c, "1b111111").unwrap();
2320 assert_eq!(a.len(), 1, "8-hex native prefix resolves");
2321 assert_eq!(a[0].session_id, sid);
2322 let b = resolve(&c, "1b111111-1111").unwrap();
2324 assert_eq!(b.len(), 1, "dashed native prefix resolves");
2325 assert_eq!(b[0].session_id, sid);
2326 }
2327
2328 #[test]
2329 fn resolve_still_matches_short_id_unchanged() {
2330 let c = mem();
2331 let sid = seed(&c, "codex", CODEX);
2332 assert_eq!(resolve(&c, &sid).unwrap().len(), 1);
2334 assert_eq!(resolve(&c, &sid[..6]).unwrap()[0].session_id, sid);
2335 }
2336
2337 #[test]
2338 fn short_id_lookup_does_not_run_the_native_scan() {
2339 assert!(!looks_like_native_prefix("abcdef012345"));
2343 assert!(looks_like_native_prefix("0a000000"));
2345 assert!(looks_like_native_prefix(
2346 "0a000000-0000-4000-8000-000000000001"
2347 ));
2348 assert!(looks_like_native_prefix("0a000000-0000"));
2349 assert!(!looks_like_native_prefix("zzz"));
2351 assert!(!looks_like_native_prefix("a1"));
2352 assert!(!looks_like_native_prefix(""));
2353 }
2354
2355 #[test]
2356 fn native_prefix_never_hides_an_existing_short_id_match() {
2357 let c = mem();
2361 let native_path =
2364 "/home/u/.codex/sessions/2025/01/01/rollout-2025-01-01T00-00-00-deadbeef-0000-4000-8000-000000000009.jsonl";
2365 seed(&c, "codex", native_path);
2366 c.execute(
2368 "INSERT INTO files(path, session_id, tool, msg_count) VALUES('/synthetic', 'deadbeef1234', 'codex', 1)",
2369 [],
2370 )
2371 .unwrap();
2372 let hits = resolve(&c, "deadbeef").unwrap();
2373 assert_eq!(hits.len(), 1);
2375 assert_eq!(hits[0].session_id, "deadbeef1234");
2376 }
2377
2378 #[test]
2379 fn session_row_serializes_native_id_not_path() {
2380 let row = SessionRow {
2381 session_id: "abc123def456".into(),
2382 tool: "codex".into(),
2383 path: CODEX.into(),
2384 project: "proj".into(),
2385 title: "t".into(),
2386 started: None,
2387 msg_count: 3,
2388 kind: "main".into(),
2389 preview: None,
2390 summary: None,
2391 tags: None,
2392 archived: false,
2393 account: None,
2394 };
2395 let v = serde_json::to_value(&row).unwrap();
2396 assert_eq!(v["id"], "abc123def456");
2397 assert_eq!(v["native_id"], CODEX_UUID, "native_id present in JSON");
2398 assert!(
2399 v.get("path").is_none(),
2400 "the absolute path is never serialized"
2401 );
2402 let mut row2 = row;
2404 row2.path = "/x/opencode.db#s1".into();
2405 let v2 = serde_json::to_value(&row2).unwrap();
2406 assert!(v2["native_id"].is_null());
2407 }
2408}
2409
2410#[cfg(test)]
2411mod edits_tests {
2412 use super::*;
2413
2414 fn conn_with_edits() -> Connection {
2415 let c = Connection::open_in_memory().unwrap();
2416 c.execute_batch(
2417 "CREATE TABLE edits(session_id TEXT NOT NULL, path TEXT NOT NULL,
2418 kind TEXT NOT NULL, ts TEXT, snippet TEXT NOT NULL);
2419 CREATE INDEX idx_edits_path ON edits(path);",
2420 )
2421 .unwrap();
2422 c
2423 }
2424
2425 fn add(c: &Connection, sid: &str, path: &str, kind: &str, ts: &str, snip: &str) {
2426 c.execute(
2427 "INSERT INTO edits(session_id, path, kind, ts, snippet) VALUES(?1,?2,?3,?4,?5)",
2428 params![sid, path, kind, ts, snip],
2429 )
2430 .unwrap();
2431 }
2432
2433 #[test]
2434 fn edits_for_returns_a_files_edits_by_suffix_newest_first() {
2435 let c = conn_with_edits();
2436 add(
2437 &c,
2438 "s1",
2439 "/home/me/proj/src/auth.rs",
2440 "edit",
2441 "2026-06-08T10:00:00Z",
2442 "let a = 1;",
2443 );
2444 add(
2445 &c,
2446 "s2",
2447 "/home/me/proj/src/auth.rs",
2448 "write",
2449 "2026-06-09T10:00:00Z",
2450 "fn main() {}",
2451 );
2452 add(
2453 &c,
2454 "s3",
2455 "/home/me/proj/src/other.rs",
2456 "edit",
2457 "2026-06-10T10:00:00Z",
2458 "nope",
2459 );
2460
2461 let hits = edits_for(&c, "src/auth.rs", 50).unwrap();
2463
2464 assert_eq!(hits.len(), 2, "both auth.rs edits, not other.rs");
2465 assert_eq!(hits[0].kind, "write", "newest edit first");
2466 assert!(hits[0].snippet.contains("fn main()"));
2467 assert_eq!(hits[1].kind, "edit");
2468 }
2469
2470 #[test]
2471 fn index_one_persists_a_sessions_edits() {
2472 use crate::model::{EditEvent, EditKind, Session};
2473 let mut c = Connection::open_in_memory().unwrap();
2474 create_cache_schema(&c).unwrap();
2475
2476 let session = Session {
2477 id: "sx".into(),
2478 tool: "claude-code",
2479 path: "/store/sx.jsonl".into(),
2480 project: "/proj".into(),
2481 started: None,
2482 ended: None,
2483 title: "t".into(),
2484 subagent: false,
2485 messages: vec![],
2486 touched: vec!["/proj/src/auth.rs".into()],
2487 edits: vec![EditEvent {
2488 path: "/proj/src/auth.rs".into(),
2489 kind: EditKind::Write,
2490 snippet: "fn main() {}".into(),
2491 ts: None,
2492 }],
2493 };
2494
2495 let tx = c.transaction().unwrap();
2496 index_one(&tx, &session, "/store/sx.jsonl", 0, 0).unwrap();
2497 tx.commit().unwrap();
2498
2499 let hits = edits_for(&c, "src/auth.rs", 50).unwrap();
2500 assert_eq!(hits.len(), 1, "the session's one edit was persisted");
2501 assert_eq!(hits[0].session_id, "sx");
2502 assert_eq!(hits[0].kind, "write");
2503 assert!(hits[0].snippet.contains("fn main()"));
2504 }
2505
2506 #[test]
2507 fn forget_removes_a_sessions_edits() {
2508 let mut c = Connection::open_in_memory().unwrap();
2509 create_cache_schema(&c).unwrap();
2510 c.execute(
2511 "INSERT INTO files(path, session_id, tool, mtime, size) VALUES('/store/s.jsonl','s1','claude-code',0,0)",
2512 [],
2513 )
2514 .unwrap();
2515 c.execute(
2516 "INSERT INTO edits(session_id,path,kind,ts,snippet) VALUES('s1','/proj/a.rs','write',NULL,'x')",
2517 [],
2518 )
2519 .unwrap();
2520 assert_eq!(edits_for(&c, "a.rs", 10).unwrap().len(), 1);
2521
2522 forget(&mut c, "s1").unwrap();
2523
2524 assert!(
2525 edits_for(&c, "a.rs", 10).unwrap().is_empty(),
2526 "forget must remove the session's edits, not orphan them"
2527 );
2528 }
2529
2530 #[test]
2531 fn edits_for_is_deterministic_when_timestamps_tie() {
2532 let c = conn_with_edits();
2533 add(&c, "s1", "/p/a.rs", "edit", "2026-01-01T00:00:00Z", "first");
2534 add(
2535 &c,
2536 "s2",
2537 "/p/a.rs",
2538 "write",
2539 "2026-01-01T00:00:00Z",
2540 "second",
2541 );
2542 let hits = edits_for(&c, "a.rs", 10).unwrap();
2543 assert_eq!(hits[0].snippet, "second");
2545 assert_eq!(hits[1].snippet, "first");
2546 }
2547
2548 #[test]
2549 fn edits_for_session_returns_only_that_sessions_edits() {
2550 let c = conn_with_edits();
2551 add(
2552 &c,
2553 "s1",
2554 "/p/a.rs",
2555 "edit",
2556 "2026-01-01T00:00:00Z",
2557 "s1-edit",
2558 );
2559 add(
2560 &c,
2561 "s2",
2562 "/p/a.rs",
2563 "write",
2564 "2026-02-01T00:00:00Z",
2565 "s2-edit",
2566 );
2567 let hits = edits_for_session(&c, "s1", "/p/a.rs", 10).unwrap();
2570 assert_eq!(hits.len(), 1);
2571 assert_eq!(hits[0].snippet, "s1-edit");
2572 }
2573
2574 #[test]
2575 fn edits_for_session_matches_every_spelling_of_the_path() {
2576 let c = conn_with_edits();
2577 add(
2579 &c,
2580 "s1",
2581 "/proj/src/auth.rs",
2582 "edit",
2583 "2026-01-01T00:00:00Z",
2584 "abs",
2585 );
2586 add(
2587 &c,
2588 "s1",
2589 "src/auth.rs",
2590 "write",
2591 "2026-01-02T00:00:00Z",
2592 "rel",
2593 );
2594 add(
2595 &c,
2596 "s2",
2597 "/other/auth.rs",
2598 "edit",
2599 "2026-01-03T00:00:00Z",
2600 "different-file",
2601 );
2602 let hits = edits_for_session(&c, "s1", "src/auth.rs", 10).unwrap();
2605 assert_eq!(hits.len(), 2, "all of s1's edits to the file, any spelling");
2606 }
2607
2608 #[test]
2609 fn index_redacts_secrets_in_messages_and_edit_snippets() {
2610 use crate::model::{EditEvent, EditKind, Message, Role, Session};
2611 let mut c = Connection::open_in_memory().unwrap();
2612 create_cache_schema(&c).unwrap();
2613 let session = Session {
2614 id: "sx".into(),
2615 tool: "claude-code",
2616 path: "/s.jsonl".into(),
2617 project: "/p".into(),
2618 started: None,
2619 ended: None,
2620 title: "title with AKIAIOSFODNN7EXAMPLE in it".into(),
2621 subagent: false,
2622 messages: vec![Message {
2623 role: Role::User,
2624 text: "my key is sk-abcdef012345678901234567890123 ok".into(),
2625 ts: None,
2626 }],
2627 touched: vec!["/p/a.rs".into()],
2628 edits: vec![EditEvent {
2629 path: "/p/a.rs".into(),
2630 kind: EditKind::Write,
2631 snippet: "const T = \"ghp_016C7f9aBcDeFgHiJkLmNoPqRsTuVwXyZ012\";".into(),
2632 ts: None,
2633 }],
2634 };
2635 let tx = c.transaction().unwrap();
2636 index_one(&tx, &session, "/s.jsonl", 0, 0).unwrap();
2637 tx.commit().unwrap();
2638
2639 let msg: String = c
2640 .query_row("SELECT text FROM messages WHERE session_id='sx'", [], |r| {
2641 r.get(0)
2642 })
2643 .unwrap();
2644 assert!(!msg.contains("sk-abcdef"), "message secret redacted: {msg}");
2645 assert!(msg.contains("[redacted:openai]"), "{msg}");
2646 let snip = edits_for(&c, "a.rs", 10).unwrap()[0].snippet.clone();
2647 assert!(!snip.contains("ghp_016C"), "edit secret redacted: {snip}");
2648 assert!(snip.contains("[redacted:github]"), "{snip}");
2649 let title: String = c
2651 .query_row("SELECT title FROM files WHERE session_id='sx'", [], |r| {
2652 r.get(0)
2653 })
2654 .unwrap();
2655 assert!(
2656 !title.contains("AKIAIOSFODNN7EXAMPLE"),
2657 "title secret redacted: {title}"
2658 );
2659 set_summary(
2661 &c,
2662 "sx",
2663 "we set sk-abcdef012345678901234567890123 as the key",
2664 )
2665 .unwrap();
2666 let sum: String = c
2667 .query_row(
2668 "SELECT summary FROM summaries WHERE session_id='sx'",
2669 [],
2670 |r| r.get(0),
2671 )
2672 .unwrap();
2673 assert!(!sum.contains("sk-abcdef"), "summary secret redacted: {sum}");
2674 }
2675
2676 #[test]
2677 fn evidence_for_assembles_sessions_with_their_edits_newest_first() {
2678 use crate::model::{EditEvent, EditKind, Session};
2679 let mut c = Connection::open_in_memory().unwrap();
2680 create_cache_schema(&c).unwrap();
2681
2682 for (sid, store, started, kind, snip) in [
2683 (
2684 "old",
2685 "/store/old.jsonl",
2686 "2026-06-01T00:00:00Z",
2687 EditKind::Edit,
2688 "v1",
2689 ),
2690 (
2691 "new",
2692 "/store/new.jsonl",
2693 "2026-06-09T00:00:00Z",
2694 EditKind::Write,
2695 "v2",
2696 ),
2697 ] {
2698 let started = chrono::DateTime::parse_from_rfc3339(started)
2699 .unwrap()
2700 .with_timezone(&chrono::Utc);
2701 let session = Session {
2702 id: sid.into(),
2703 tool: "claude-code",
2704 path: store.into(),
2705 project: "/proj".into(),
2706 started: Some(started),
2707 ended: None,
2708 title: format!("{sid} title"),
2709 subagent: false,
2710 messages: vec![],
2711 touched: vec!["/proj/src/a.rs".into()],
2712 edits: vec![EditEvent {
2713 path: "/proj/src/a.rs".into(),
2714 kind,
2715 snippet: snip.into(),
2716 ts: None,
2717 }],
2718 };
2719 let tx = c.transaction().unwrap();
2720 index_one(&tx, &session, store, 0, 0).unwrap();
2721 tx.commit().unwrap();
2722 }
2723
2724 let hist = evidence_for(&c, "src/a.rs", 50).unwrap();
2725 assert_eq!(hist.path, "src/a.rs");
2726 assert_eq!(hist.sessions.len(), 2, "both sessions that edited the file");
2727 assert_eq!(
2728 hist.sessions[0].session.session_id, "new",
2729 "newest session first"
2730 );
2731 assert_eq!(hist.sessions[0].edits.len(), 1);
2732 assert_eq!(hist.sessions[0].edits[0].snippet, "v2");
2733 assert_eq!(hist.sessions[1].session.session_id, "old");
2734 }
2735}
2736
2737#[cfg(test)]
2738mod moved_file_tests {
2739 use super::*;
2740
2741 #[test]
2750 fn a_renamed_folder_still_traces_by_file_name() {
2751 assert_eq!(
2752 basename_fallback("/Users/b/Project/slack/diag.py").as_deref(),
2753 Some("diag.py"),
2754 "a full path falls back to its file name"
2755 );
2756 assert_eq!(basename_fallback("diag.py"), None);
2759 assert_eq!(basename_fallback(""), None);
2760 assert_eq!(basename_fallback("/Users/b/Project/slack/"), None);
2762 }
2763}
2764
2765#[cfg(test)]
2766mod legacy_migration_tests {
2767 use super::*;
2768
2769 #[test]
2777 fn a_legacy_index_is_only_looked_for_beside_the_new_one() {
2778 let under_home = std::path::Path::new("/home/someone/.local/share/sessionwiki");
2779 let got = legacy_candidates(under_home);
2780 assert_eq!(
2781 got,
2782 vec![
2783 std::path::PathBuf::from("/home/someone/.local/share/sessiondex"),
2784 std::path::PathBuf::from("/home/someone/.local/share/session-atlas"),
2785 ],
2786 "the normal case must keep working"
2787 );
2788
2789 let redirected = std::path::Path::new("/tmp/sessionwiki-test-xyz");
2790 for c in legacy_candidates(redirected) {
2791 assert!(
2792 c.starts_with("/tmp"),
2793 "a redirected run reached outside its own tree: {}",
2794 c.display()
2795 );
2796 }
2797 }
2798
2799 #[test]
2800 fn a_destination_with_no_parent_offers_nothing_to_migrate() {
2801 assert!(legacy_candidates(std::path::Path::new("/")).is_empty());
2802 }
2803}
2804
2805#[cfg(test)]
2806mod embedder_hook_tests {
2807 use super::*;
2808 use crate::adapters::{Adapter, Discovered, Store};
2809 use crate::model::{Message, Role, Session};
2810 use std::path::Path;
2811
2812 struct FakeStore {
2815 keys: Vec<(String, i64)>,
2816 scope: Option<String>,
2817 }
2818
2819 impl Adapter for FakeStore {
2820 fn name(&self) -> &'static str {
2821 "mjolnir"
2822 }
2823 fn root(&self) -> Option<PathBuf> {
2824 Some(std::env::current_dir().unwrap())
2827 }
2828 fn discover(&self) -> Discovered {
2829 Discovered {
2830 files: Vec::new(),
2831 had_error: false,
2832 }
2833 }
2834 fn parse(&self, _path: &Path) -> Result<Session> {
2835 anyhow::bail!("shared store")
2836 }
2837 fn store(&self) -> Option<Store> {
2838 Some(Store {
2839 keys: self.keys.clone(),
2840 files: Vec::new(),
2841 had_error: false,
2842 })
2843 }
2844 fn parse_key(&self, key: &str) -> Result<Session> {
2845 Ok(Session {
2846 id: key.rsplit('/').next().unwrap().to_string(),
2847 tool: "mjolnir",
2848 path: PathBuf::from(key),
2849 project: "/proj".into(),
2850 started: None,
2851 ended: None,
2852 title: "a restored session".into(),
2853 subagent: false,
2854 messages: vec![Message {
2855 role: Role::User,
2856 text: "make the tests green".into(),
2857 ts: None,
2858 }],
2859 touched: vec![],
2860 edits: vec![],
2861 })
2862 }
2863 fn reconcile_scope(&self) -> Option<String> {
2864 self.scope.clone()
2865 }
2866 }
2867
2868 fn insert_live_row(c: &Connection, path: &str, sid: &str) {
2869 c.execute(
2870 "INSERT INTO files(path, session_id, tool, mtime, size, project, title, msg_count, kind)
2871 VALUES(?1, ?2, 'mjolnir', 0, 0, '/proj', 't', 1, 'session')",
2872 params![path, sid],
2873 )
2874 .unwrap();
2875 c.execute(
2876 "INSERT INTO messages(session_id, role, text) VALUES(?1,'user','hello')",
2877 params![sid],
2878 )
2879 .unwrap();
2880 }
2881
2882 fn archived_at(c: &Connection, path: &str) -> Option<String> {
2883 c.query_row(
2884 "SELECT archived_at FROM files WHERE path = ?1",
2885 params![path],
2886 |r| r.get(0),
2887 )
2888 .unwrap()
2889 }
2890
2891 #[test]
2895 fn reconcile_scope_limits_archiving_to_the_adapters_own_keys() {
2896 let mut c = Connection::open_in_memory().unwrap();
2897 create_cache_schema(&c).unwrap();
2898 insert_live_row(&c, "/data/one/sess-a", "sa");
2899 insert_live_row(&c, "/data/two/sess-b", "sb");
2900
2901 let adapters: Vec<Box<dyn Adapter>> = vec![Box::new(FakeStore {
2902 keys: Vec::new(),
2903 scope: Some("/data/one/".to_string()),
2904 })];
2905 sync_with(&mut c, &adapters, None).unwrap();
2906
2907 assert!(
2908 archived_at(&c, "/data/one/sess-a").is_some(),
2909 "the in-scope row the adapter no longer lists must be archived"
2910 );
2911 assert!(
2912 archived_at(&c, "/data/two/sess-b").is_none(),
2913 "the other installation's row must be left live"
2914 );
2915 }
2916
2917 #[test]
2919 fn an_unscoped_adapter_still_archives_every_row_of_its_tool() {
2920 let mut c = Connection::open_in_memory().unwrap();
2921 create_cache_schema(&c).unwrap();
2922 insert_live_row(&c, "/data/one/sess-a", "sa");
2923 insert_live_row(&c, "/data/two/sess-b", "sb");
2924
2925 let adapters: Vec<Box<dyn Adapter>> = vec![Box::new(FakeStore {
2926 keys: Vec::new(),
2927 scope: None,
2928 })];
2929 sync_with(&mut c, &adapters, None).unwrap();
2930
2931 assert!(archived_at(&c, "/data/one/sess-a").is_some());
2932 assert!(archived_at(&c, "/data/two/sess-b").is_some());
2933 }
2934
2935 #[test]
2938 fn sync_with_indexes_a_session_from_a_supplied_adapter() {
2939 let mut c = Connection::open_in_memory().unwrap();
2940 create_cache_schema(&c).unwrap();
2941
2942 let adapters: Vec<Box<dyn Adapter>> = vec![Box::new(FakeStore {
2943 keys: vec![("/data/one/sess-a".to_string(), 42)],
2944 scope: Some("/data/one/".to_string()),
2945 })];
2946 sync_with(&mut c, &adapters, None).unwrap();
2947
2948 let rows = recent(&c, 10, Some("mjolnir"), None, None, false).unwrap();
2949 assert_eq!(rows.len(), 1, "the supplied adapter's session was indexed");
2950 assert_eq!(rows[0].session_id, "sess-a");
2951 assert_eq!(rows[0].title, "a restored session");
2952 assert_eq!(rows[0].msg_count, 1);
2953 assert!(!rows[0].archived, "a listed session stays live");
2954 }
2955
2956 #[test]
2959 fn a_session_keeps_its_own_tool_name_when_no_adapter_is_registered() {
2960 let mut c = Connection::open_in_memory().unwrap();
2961 create_cache_schema(&c).unwrap();
2962
2963 let adapters: Vec<Box<dyn Adapter>> = vec![Box::new(FakeStore {
2964 keys: vec![("/data/one/sess-a".to_string(), 42)],
2965 scope: Some("/data/one/".to_string()),
2966 })];
2967 sync_with(&mut c, &adapters, None).unwrap();
2968
2969 let rows = recent(&c, 10, Some("mjolnir"), None, None, false).unwrap();
2970 assert!(
2971 crate::adapters::by_name("mjolnir").is_none(),
2972 "the built-in registry must not know this tool, or the test proves nothing"
2973 );
2974 let session = session_from_index(&c, &rows[0]).unwrap();
2975 assert_eq!(session.tool, "mjolnir");
2976 }
2977
2978 #[test]
2981 fn interning_a_tool_name_twice_yields_the_same_string() {
2982 let first = interned_tool("a-tool-no-adapter-knows");
2983 let second = interned_tool(&String::from("a-tool-no-adapter-knows"));
2984 assert_eq!(first, "a-tool-no-adapter-knows");
2985 assert!(std::ptr::eq(first, second));
2986 }
2987}