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 pub i: usize,
1222}
1223
1224pub fn search(
1227 conn: &Connection,
1228 query: &str,
1229 limit: usize,
1230 tool: Option<&str>,
1231 project: Option<&str>,
1232) -> Result<Vec<Hit>> {
1233 let fts_query = format!("\"{}\"", crate::util::nfc(query).replace('"', "\"\""));
1238
1239 let mut sql = String::from(
1247 "SELECT f.session_id, f.tool, f.path, f.project, f.title, f.started, f.msg_count, f.kind,
1248 m.role, x.snip, min(x.rank) AS best, (f.archived_at IS NOT NULL),
1249 m.id AS mid
1250 FROM (SELECT rowid AS mid,
1251 snippet(msgs, 0, char(2), char(3), char(8230), 18) AS snip,
1252 rank
1253 FROM msgs WHERE msgs MATCH ? ORDER BY rank LIMIT 4000) x
1254 JOIN messages m ON m.id = x.mid
1255 JOIN files f ON f.session_id = m.session_id
1256 WHERE 1=1",
1257 );
1258 let mut args: Vec<String> = vec![fts_query];
1259 if let Some(t) = tool {
1260 sql.push_str(" AND f.tool = ?");
1261 args.push(t.to_string());
1262 }
1263 if let Some(p) = project {
1264 sql.push_str(" AND f.project LIKE ?");
1265 args.push(format!("%{}%", crate::util::nfc(p)));
1266 }
1267 sql.push_str(" GROUP BY f.session_id");
1268 let sql = format!(
1273 "SELECT g.*,
1274 (SELECT COUNT(*) FROM messages m2
1275 WHERE m2.session_id = g.session_id AND m2.id < g.mid) AS i
1276 FROM ({sql}) g ORDER BY g.best LIMIT {limit}"
1277 );
1278
1279 let mut stmt = conn.prepare(&sql)?;
1280 let rows = stmt.query_map(rusqlite::params_from_iter(args), |r| {
1281 Ok(Hit {
1282 row: SessionRow {
1283 session_id: r.get(0)?,
1284 tool: r.get(1)?,
1285 path: r.get(2)?,
1286 project: r.get(3)?,
1287 title: r.get(4)?,
1288 started: r.get(5)?,
1289 msg_count: r.get(6)?,
1290 kind: r.get(7)?,
1291 preview: None,
1292 summary: None,
1293 tags: None,
1294 archived: r.get(11)?,
1295 account: None,
1296 },
1297 role: r.get(8)?,
1298 snippet: r.get(9)?,
1299 i: r.get::<_, i64>(13)?.max(0) as usize,
1300 })
1301 })?;
1302 let mut out = rows.collect::<rusqlite::Result<Vec<_>>>()?;
1303 crate::account_link::annotate(out.iter_mut().map(|h| &mut h.row));
1304 Ok(out)
1305}
1306
1307pub fn search_like(
1320 conn: &Connection,
1321 query: &str,
1322 limit: usize,
1323 tool: Option<&str>,
1324 project: Option<&str>,
1325) -> Result<Vec<Hit>> {
1326 const SCAN_CAP: i64 = 50_000;
1327
1328 let q = crate::util::nfc(query.trim());
1331 let pattern = format!(
1332 "%{}%",
1333 q.replace('\\', "\\\\")
1334 .replace('%', "\\%")
1335 .replace('_', "\\_")
1336 );
1337
1338 let mut sql = String::from(
1339 "SELECT f.session_id, f.tool, f.path, f.project, f.title, f.started, f.msg_count, f.kind,
1340 x.role, x.text, (f.archived_at IS NOT NULL), max(x.mid) AS mid
1341 FROM (SELECT m.session_id AS sid, m.role AS role, m.text AS text, m.id AS mid
1342 FROM messages m
1343 WHERE m.text LIKE ?1 ESCAPE '\\'
1344 ORDER BY m.id DESC LIMIT ?2) x
1345 JOIN files f ON f.session_id = x.sid
1346 WHERE 1=1",
1347 );
1348 let mut args: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(pattern), Box::new(SCAN_CAP)];
1349 if let Some(t) = tool {
1350 sql.push_str(" AND f.tool = ?");
1351 args.push(Box::new(t.to_string()));
1352 }
1353 if let Some(p) = project {
1354 sql.push_str(" AND f.project LIKE ?");
1355 args.push(Box::new(format!("%{}%", crate::util::nfc(p))));
1356 }
1357 sql.push_str(" GROUP BY f.session_id");
1362 let sql = format!(
1363 "SELECT g.*,
1364 (SELECT COUNT(*) FROM messages m2
1365 WHERE m2.session_id = g.session_id AND m2.id < g.mid) AS i
1366 FROM ({sql}) g ORDER BY g.mid DESC LIMIT {limit}"
1367 );
1368
1369 let mut stmt = conn.prepare(&sql)?;
1370 let rows = stmt.query_map(
1371 rusqlite::params_from_iter(args.iter().map(|b| b.as_ref())),
1372 |r| {
1373 let role: String = r.get(8)?;
1374 let text: String = r.get(9)?;
1375 Ok(Hit {
1376 row: SessionRow {
1377 session_id: r.get(0)?,
1378 tool: r.get(1)?,
1379 path: r.get(2)?,
1380 project: r.get(3)?,
1381 title: r.get(4)?,
1382 started: r.get(5)?,
1383 msg_count: r.get(6)?,
1384 kind: r.get(7)?,
1385 preview: None,
1386 summary: None,
1387 tags: None,
1388 archived: r.get(10)?,
1389 account: None,
1390 },
1391 role,
1392 snippet: snippet_around(&text, &q),
1393 i: r.get::<_, i64>(12)?.max(0) as usize,
1394 })
1395 },
1396 )?;
1397 let mut out = rows.collect::<rusqlite::Result<Vec<_>>>()?;
1398 crate::account_link::annotate(out.iter_mut().map(|h| &mut h.row));
1399 Ok(out)
1400}
1401
1402fn snippet_around(text: &str, needle: &str) -> String {
1408 const WINDOW: usize = 36;
1409 let hay_chars: Vec<char> = text.to_lowercase().chars().collect();
1410 let nee_chars: Vec<char> = needle.to_lowercase().chars().collect();
1411 let chars: Vec<char> = text.chars().collect();
1412
1413 let match_at = if nee_chars.is_empty() {
1414 None
1415 } else {
1416 hay_chars
1417 .windows(nee_chars.len())
1418 .position(|w| w == nee_chars.as_slice())
1419 };
1420 let Some(start) = match_at else {
1421 return chars
1422 .iter()
1423 .take(WINDOW * 2)
1424 .collect::<String>()
1425 .replace('\n', " ");
1426 };
1427 let end = start + nee_chars.len();
1428 let lo = start.saturating_sub(WINDOW);
1429 let hi = (end + WINDOW).min(chars.len());
1430
1431 let mut out = String::new();
1432 if lo > 0 {
1433 out.push('\u{2026}');
1434 }
1435 out.extend(&chars[lo..start]);
1436 out.push('\u{2}');
1437 out.extend(&chars[start..end]);
1438 out.push('\u{3}');
1439 out.extend(&chars[end..hi]);
1440 if hi < chars.len() {
1441 out.push('\u{2026}');
1442 }
1443 out.replace('\n', " ")
1444}
1445
1446fn escape_like(s: &str) -> String {
1449 s.replace('\\', "\\\\")
1450 .replace('%', "\\%")
1451 .replace('_', "\\_")
1452}
1453
1454const RESOLVE_COLS: &str = "session_id, tool, path, project, title, started, msg_count, kind";
1457
1458fn map_resolve_row(r: &rusqlite::Row) -> rusqlite::Result<SessionRow> {
1461 Ok(SessionRow {
1462 session_id: r.get(0)?,
1463 tool: r.get(1)?,
1464 path: r.get(2)?,
1465 project: r.get(3)?,
1466 title: r.get(4)?,
1467 started: r.get(5)?,
1468 msg_count: r.get(6)?,
1469 kind: r.get(7)?,
1470 preview: None,
1471 summary: r.get(8)?,
1472 tags: r.get(9)?,
1473 archived: r.get(10)?,
1474 account: None,
1475 })
1476}
1477
1478pub fn resolve(conn: &Connection, id_prefix: &str) -> Result<Vec<SessionRow>> {
1486 let mut out = resolve_by_short_id(conn, id_prefix)?;
1487
1488 let native_ok =
1492 looks_like_native_prefix(id_prefix) && (out.is_empty() || id_prefix.contains('-'));
1493 if native_ok {
1494 for row in resolve_by_native_id(conn, id_prefix)? {
1495 if out.len() >= 10 {
1496 break;
1497 }
1498 if out.iter().all(|r| r.session_id != row.session_id) {
1499 out.push(row);
1500 }
1501 }
1502 }
1503 Ok(out)
1504}
1505
1506fn resolve_by_short_id(conn: &Connection, id_prefix: &str) -> Result<Vec<SessionRow>> {
1508 let mut stmt = conn.prepare(&format!(
1509 "SELECT {RESOLVE_COLS}, {SUMMARY_SQL}, {TAGS_SQL}, (archived_at IS NOT NULL)
1510 FROM files f WHERE session_id LIKE ?1 ESCAPE '\\' LIMIT 10",
1511 ))?;
1512 let pattern = format!("{}%", escape_like(id_prefix));
1513 let rows = stmt.query_map(params![pattern], map_resolve_row)?;
1514 Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
1515}
1516
1517fn resolve_by_native_id(conn: &Connection, prefix: &str) -> Result<Vec<SessionRow>> {
1522 let mut stmt = conn.prepare(&format!(
1523 "SELECT {RESOLVE_COLS}, {SUMMARY_SQL}, {TAGS_SQL}, (archived_at IS NOT NULL)
1524 FROM files f WHERE path LIKE ?1 ESCAPE '\\' LIMIT 500",
1525 ))?;
1526 let pattern = format!("%{}%", escape_like(prefix));
1527 let want = prefix.to_ascii_lowercase();
1528 let rows = stmt.query_map(params![pattern], map_resolve_row)?;
1529 let mut out = Vec::new();
1530 for row in rows {
1531 let row = row?;
1532 if native_id_of(&row.path).is_some_and(|n| n.starts_with(&want)) {
1533 out.push(row);
1534 if out.len() >= 10 {
1535 break;
1536 }
1537 }
1538 }
1539 Ok(out)
1540}
1541
1542pub fn locate_by_native_id(prefix: &str) -> Option<(String, PathBuf)> {
1551 if !looks_like_native_prefix(prefix) {
1552 return None;
1553 }
1554 let want = prefix.to_ascii_lowercase();
1555 for name in ["claude-code", "codex"] {
1556 let Some(adapter) = adapters::by_name(name) else {
1557 continue;
1558 };
1559 let Some(root) = adapter.root() else { continue };
1560 if !root.exists() {
1561 continue;
1562 }
1563 let hit = walkdir::WalkDir::new(&root)
1564 .into_iter()
1565 .filter_map(std::result::Result::ok)
1566 .find(|e| {
1567 e.file_type().is_file()
1568 && e.path().extension().is_some_and(|x| x == "jsonl")
1569 && native_id_of(&e.path().to_string_lossy())
1570 .is_some_and(|n| n.starts_with(&want))
1571 });
1572 if let Some(e) = hit {
1573 return Some((name.to_string(), e.into_path()));
1574 }
1575 }
1576 None
1577}
1578
1579pub fn live_row(tool: String, path: PathBuf) -> SessionRow {
1585 let path = path.to_string_lossy().into_owned();
1586 SessionRow {
1587 session_id: crate::util::short_id(&path),
1588 tool,
1589 path,
1590 project: String::new(),
1591 title: String::new(),
1592 started: None,
1593 msg_count: 0,
1594 kind: "main".into(),
1595 preview: None,
1596 summary: None,
1597 tags: None,
1598 archived: false,
1599 account: None,
1600 }
1601}
1602
1603pub fn set_summary(conn: &Connection, session_id: &str, summary: &str) -> Result<()> {
1607 conn.execute(
1608 "INSERT OR REPLACE INTO summaries(session_id, summary, created)
1609 VALUES (?1, ?2, datetime('now'))",
1610 params![session_id, crate::redact::redact(summary).as_ref()],
1611 )?;
1612 Ok(())
1613}
1614
1615pub fn unsummarized(
1617 conn: &Connection,
1618 limit: usize,
1619 tool: Option<&str>,
1620) -> Result<Vec<SessionRow>> {
1621 let mut sql = format!(
1622 "SELECT session_id, tool, path, project, title, started, msg_count, kind, {PREVIEW_SQL}, {SUMMARY_SQL}, {TAGS_SQL}, (archived_at IS NOT NULL)
1623 FROM files f
1624 WHERE kind = 'main' AND NOT EXISTS
1625 (SELECT 1 FROM summaries s WHERE s.session_id = f.session_id)",
1626 );
1627 let mut args: Vec<String> = Vec::new();
1628 if let Some(t) = tool {
1629 sql.push_str(" AND tool = ?");
1630 args.push(t.to_string());
1631 }
1632 sql.push_str(&format!(" ORDER BY started DESC LIMIT {limit}"));
1633 let mut stmt = conn.prepare(&sql)?;
1634 let rows = stmt.query_map(rusqlite::params_from_iter(args), |r| {
1635 Ok(SessionRow {
1636 session_id: r.get(0)?,
1637 tool: r.get(1)?,
1638 path: r.get(2)?,
1639 project: r.get(3)?,
1640 title: r.get(4)?,
1641 started: r.get(5)?,
1642 msg_count: r.get(6)?,
1643 kind: r.get(7)?,
1644 preview: r.get(8)?,
1645 summary: r.get(9)?,
1646 tags: r.get(10)?,
1647 archived: r.get(11)?,
1648 account: None,
1649 })
1650 })?;
1651 Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
1652}
1653
1654fn norm_tag(tag: &str) -> String {
1660 crate::util::nfc(&tag.trim().to_lowercase())
1661}
1662
1663pub fn add_tag(conn: &Connection, session_id: &str, tag: &str) -> Result<()> {
1664 let tag = norm_tag(tag);
1668 if tag.is_empty() || tag.contains(',') {
1669 anyhow::bail!("a tag must be non-empty and contain no commas");
1670 }
1671 conn.execute(
1672 "INSERT OR IGNORE INTO tags(session_id, tag) VALUES (?1, ?2)",
1673 params![session_id, tag],
1674 )?;
1675 Ok(())
1676}
1677
1678pub fn remove_tag(conn: &Connection, session_id: &str, tag: &str) -> Result<usize> {
1679 Ok(conn.execute(
1680 "DELETE FROM tags WHERE session_id = ?1 AND tag = ?2",
1681 params![session_id, norm_tag(tag)],
1682 )?)
1683}
1684
1685pub fn set_note(conn: &Connection, session_id: &str, note: &str) -> Result<()> {
1686 conn.execute(
1687 "INSERT OR REPLACE INTO notes(session_id, note, updated)
1688 VALUES (?1, ?2, datetime('now'))",
1689 params![session_id, note],
1690 )?;
1691 Ok(())
1692}
1693
1694pub fn note_for(conn: &Connection, session_id: &str) -> Result<Option<String>> {
1695 Ok(conn
1696 .query_row(
1697 "SELECT note FROM notes WHERE session_id = ?1",
1698 params![session_id],
1699 |r| r.get(0),
1700 )
1701 .ok())
1702}
1703
1704pub fn tag_counts(conn: &Connection) -> Result<Vec<(String, i64)>> {
1706 let mut stmt =
1707 conn.prepare("SELECT tag, count(*) FROM tags GROUP BY tag ORDER BY count(*) DESC, tag")?;
1708 let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?;
1709 Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
1710}
1711
1712pub fn files_for(conn: &Connection, session_id: &str) -> Result<Vec<String>> {
1716 let mut stmt = conn.prepare("SELECT path FROM touched WHERE session_id = ?1 ORDER BY rowid")?;
1717 let rows = stmt.query_map(params![session_id], |r| r.get(0))?;
1718 Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
1719}
1720
1721#[derive(Debug, Serialize)]
1724pub struct FileEdit {
1725 pub session_id: String,
1726 pub kind: String,
1727 pub ts: Option<String>,
1728 pub snippet: String,
1729 pub matched_path: String,
1730}
1731
1732fn sql_limit(n: usize) -> i64 {
1739 i64::try_from(n).unwrap_or(i64::MAX)
1740}
1741
1742pub fn edits_for(conn: &Connection, query: &str, limit: usize) -> Result<Vec<FileEdit>> {
1743 let q = crate::util::nfc(query.trim().trim_start_matches("./"));
1744 let esc = q
1746 .replace('\\', "\\\\")
1747 .replace('%', "\\%")
1748 .replace('_', "\\_");
1749 let suffix = format!("%/{esc}");
1750 let mut stmt = conn.prepare(
1751 "SELECT session_id, kind, ts, snippet, path
1752 FROM edits
1753 WHERE path = ?1 OR path LIKE ?2 ESCAPE '\\'
1754 OR (length(?1) > length(path)
1755 AND substr(?1, -length(path)) = path
1756 AND substr(?1, -length(path)-1, 1) = '/')
1757 ORDER BY ts DESC, rowid DESC LIMIT ?3",
1758 )?;
1759 let rows = stmt.query_map(params![q, suffix, sql_limit(limit)], |r| {
1760 Ok(FileEdit {
1761 session_id: r.get(0)?,
1762 kind: r.get(1)?,
1763 ts: r.get(2)?,
1764 snippet: r.get(3)?,
1765 matched_path: r.get(4)?,
1766 })
1767 })?;
1768 Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
1769}
1770
1771pub fn edits_for_session(
1777 conn: &Connection,
1778 session_id: &str,
1779 query: &str,
1780 limit: usize,
1781) -> Result<Vec<FileEdit>> {
1782 let q = crate::util::nfc(query.trim().trim_start_matches("./"));
1783 let esc = q
1784 .replace('\\', "\\\\")
1785 .replace('%', "\\%")
1786 .replace('_', "\\_");
1787 let suffix = format!("%/{esc}");
1788 let mut stmt = conn.prepare(
1789 "SELECT session_id, kind, ts, snippet, path
1790 FROM edits
1791 WHERE session_id = ?1
1792 AND (path = ?2 OR path LIKE ?3 ESCAPE '\\'
1793 OR (length(?2) > length(path)
1794 AND substr(?2, -length(path)) = path
1795 AND substr(?2, -length(path)-1, 1) = '/'))
1796 ORDER BY ts DESC, rowid DESC LIMIT ?4",
1797 )?;
1798 let rows = stmt.query_map(params![session_id, q, suffix, sql_limit(limit)], |r| {
1799 Ok(FileEdit {
1800 session_id: r.get(0)?,
1801 kind: r.get(1)?,
1802 ts: r.get(2)?,
1803 snippet: r.get(3)?,
1804 matched_path: r.get(4)?,
1805 })
1806 })?;
1807 Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
1808}
1809
1810#[derive(Serialize)]
1813pub struct SessionEvidence {
1814 pub session: SessionRow,
1815 pub edits: Vec<FileEdit>,
1816}
1817
1818#[derive(Serialize)]
1821pub struct FileHistory {
1822 pub path: String,
1823 pub sessions: Vec<SessionEvidence>,
1824}
1825
1826pub fn evidence_for(conn: &Connection, path: &str, limit: usize) -> Result<FileHistory> {
1831 let sessions = sessions_for_file(conn, path, limit)?;
1832 const PER_SESSION_EDIT_CAP: usize = 500;
1836 let sessions = sessions
1837 .into_iter()
1838 .map(|(session, _matched)| {
1839 let edits = edits_for_session(conn, &session.session_id, path, PER_SESSION_EDIT_CAP)?;
1840 Ok(SessionEvidence { session, edits })
1841 })
1842 .collect::<Result<Vec<_>>>()?;
1843 Ok(FileHistory {
1844 path: path.to_string(),
1845 sessions,
1846 })
1847}
1848
1849pub fn basename_fallback(query: &str) -> Option<String> {
1864 let q = query.trim();
1865 if q.is_empty() || q.ends_with('/') {
1866 return None;
1867 }
1868 let (head, name) = q.rsplit_once('/')?;
1869 (!head.is_empty() && !name.is_empty()).then(|| name.to_string())
1870}
1871
1872pub fn sessions_for_file(
1873 conn: &Connection,
1874 query: &str,
1875 limit: usize,
1876) -> Result<Vec<(SessionRow, String)>> {
1877 let q = crate::util::nfc(query.trim().trim_start_matches("./"));
1878 let esc = q
1882 .replace('\\', "\\\\")
1883 .replace('%', "\\%")
1884 .replace('_', "\\_");
1885 let suffix = format!("%/{esc}");
1886 let mut stmt = conn.prepare(&format!(
1887 "SELECT f.session_id, f.tool, f.path, f.project, f.title, f.started, f.msg_count, f.kind,
1888 {SUMMARY_SQL}, {TAGS_SQL}, t.path, (f.archived_at IS NOT NULL)
1889 FROM touched t JOIN files f ON f.session_id = t.session_id
1890 WHERE t.path = ?1 OR t.path LIKE ?2 ESCAPE '\\'
1891 OR (length(?1) > length(t.path)
1892 AND substr(?1, -length(t.path)) = t.path
1893 AND substr(?1, -length(t.path)-1, 1) = '/')
1894 GROUP BY f.session_id
1895 ORDER BY f.started DESC, f.session_id LIMIT ?3"
1896 ))?;
1897 let rows = stmt.query_map(params![q, suffix, sql_limit(limit)], |r| {
1898 Ok((
1899 SessionRow {
1900 session_id: r.get(0)?,
1901 tool: r.get(1)?,
1902 path: r.get(2)?,
1903 project: r.get(3)?,
1904 title: r.get(4)?,
1905 started: r.get(5)?,
1906 msg_count: r.get(6)?,
1907 kind: r.get(7)?,
1908 preview: None,
1909 summary: r.get(8)?,
1910 tags: r.get(9)?,
1911 archived: r.get(11)?,
1912 account: None,
1913 },
1914 r.get::<_, String>(10)?,
1915 ))
1916 })?;
1917 let mut out = rows.collect::<rusqlite::Result<Vec<_>>>()?;
1918 crate::account_link::annotate(out.iter_mut().map(|(r, _)| r));
1919 Ok(out)
1920}
1921
1922pub fn sessions_touching(
1928 conn: &Connection,
1929 query: &str,
1930) -> Result<Vec<crate::blame::TouchingSession>> {
1931 let q = crate::util::nfc(query.trim().trim_start_matches("./"));
1932 let suffix = format!("%/{q}");
1933 let mut stmt = conn.prepare(
1934 "SELECT f.session_id, f.tool, f.title, f.project, f.started, f.ended, (f.archived_at IS NOT NULL)
1935 FROM touched t JOIN files f ON f.session_id = t.session_id
1936 WHERE t.path = ?1 OR t.path LIKE ?2
1937 GROUP BY f.session_id",
1938 )?;
1939 let rows = stmt.query_map(params![q, suffix], |r| {
1940 let started: Option<String> = r.get(4)?;
1941 let ended: Option<String> = r.get(5)?;
1942 Ok(crate::blame::TouchingSession {
1943 session_id: r.get(0)?,
1944 tool: r.get(1)?,
1945 title: r.get(2)?,
1946 project: r.get(3)?,
1947 started: started.as_deref().and_then(to_epoch),
1948 ended: ended.as_deref().and_then(to_epoch),
1949 archived: r.get(6)?,
1950 })
1951 })?;
1952 Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
1953}
1954
1955fn to_epoch(s: &str) -> Option<i64> {
1957 chrono::DateTime::parse_from_rfc3339(s)
1958 .ok()
1959 .map(|d| d.timestamp())
1960}
1961
1962fn interned_tool(name: &str) -> &'static str {
1973 use std::collections::HashSet;
1974 use std::sync::{Mutex, OnceLock};
1975 static NAMES: OnceLock<Mutex<HashSet<&'static str>>> = OnceLock::new();
1976 let mut names = NAMES
1977 .get_or_init(|| Mutex::new(HashSet::new()))
1978 .lock()
1979 .unwrap_or_else(std::sync::PoisonError::into_inner);
1980 if let Some(existing) = names.get(name) {
1981 return existing;
1982 }
1983 let leaked: &'static str = Box::leak(name.to_owned().into_boxed_str());
1984 names.insert(leaked);
1985 leaked
1986}
1987
1988pub fn session_from_index(conn: &Connection, row: &SessionRow) -> Result<crate::model::Session> {
1992 use crate::model::{Message, Role};
1993 let mut stmt =
1994 conn.prepare("SELECT role, text FROM messages WHERE session_id = ?1 ORDER BY id")?;
1995 let messages: Vec<Message> = stmt
1996 .query_map(params![row.session_id], |r| {
1997 let role: String = r.get(0)?;
1998 let text: String = r.get(1)?;
1999 Ok(Message {
2000 role: match role.as_str() {
2001 "user" => Role::User,
2002 "assistant" => Role::Assistant,
2003 _ => Role::Tool,
2004 },
2005 text,
2006 ts: None,
2007 })
2008 })?
2009 .collect::<rusqlite::Result<_>>()?;
2010 drop(stmt);
2011
2012 let tool = adapters::by_name(&row.tool)
2013 .map(|a| a.name())
2014 .unwrap_or_else(|| interned_tool(&row.tool));
2015 let started = row
2016 .started
2017 .as_deref()
2018 .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
2019 .map(|t| t.with_timezone(&chrono::Utc));
2020 Ok(crate::model::Session {
2021 id: row.session_id.clone(),
2022 tool,
2023 path: std::path::PathBuf::from(row.path.replace('\u{1f}', "#")),
2028 project: row.project.clone(),
2029 started,
2030 ended: started,
2031 title: row.title.clone(),
2032 subagent: row.kind == "sub",
2033 messages,
2034 touched: files_for(conn, &row.session_id)?,
2035 edits: Vec::new(),
2036 })
2037}
2038
2039pub fn forget(conn: &mut Connection, session_id: &str) -> Result<()> {
2045 let tx = conn.transaction()?;
2046 delete_session_msgs(&tx, session_id)?;
2047 for table in [
2048 "files",
2049 "touched",
2050 "edits",
2051 "archive",
2052 "summaries",
2053 "tags",
2054 "notes",
2055 ] {
2056 tx.execute(
2057 &format!("DELETE FROM {table} WHERE session_id = ?1"),
2058 params![session_id],
2059 )?;
2060 }
2061 tx.commit()?;
2062 Ok(())
2063}
2064
2065pub fn related(conn: &Connection, session_id: &str, limit: usize) -> Result<Vec<SessionRow>> {
2074 let Some(target) = resolve(conn, session_id)?.into_iter().next() else {
2075 return Ok(vec![]);
2076 };
2077 let target_tags: Vec<String> = target
2078 .tags
2079 .as_deref()
2080 .map(|t| t.split(',').map(String::from).collect())
2081 .unwrap_or_default();
2082
2083 let mut out: Vec<SessionRow> = Vec::new();
2084 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
2085 seen.insert(target.session_id.clone());
2086
2087 if !target.project.is_empty() {
2089 let sql = format!(
2090 "SELECT session_id, tool, path, project, title, started, msg_count, kind,
2091 {PREVIEW_SQL}, {SUMMARY_SQL}, {TAGS_SQL}, (archived_at IS NOT NULL)
2092 FROM files f
2093 WHERE kind = 'main' AND project = ?1 AND session_id != ?2
2094 ORDER BY started DESC LIMIT ?3"
2095 );
2096 let mut stmt = conn.prepare(&sql)?;
2097 let rows = stmt.query_map(
2098 params![target.project, target.session_id, limit as i64 + 1],
2099 map_row,
2100 )?;
2101 for row in rows {
2102 let row = row?;
2103 if seen.insert(row.session_id.clone()) {
2104 out.push(row);
2105 }
2106 }
2107 }
2108
2109 if out.len() < limit {
2113 let sql = format!(
2114 "SELECT DISTINCT f.session_id, f.tool, f.path, f.project, f.title, f.started,
2115 f.msg_count, f.kind, {PREVIEW_SQL}, {SUMMARY_SQL}, {TAGS_SQL}, (f.archived_at IS NOT NULL)
2116 FROM touched a
2117 JOIN touched b ON a.path = b.path AND b.session_id != a.session_id
2118 JOIN files f ON f.session_id = b.session_id
2119 WHERE a.session_id = ?1 AND f.kind = 'main'
2120 ORDER BY f.started DESC LIMIT 50"
2121 );
2122 let mut stmt = conn.prepare(&sql)?;
2123 let rows = stmt.query_map(params![target.session_id], map_row)?;
2124 for row in rows {
2125 let row = row?;
2126 if seen.insert(row.session_id.clone()) {
2127 out.push(row);
2128 if out.len() >= limit {
2129 break;
2130 }
2131 }
2132 }
2133 }
2134
2135 if out.len() < limit && !target_tags.is_empty() {
2137 let placeholders = target_tags
2138 .iter()
2139 .map(|_| "?")
2140 .collect::<Vec<_>>()
2141 .join(",");
2142 let sql = format!(
2143 "SELECT DISTINCT f.session_id, f.tool, f.path, f.project, f.title, f.started,
2144 f.msg_count, f.kind, {PREVIEW_SQL}, {SUMMARY_SQL}, {TAGS_SQL}, (f.archived_at IS NOT NULL)
2145 FROM files f JOIN tags t ON t.session_id = f.session_id
2146 WHERE f.kind = 'main' AND t.tag IN ({placeholders})
2147 ORDER BY f.started DESC LIMIT 50"
2148 );
2149 let mut stmt = conn.prepare(&sql)?;
2150 let rows = stmt.query_map(rusqlite::params_from_iter(&target_tags), map_row)?;
2151 for row in rows {
2152 let row = row?;
2153 if seen.insert(row.session_id.clone()) {
2154 out.push(row);
2155 if out.len() >= limit {
2156 break;
2157 }
2158 }
2159 }
2160 }
2161
2162 out.truncate(limit);
2163 crate::account_link::annotate(out.iter_mut());
2164 Ok(out)
2165}
2166
2167fn map_row(r: &rusqlite::Row) -> rusqlite::Result<SessionRow> {
2169 Ok(SessionRow {
2170 session_id: r.get(0)?,
2171 tool: r.get(1)?,
2172 path: r.get(2)?,
2173 project: r.get(3)?,
2174 title: r.get(4)?,
2175 started: r.get(5)?,
2176 msg_count: r.get(6)?,
2177 kind: r.get(7)?,
2178 preview: r.get(8)?,
2179 summary: r.get(9)?,
2180 tags: r.get(10)?,
2181 archived: r.get(11)?,
2182 account: None,
2183 })
2184}
2185
2186pub struct ProjectRow {
2189 pub project: String,
2190 pub sessions: i64,
2191 pub messages: i64,
2192 pub oldest: Option<String>,
2193 pub newest: Option<String>,
2194}
2195
2196pub fn projects(conn: &Connection) -> Result<Vec<ProjectRow>> {
2198 let mut stmt = conn.prepare(
2199 "SELECT project, count(*), coalesce(sum(msg_count), 0), min(started), max(started)
2200 FROM files WHERE kind = 'main' AND project != ''
2201 GROUP BY project ORDER BY count(*) DESC, max(started) DESC",
2202 )?;
2203 let rows = stmt.query_map([], |r| {
2204 Ok(ProjectRow {
2205 project: r.get(0)?,
2206 sessions: r.get(1)?,
2207 messages: r.get(2)?,
2208 oldest: r.get(3)?,
2209 newest: r.get(4)?,
2210 })
2211 })?;
2212 Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
2213}
2214
2215pub struct Stats {
2216 pub per_tool: Vec<(String, i64, i64)>, pub per_month: Vec<(String, i64)>, pub total_sessions: i64,
2219 pub total_messages: i64,
2220 pub projects: i64,
2221 pub tags: i64,
2222 pub summarized: i64,
2223 pub files: i64,
2225 pub archived: i64,
2227}
2228
2229pub fn stats(conn: &Connection) -> Result<Stats> {
2230 let mut per_tool_stmt = conn.prepare(
2231 "SELECT tool, count(*), coalesce(sum(msg_count),0) FROM files WHERE kind='main'
2232 GROUP BY tool ORDER BY count(*) DESC",
2233 )?;
2234 let per_tool = per_tool_stmt
2235 .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?
2236 .collect::<rusqlite::Result<Vec<_>>>()?;
2237
2238 let mut per_month_stmt = conn.prepare(
2239 "SELECT substr(started,1,7) AS ym, count(*) FROM files
2240 WHERE kind='main' AND started IS NOT NULL
2241 GROUP BY ym ORDER BY ym DESC LIMIT 12",
2242 )?;
2243 let per_month = per_month_stmt
2244 .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
2245 .collect::<rusqlite::Result<Vec<_>>>()?;
2246
2247 let one = |sql: &str| -> Result<i64> { Ok(conn.query_row(sql, [], |r| r.get(0))?) };
2248 Ok(Stats {
2249 per_tool,
2250 per_month,
2251 total_sessions: one("SELECT count(*) FROM files WHERE kind='main'")?,
2252 total_messages: one("SELECT coalesce(sum(msg_count),0) FROM files WHERE kind='main'")?,
2253 projects: one(
2254 "SELECT count(DISTINCT project) FROM files WHERE kind='main' AND project!=''",
2255 )?,
2256 tags: one("SELECT count(DISTINCT tag) FROM tags")?,
2257 summarized: one("SELECT count(*) FROM summaries")?,
2258 files: one("SELECT count(DISTINCT path) FROM touched")?,
2259 archived: one("SELECT count(*) FROM files WHERE archived_at IS NOT NULL")?,
2260 })
2261}
2262
2263#[cfg(test)]
2264mod native_id_tests {
2265 use super::*;
2266
2267 const CODEX: &str = "/home/u/.codex/sessions/2025/05/13/rollout-2025-05-13T18-19-30-0a000000-0000-4000-8000-000000000001.jsonl";
2270 const CODEX_UUID: &str = "0a000000-0000-4000-8000-000000000001";
2271 const CLAUDE: &str =
2272 "/home/u/.claude/projects/-home-u-proj/1b111111-1111-4111-8111-111111111111.jsonl";
2273 const CLAUDE_UUID: &str = "1b111111-1111-4111-8111-111111111111";
2274 const SUBAGENT: &str = "/home/u/.claude/projects/-x/1b111111-1111-4111-8111-111111111111/subagents/agent-2c222222-2222-4222-8222-222222222222.jsonl";
2275 const SUBAGENT_UUID: &str = "2c222222-2222-4222-8222-222222222222";
2276
2277 fn mem() -> Connection {
2278 let c = Connection::open_in_memory().unwrap();
2279 c.execute_batch(
2280 "CREATE TABLE files(
2281 path TEXT PRIMARY KEY, mtime INTEGER NOT NULL DEFAULT 0,
2282 size INTEGER NOT NULL DEFAULT 0, session_id TEXT NOT NULL,
2283 tool TEXT NOT NULL, project TEXT NOT NULL DEFAULT '',
2284 title TEXT NOT NULL DEFAULT '', started TEXT, ended TEXT,
2285 msg_count INTEGER NOT NULL DEFAULT 0,
2286 kind TEXT NOT NULL DEFAULT 'main', archived_at TEXT);
2287 CREATE TABLE summaries(session_id TEXT PRIMARY KEY, summary TEXT NOT NULL, created TEXT NOT NULL);
2288 CREATE TABLE tags(session_id TEXT NOT NULL, tag TEXT NOT NULL, PRIMARY KEY(session_id, tag));",
2289 )
2290 .unwrap();
2291 c
2292 }
2293
2294 fn seed(c: &Connection, tool: &str, path: &str) -> String {
2296 let sid = crate::util::short_id(path);
2297 c.execute(
2298 "INSERT INTO files(path, session_id, tool, msg_count) VALUES(?1,?2,?3,3)",
2299 params![path, sid, tool],
2300 )
2301 .unwrap();
2302 sid
2303 }
2304
2305 #[test]
2306 fn native_id_extracted_per_tool() {
2307 assert_eq!(native_id_of(CODEX).as_deref(), Some(CODEX_UUID));
2308 assert_eq!(native_id_of(CLAUDE).as_deref(), Some(CLAUDE_UUID));
2309 assert_eq!(native_id_of(SUBAGENT).as_deref(), Some(SUBAGENT_UUID));
2312 assert_eq!(native_id_of("/x/opencode.db#session-42"), None);
2315 }
2316
2317 #[test]
2318 fn native_id_uppercase_is_normalized_to_lowercase() {
2319 let p = "/a/b/AB000000-0000-4000-8000-0000000000FF.jsonl";
2320 assert_eq!(
2321 native_id_of(p).as_deref(),
2322 Some("ab000000-0000-4000-8000-0000000000ff")
2323 );
2324 }
2325
2326 #[test]
2327 fn resolve_by_full_native_id() {
2328 let c = mem();
2329 let sid = seed(&c, "codex", CODEX);
2330 let hits = resolve(&c, CODEX_UUID).unwrap();
2331 assert_eq!(hits.len(), 1, "full native uuid resolves");
2332 assert_eq!(hits[0].session_id, sid);
2333 }
2334
2335 #[test]
2336 fn resolve_by_native_prefix_hex_and_dashed() {
2337 let c = mem();
2338 let sid = seed(&c, "claude-code", CLAUDE);
2339 let a = resolve(&c, "1b111111").unwrap();
2341 assert_eq!(a.len(), 1, "8-hex native prefix resolves");
2342 assert_eq!(a[0].session_id, sid);
2343 let b = resolve(&c, "1b111111-1111").unwrap();
2345 assert_eq!(b.len(), 1, "dashed native prefix resolves");
2346 assert_eq!(b[0].session_id, sid);
2347 }
2348
2349 #[test]
2350 fn resolve_still_matches_short_id_unchanged() {
2351 let c = mem();
2352 let sid = seed(&c, "codex", CODEX);
2353 assert_eq!(resolve(&c, &sid).unwrap().len(), 1);
2355 assert_eq!(resolve(&c, &sid[..6]).unwrap()[0].session_id, sid);
2356 }
2357
2358 #[test]
2359 fn short_id_lookup_does_not_run_the_native_scan() {
2360 assert!(!looks_like_native_prefix("abcdef012345"));
2364 assert!(looks_like_native_prefix("0a000000"));
2366 assert!(looks_like_native_prefix(
2367 "0a000000-0000-4000-8000-000000000001"
2368 ));
2369 assert!(looks_like_native_prefix("0a000000-0000"));
2370 assert!(!looks_like_native_prefix("zzz"));
2372 assert!(!looks_like_native_prefix("a1"));
2373 assert!(!looks_like_native_prefix(""));
2374 }
2375
2376 #[test]
2377 fn native_prefix_never_hides_an_existing_short_id_match() {
2378 let c = mem();
2382 let native_path =
2385 "/home/u/.codex/sessions/2025/01/01/rollout-2025-01-01T00-00-00-deadbeef-0000-4000-8000-000000000009.jsonl";
2386 seed(&c, "codex", native_path);
2387 c.execute(
2389 "INSERT INTO files(path, session_id, tool, msg_count) VALUES('/synthetic', 'deadbeef1234', 'codex', 1)",
2390 [],
2391 )
2392 .unwrap();
2393 let hits = resolve(&c, "deadbeef").unwrap();
2394 assert_eq!(hits.len(), 1);
2396 assert_eq!(hits[0].session_id, "deadbeef1234");
2397 }
2398
2399 #[test]
2400 fn session_row_serializes_native_id_not_path() {
2401 let row = SessionRow {
2402 session_id: "abc123def456".into(),
2403 tool: "codex".into(),
2404 path: CODEX.into(),
2405 project: "proj".into(),
2406 title: "t".into(),
2407 started: None,
2408 msg_count: 3,
2409 kind: "main".into(),
2410 preview: None,
2411 summary: None,
2412 tags: None,
2413 archived: false,
2414 account: None,
2415 };
2416 let v = serde_json::to_value(&row).unwrap();
2417 assert_eq!(v["id"], "abc123def456");
2418 assert_eq!(v["native_id"], CODEX_UUID, "native_id present in JSON");
2419 assert!(
2420 v.get("path").is_none(),
2421 "the absolute path is never serialized"
2422 );
2423 let mut row2 = row;
2425 row2.path = "/x/opencode.db#s1".into();
2426 let v2 = serde_json::to_value(&row2).unwrap();
2427 assert!(v2["native_id"].is_null());
2428 }
2429}
2430
2431#[cfg(test)]
2432mod edits_tests {
2433 use super::*;
2434
2435 fn conn_with_edits() -> Connection {
2436 let c = Connection::open_in_memory().unwrap();
2437 c.execute_batch(
2438 "CREATE TABLE edits(session_id TEXT NOT NULL, path TEXT NOT NULL,
2439 kind TEXT NOT NULL, ts TEXT, snippet TEXT NOT NULL);
2440 CREATE INDEX idx_edits_path ON edits(path);",
2441 )
2442 .unwrap();
2443 c
2444 }
2445
2446 fn add(c: &Connection, sid: &str, path: &str, kind: &str, ts: &str, snip: &str) {
2447 c.execute(
2448 "INSERT INTO edits(session_id, path, kind, ts, snippet) VALUES(?1,?2,?3,?4,?5)",
2449 params![sid, path, kind, ts, snip],
2450 )
2451 .unwrap();
2452 }
2453
2454 #[test]
2455 fn edits_for_returns_a_files_edits_by_suffix_newest_first() {
2456 let c = conn_with_edits();
2457 add(
2458 &c,
2459 "s1",
2460 "/home/me/proj/src/auth.rs",
2461 "edit",
2462 "2026-06-08T10:00:00Z",
2463 "let a = 1;",
2464 );
2465 add(
2466 &c,
2467 "s2",
2468 "/home/me/proj/src/auth.rs",
2469 "write",
2470 "2026-06-09T10:00:00Z",
2471 "fn main() {}",
2472 );
2473 add(
2474 &c,
2475 "s3",
2476 "/home/me/proj/src/other.rs",
2477 "edit",
2478 "2026-06-10T10:00:00Z",
2479 "nope",
2480 );
2481
2482 let hits = edits_for(&c, "src/auth.rs", 50).unwrap();
2484
2485 assert_eq!(hits.len(), 2, "both auth.rs edits, not other.rs");
2486 assert_eq!(hits[0].kind, "write", "newest edit first");
2487 assert!(hits[0].snippet.contains("fn main()"));
2488 assert_eq!(hits[1].kind, "edit");
2489 }
2490
2491 #[test]
2492 fn index_one_persists_a_sessions_edits() {
2493 use crate::model::{EditEvent, EditKind, Session};
2494 let mut c = Connection::open_in_memory().unwrap();
2495 create_cache_schema(&c).unwrap();
2496
2497 let session = Session {
2498 id: "sx".into(),
2499 tool: "claude-code",
2500 path: "/store/sx.jsonl".into(),
2501 project: "/proj".into(),
2502 started: None,
2503 ended: None,
2504 title: "t".into(),
2505 subagent: false,
2506 messages: vec![],
2507 touched: vec!["/proj/src/auth.rs".into()],
2508 edits: vec![EditEvent {
2509 path: "/proj/src/auth.rs".into(),
2510 kind: EditKind::Write,
2511 snippet: "fn main() {}".into(),
2512 ts: None,
2513 }],
2514 };
2515
2516 let tx = c.transaction().unwrap();
2517 index_one(&tx, &session, "/store/sx.jsonl", 0, 0).unwrap();
2518 tx.commit().unwrap();
2519
2520 let hits = edits_for(&c, "src/auth.rs", 50).unwrap();
2521 assert_eq!(hits.len(), 1, "the session's one edit was persisted");
2522 assert_eq!(hits[0].session_id, "sx");
2523 assert_eq!(hits[0].kind, "write");
2524 assert!(hits[0].snippet.contains("fn main()"));
2525 }
2526
2527 #[test]
2528 fn forget_removes_a_sessions_edits() {
2529 let mut c = Connection::open_in_memory().unwrap();
2530 create_cache_schema(&c).unwrap();
2531 c.execute(
2532 "INSERT INTO files(path, session_id, tool, mtime, size) VALUES('/store/s.jsonl','s1','claude-code',0,0)",
2533 [],
2534 )
2535 .unwrap();
2536 c.execute(
2537 "INSERT INTO edits(session_id,path,kind,ts,snippet) VALUES('s1','/proj/a.rs','write',NULL,'x')",
2538 [],
2539 )
2540 .unwrap();
2541 assert_eq!(edits_for(&c, "a.rs", 10).unwrap().len(), 1);
2542
2543 forget(&mut c, "s1").unwrap();
2544
2545 assert!(
2546 edits_for(&c, "a.rs", 10).unwrap().is_empty(),
2547 "forget must remove the session's edits, not orphan them"
2548 );
2549 }
2550
2551 #[test]
2552 fn edits_for_is_deterministic_when_timestamps_tie() {
2553 let c = conn_with_edits();
2554 add(&c, "s1", "/p/a.rs", "edit", "2026-01-01T00:00:00Z", "first");
2555 add(
2556 &c,
2557 "s2",
2558 "/p/a.rs",
2559 "write",
2560 "2026-01-01T00:00:00Z",
2561 "second",
2562 );
2563 let hits = edits_for(&c, "a.rs", 10).unwrap();
2564 assert_eq!(hits[0].snippet, "second");
2566 assert_eq!(hits[1].snippet, "first");
2567 }
2568
2569 #[test]
2570 fn edits_for_session_returns_only_that_sessions_edits() {
2571 let c = conn_with_edits();
2572 add(
2573 &c,
2574 "s1",
2575 "/p/a.rs",
2576 "edit",
2577 "2026-01-01T00:00:00Z",
2578 "s1-edit",
2579 );
2580 add(
2581 &c,
2582 "s2",
2583 "/p/a.rs",
2584 "write",
2585 "2026-02-01T00:00:00Z",
2586 "s2-edit",
2587 );
2588 let hits = edits_for_session(&c, "s1", "/p/a.rs", 10).unwrap();
2591 assert_eq!(hits.len(), 1);
2592 assert_eq!(hits[0].snippet, "s1-edit");
2593 }
2594
2595 #[test]
2596 fn edits_for_session_matches_every_spelling_of_the_path() {
2597 let c = conn_with_edits();
2598 add(
2600 &c,
2601 "s1",
2602 "/proj/src/auth.rs",
2603 "edit",
2604 "2026-01-01T00:00:00Z",
2605 "abs",
2606 );
2607 add(
2608 &c,
2609 "s1",
2610 "src/auth.rs",
2611 "write",
2612 "2026-01-02T00:00:00Z",
2613 "rel",
2614 );
2615 add(
2616 &c,
2617 "s2",
2618 "/other/auth.rs",
2619 "edit",
2620 "2026-01-03T00:00:00Z",
2621 "different-file",
2622 );
2623 let hits = edits_for_session(&c, "s1", "src/auth.rs", 10).unwrap();
2626 assert_eq!(hits.len(), 2, "all of s1's edits to the file, any spelling");
2627 }
2628
2629 #[test]
2630 fn index_redacts_secrets_in_messages_and_edit_snippets() {
2631 use crate::model::{EditEvent, EditKind, Message, Role, Session};
2632 let mut c = Connection::open_in_memory().unwrap();
2633 create_cache_schema(&c).unwrap();
2634 let session = Session {
2635 id: "sx".into(),
2636 tool: "claude-code",
2637 path: "/s.jsonl".into(),
2638 project: "/p".into(),
2639 started: None,
2640 ended: None,
2641 title: "title with AKIAIOSFODNN7EXAMPLE in it".into(),
2642 subagent: false,
2643 messages: vec![Message {
2644 role: Role::User,
2645 text: "my key is sk-abcdef012345678901234567890123 ok".into(),
2646 ts: None,
2647 }],
2648 touched: vec!["/p/a.rs".into()],
2649 edits: vec![EditEvent {
2650 path: "/p/a.rs".into(),
2651 kind: EditKind::Write,
2652 snippet: "const T = \"ghp_016C7f9aBcDeFgHiJkLmNoPqRsTuVwXyZ012\";".into(),
2653 ts: None,
2654 }],
2655 };
2656 let tx = c.transaction().unwrap();
2657 index_one(&tx, &session, "/s.jsonl", 0, 0).unwrap();
2658 tx.commit().unwrap();
2659
2660 let msg: String = c
2661 .query_row("SELECT text FROM messages WHERE session_id='sx'", [], |r| {
2662 r.get(0)
2663 })
2664 .unwrap();
2665 assert!(!msg.contains("sk-abcdef"), "message secret redacted: {msg}");
2666 assert!(msg.contains("[redacted:openai]"), "{msg}");
2667 let snip = edits_for(&c, "a.rs", 10).unwrap()[0].snippet.clone();
2668 assert!(!snip.contains("ghp_016C"), "edit secret redacted: {snip}");
2669 assert!(snip.contains("[redacted:github]"), "{snip}");
2670 let title: String = c
2672 .query_row("SELECT title FROM files WHERE session_id='sx'", [], |r| {
2673 r.get(0)
2674 })
2675 .unwrap();
2676 assert!(
2677 !title.contains("AKIAIOSFODNN7EXAMPLE"),
2678 "title secret redacted: {title}"
2679 );
2680 set_summary(
2682 &c,
2683 "sx",
2684 "we set sk-abcdef012345678901234567890123 as the key",
2685 )
2686 .unwrap();
2687 let sum: String = c
2688 .query_row(
2689 "SELECT summary FROM summaries WHERE session_id='sx'",
2690 [],
2691 |r| r.get(0),
2692 )
2693 .unwrap();
2694 assert!(!sum.contains("sk-abcdef"), "summary secret redacted: {sum}");
2695 }
2696
2697 #[test]
2698 fn evidence_for_assembles_sessions_with_their_edits_newest_first() {
2699 use crate::model::{EditEvent, EditKind, Session};
2700 let mut c = Connection::open_in_memory().unwrap();
2701 create_cache_schema(&c).unwrap();
2702
2703 for (sid, store, started, kind, snip) in [
2704 (
2705 "old",
2706 "/store/old.jsonl",
2707 "2026-06-01T00:00:00Z",
2708 EditKind::Edit,
2709 "v1",
2710 ),
2711 (
2712 "new",
2713 "/store/new.jsonl",
2714 "2026-06-09T00:00:00Z",
2715 EditKind::Write,
2716 "v2",
2717 ),
2718 ] {
2719 let started = chrono::DateTime::parse_from_rfc3339(started)
2720 .unwrap()
2721 .with_timezone(&chrono::Utc);
2722 let session = Session {
2723 id: sid.into(),
2724 tool: "claude-code",
2725 path: store.into(),
2726 project: "/proj".into(),
2727 started: Some(started),
2728 ended: None,
2729 title: format!("{sid} title"),
2730 subagent: false,
2731 messages: vec![],
2732 touched: vec!["/proj/src/a.rs".into()],
2733 edits: vec![EditEvent {
2734 path: "/proj/src/a.rs".into(),
2735 kind,
2736 snippet: snip.into(),
2737 ts: None,
2738 }],
2739 };
2740 let tx = c.transaction().unwrap();
2741 index_one(&tx, &session, store, 0, 0).unwrap();
2742 tx.commit().unwrap();
2743 }
2744
2745 let hist = evidence_for(&c, "src/a.rs", 50).unwrap();
2746 assert_eq!(hist.path, "src/a.rs");
2747 assert_eq!(hist.sessions.len(), 2, "both sessions that edited the file");
2748 assert_eq!(
2749 hist.sessions[0].session.session_id, "new",
2750 "newest session first"
2751 );
2752 assert_eq!(hist.sessions[0].edits.len(), 1);
2753 assert_eq!(hist.sessions[0].edits[0].snippet, "v2");
2754 assert_eq!(hist.sessions[1].session.session_id, "old");
2755 }
2756}
2757
2758#[cfg(test)]
2759mod moved_file_tests {
2760 use super::*;
2761
2762 #[test]
2771 fn a_renamed_folder_still_traces_by_file_name() {
2772 assert_eq!(
2773 basename_fallback("/Users/b/Project/slack/diag.py").as_deref(),
2774 Some("diag.py"),
2775 "a full path falls back to its file name"
2776 );
2777 assert_eq!(basename_fallback("diag.py"), None);
2780 assert_eq!(basename_fallback(""), None);
2781 assert_eq!(basename_fallback("/Users/b/Project/slack/"), None);
2783 }
2784}
2785
2786#[cfg(test)]
2787mod legacy_migration_tests {
2788 use super::*;
2789
2790 #[test]
2798 fn a_legacy_index_is_only_looked_for_beside_the_new_one() {
2799 let under_home = std::path::Path::new("/home/someone/.local/share/sessionwiki");
2800 let got = legacy_candidates(under_home);
2801 assert_eq!(
2802 got,
2803 vec![
2804 std::path::PathBuf::from("/home/someone/.local/share/sessiondex"),
2805 std::path::PathBuf::from("/home/someone/.local/share/session-atlas"),
2806 ],
2807 "the normal case must keep working"
2808 );
2809
2810 let redirected = std::path::Path::new("/tmp/sessionwiki-test-xyz");
2811 for c in legacy_candidates(redirected) {
2812 assert!(
2813 c.starts_with("/tmp"),
2814 "a redirected run reached outside its own tree: {}",
2815 c.display()
2816 );
2817 }
2818 }
2819
2820 #[test]
2821 fn a_destination_with_no_parent_offers_nothing_to_migrate() {
2822 assert!(legacy_candidates(std::path::Path::new("/")).is_empty());
2823 }
2824}
2825
2826#[cfg(test)]
2827mod embedder_hook_tests {
2828 use super::*;
2829 use crate::adapters::{Adapter, Discovered, Store};
2830 use crate::model::{Message, Role, Session};
2831 use std::path::Path;
2832
2833 struct FakeStore {
2836 keys: Vec<(String, i64)>,
2837 scope: Option<String>,
2838 }
2839
2840 impl Adapter for FakeStore {
2841 fn name(&self) -> &'static str {
2842 "mjolnir"
2843 }
2844 fn root(&self) -> Option<PathBuf> {
2845 Some(std::env::current_dir().unwrap())
2848 }
2849 fn discover(&self) -> Discovered {
2850 Discovered {
2851 files: Vec::new(),
2852 had_error: false,
2853 }
2854 }
2855 fn parse(&self, _path: &Path) -> Result<Session> {
2856 anyhow::bail!("shared store")
2857 }
2858 fn store(&self) -> Option<Store> {
2859 Some(Store {
2860 keys: self.keys.clone(),
2861 files: Vec::new(),
2862 had_error: false,
2863 })
2864 }
2865 fn parse_key(&self, key: &str) -> Result<Session> {
2866 Ok(Session {
2867 id: key.rsplit('/').next().unwrap().to_string(),
2868 tool: "mjolnir",
2869 path: PathBuf::from(key),
2870 project: "/proj".into(),
2871 started: None,
2872 ended: None,
2873 title: "a restored session".into(),
2874 subagent: false,
2875 messages: vec![Message {
2876 role: Role::User,
2877 text: "make the tests green".into(),
2878 ts: None,
2879 }],
2880 touched: vec![],
2881 edits: vec![],
2882 })
2883 }
2884 fn reconcile_scope(&self) -> Option<String> {
2885 self.scope.clone()
2886 }
2887 }
2888
2889 fn insert_live_row(c: &Connection, path: &str, sid: &str) {
2890 c.execute(
2891 "INSERT INTO files(path, session_id, tool, mtime, size, project, title, msg_count, kind)
2892 VALUES(?1, ?2, 'mjolnir', 0, 0, '/proj', 't', 1, 'session')",
2893 params![path, sid],
2894 )
2895 .unwrap();
2896 c.execute(
2897 "INSERT INTO messages(session_id, role, text) VALUES(?1,'user','hello')",
2898 params![sid],
2899 )
2900 .unwrap();
2901 }
2902
2903 fn archived_at(c: &Connection, path: &str) -> Option<String> {
2904 c.query_row(
2905 "SELECT archived_at FROM files WHERE path = ?1",
2906 params![path],
2907 |r| r.get(0),
2908 )
2909 .unwrap()
2910 }
2911
2912 #[test]
2916 fn reconcile_scope_limits_archiving_to_the_adapters_own_keys() {
2917 let mut c = Connection::open_in_memory().unwrap();
2918 create_cache_schema(&c).unwrap();
2919 insert_live_row(&c, "/data/one/sess-a", "sa");
2920 insert_live_row(&c, "/data/two/sess-b", "sb");
2921
2922 let adapters: Vec<Box<dyn Adapter>> = vec![Box::new(FakeStore {
2923 keys: Vec::new(),
2924 scope: Some("/data/one/".to_string()),
2925 })];
2926 sync_with(&mut c, &adapters, None).unwrap();
2927
2928 assert!(
2929 archived_at(&c, "/data/one/sess-a").is_some(),
2930 "the in-scope row the adapter no longer lists must be archived"
2931 );
2932 assert!(
2933 archived_at(&c, "/data/two/sess-b").is_none(),
2934 "the other installation's row must be left live"
2935 );
2936 }
2937
2938 #[test]
2940 fn an_unscoped_adapter_still_archives_every_row_of_its_tool() {
2941 let mut c = Connection::open_in_memory().unwrap();
2942 create_cache_schema(&c).unwrap();
2943 insert_live_row(&c, "/data/one/sess-a", "sa");
2944 insert_live_row(&c, "/data/two/sess-b", "sb");
2945
2946 let adapters: Vec<Box<dyn Adapter>> = vec![Box::new(FakeStore {
2947 keys: Vec::new(),
2948 scope: None,
2949 })];
2950 sync_with(&mut c, &adapters, None).unwrap();
2951
2952 assert!(archived_at(&c, "/data/one/sess-a").is_some());
2953 assert!(archived_at(&c, "/data/two/sess-b").is_some());
2954 }
2955
2956 #[test]
2959 fn sync_with_indexes_a_session_from_a_supplied_adapter() {
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_eq!(rows.len(), 1, "the supplied adapter's session was indexed");
2971 assert_eq!(rows[0].session_id, "sess-a");
2972 assert_eq!(rows[0].title, "a restored session");
2973 assert_eq!(rows[0].msg_count, 1);
2974 assert!(!rows[0].archived, "a listed session stays live");
2975 }
2976
2977 #[test]
2980 fn a_session_keeps_its_own_tool_name_when_no_adapter_is_registered() {
2981 let mut c = Connection::open_in_memory().unwrap();
2982 create_cache_schema(&c).unwrap();
2983
2984 let adapters: Vec<Box<dyn Adapter>> = vec![Box::new(FakeStore {
2985 keys: vec![("/data/one/sess-a".to_string(), 42)],
2986 scope: Some("/data/one/".to_string()),
2987 })];
2988 sync_with(&mut c, &adapters, None).unwrap();
2989
2990 let rows = recent(&c, 10, Some("mjolnir"), None, None, false).unwrap();
2991 assert!(
2992 crate::adapters::by_name("mjolnir").is_none(),
2993 "the built-in registry must not know this tool, or the test proves nothing"
2994 );
2995 let session = session_from_index(&c, &rows[0]).unwrap();
2996 assert_eq!(session.tool, "mjolnir");
2997 }
2998
2999 #[test]
3002 fn interning_a_tool_name_twice_yields_the_same_string() {
3003 let first = interned_tool("a-tool-no-adapter-knows");
3004 let second = interned_tool(&String::from("a-tool-no-adapter-knows"));
3005 assert_eq!(first, "a-tool-no-adapter-knows");
3006 assert!(std::ptr::eq(first, second));
3007 }
3008}