diff --git a/Cargo.lock b/Cargo.lock index 07a86ba..98c8ac3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -491,6 +491,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.3.0" @@ -524,6 +536,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -661,7 +679,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -669,6 +687,18 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hashlink" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" +dependencies = [ + "hashbrown 0.16.1", +] [[package]] name = "heck" @@ -1036,6 +1066,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1247,6 +1288,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + [[package]] name = "potential_utf" version = "0.1.4" @@ -1538,6 +1585,31 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rsqlite-vfs" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.18", +] + +[[package]] +name = "rusqlite" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3" +dependencies = [ + "bitflags 2.11.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + [[package]] name = "rustc-hash" version = "2.1.1" @@ -1848,6 +1920,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4206ed3a67690b9c29b77d728f6acc3ce78f16bf846d83c94f76400320181b" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -2027,6 +2111,25 @@ dependencies = [ "toolbox-core", ] +[[package]] +name = "tb-session" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "chrono", + "clap", + "colored", + "dirs", + "predicates", + "rusqlite", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "toml", + "toolbox-core", +] + [[package]] name = "tempfile" version = "3.26.0" @@ -2448,6 +2551,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "vcr-cassette" version = "2.0.1" diff --git a/Cargo.toml b/Cargo.toml index 96673fd..fd5d2b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,7 @@ indicatif = "0.18" inquire = "0.7" # Database -rusqlite = { version = "0.38", features = ["bundled"] } +rusqlite = { version = "0.38", features = ["bundled", "modern_sqlite"] } # Error handling thiserror = "2.0" @@ -45,6 +45,7 @@ tb-sem = { path = "crates/tb-sem" } tb-prod = { path = "crates/tb-prod" } tb-bug = { path = "crates/tb-bug" } tb-devctl = { path = "crates/tb-devctl" } +tb-session = { path = "crates/tb-session" } # Dev/test (also in workspace.dependencies so crates can inherit them) assert_cmd = "2" diff --git a/crates/tb-session/Cargo.toml b/crates/tb-session/Cargo.toml new file mode 100644 index 0000000..6f3bb47 --- /dev/null +++ b/crates/tb-session/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "tb-session" +version = "0.1.0" +edition = "2024" +description = "Claude Code session search CLI" +authors.workspace = true +license.workspace = true +repository.workspace = true + +[[bin]] +name = "tb-session" +path = "src/main.rs" + +[lib] +doctest = false + +[dependencies] +toolbox-core = { workspace = true, features = ["version-check"] } +clap.workspace = true +serde.workspace = true +serde_json.workspace = true +chrono.workspace = true +thiserror.workspace = true +colored.workspace = true +rusqlite.workspace = true +toml.workspace = true +dirs = "6" + +[dev-dependencies] +assert_cmd.workspace = true +predicates.workspace = true +tempfile.workspace = true diff --git a/crates/tb-session/SKILL.md b/crates/tb-session/SKILL.md new file mode 100644 index 0000000..8241e6a --- /dev/null +++ b/crates/tb-session/SKILL.md @@ -0,0 +1,61 @@ +--- +name: tb-session +description: Search and manage Claude Code sessions. Use when the user references past sessions, wants to find prior work, or needs to resume a specific conversation. +--- + +# tb-session + +Claude Code session search CLI. Full-text search across session history with metadata filtering. Built for AI agent consumption but works for humans too. + +## Capabilities + +- **Search** — full-text search across messages with BM25 ranking, `--pr` filter, metadata filters +- **List** — browse sessions by metadata (branch, date, project), worktree-aware +- **Show** — session detail with conversation preview +- **Resume** — resume a past session in a **new terminal tab** (accepts UUIDs, prefixes, or name search) + +## When to use + +- User references prior work: "remember when we...", "that session where..." +- User asks about a specific PR: use `--pr` to find sessions mentioning it +- User asks to find or resume a past session +- Before starting work: check if a prior session already started the same task +- Use `--json` for programmatic access when processing results + +## Quick reference + +```bash +# Search by content +tb-session search "authentication middleware" +tb-session search "budget calculation" --all-projects + +# Search by PR (number or URL) +tb-session search --pr 557 +tb-session search --pr https://github.com/org/repo/pull/123 + +# List recent sessions +tb-session list +tb-session list --all-projects --limit 20 + +# Show session details (prefix match works) +tb-session show bcb7ff + +# Resume by UUID prefix or name +tb-session resume bcb7ffed +tb-session resume "auth refactor" +``` + +## Important + +- **Resume opens a new terminal tab** — Claude sessions can't nest inside each other. When called from within Claude Code (non-TTY), `resume` opens a new iTerm/Terminal.app tab, cd's into the original project, and runs `claude --resume`. This is expected — not an error. +- **Worktree-aware by default** — `list` and `search` include sessions from all git worktrees of the same repo. Use `--all-projects` for everything. +- **Resume accepts names** — `resume "auth refactor"` searches summary/first prompt and resumes the most recent match. UUID prefixes of any length also work. +- **URLs work as search queries** — special characters are sanitized automatically. + +## Getting started + +Run `tb-session prime` for available commands and index status. + +## Live context + +!`tb-session prime` diff --git a/crates/tb-session/src/commands/cache_clear.rs b/crates/tb-session/src/commands/cache_clear.rs new file mode 100644 index 0000000..cfc799f --- /dev/null +++ b/crates/tb-session/src/commands/cache_clear.rs @@ -0,0 +1,27 @@ +use crate::config::Config; +use crate::error::Result; + +pub fn run() -> Result<()> { + let config = Config::load()?; + let db_path = config.db_path()?; + + if db_path.exists() { + std::fs::remove_file(&db_path)?; + + // Also remove SQLite WAL and shared-memory sidecar files if present. + let wal = db_path.with_extension("db-wal"); + if wal.exists() { + std::fs::remove_file(&wal)?; + } + let shm = db_path.with_extension("db-shm"); + if shm.exists() { + std::fs::remove_file(&shm)?; + } + + println!("Index cleared: {}", db_path.display()); + } else { + println!("No index to clear."); + } + + Ok(()) +} diff --git a/crates/tb-session/src/commands/config_cmd.rs b/crates/tb-session/src/commands/config_cmd.rs new file mode 100644 index 0000000..93232f0 --- /dev/null +++ b/crates/tb-session/src/commands/config_cmd.rs @@ -0,0 +1,34 @@ +use crate::config::Config; +use crate::error::Result; + +/// Write default config to disk and print its path. +pub fn init() -> Result<()> { + let config = Config::default(); + config.save()?; + let path = Config::config_path()?; + println!("Config initialized at: {}", path.display()); + Ok(()) +} + +/// Load and print all config values and resolved paths. +pub fn show() -> Result<()> { + let config = Config::load()?; + let path = Config::config_path()?; + + println!("Config file: {}", path.display()); + println!("claude_home: {}", config.claude_home); + println!( + "claude_home resolved: {}", + config.claude_home_path().display() + ); + println!("projects_dir: {}", config.projects_dir().display()); + match config.db_path() { + Ok(p) => println!("db_path: {}", p.display()), + Err(e) => println!("db_path: (error: {})", e), + } + println!("ttl_minutes: {}", config.ttl_minutes); + println!("ttl: {}s", config.ttl().as_secs()); + println!("default_limit: {}", config.default_limit); + + Ok(()) +} diff --git a/crates/tb-session/src/commands/doctor.rs b/crates/tb-session/src/commands/doctor.rs new file mode 100644 index 0000000..0335856 --- /dev/null +++ b/crates/tb-session/src/commands/doctor.rs @@ -0,0 +1,147 @@ +use colored::Colorize; + +use crate::config::Config; +use crate::error::Result; + +fn humanize_bytes(bytes: u64) -> String { + if bytes < 1024 { + format!("{} B", bytes) + } else if bytes < 1024 * 1024 { + format!("{:.1} KB", bytes as f64 / 1024.0) + } else { + format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0)) + } +} + +fn check(ok: bool, label: &str) -> bool { + if ok { + println!(" {} {}", "✓".green().bold(), label); + } else { + println!(" {} {}", "✗".red().bold(), label); + } + ok +} + +fn warn(label: &str) { + println!(" {} {}", "⚠".yellow().bold(), label); +} + +pub fn run() -> Result<()> { + let mut all_ok = true; + + println!("{}", "tb-session doctor".bold()); + println!(); + + let config = Config::load()?; + + // 1. Claude home directory + let claude_home = config.claude_home_path(); + let claude_home_exists = claude_home.exists(); + let label = format!("Claude home exists ({})", claude_home.display()); + if !check(claude_home_exists, &label) { + all_ok = false; + } + + // 2. Projects directory + let projects_dir = config.projects_dir(); + let subdir_count = if projects_dir.exists() { + std::fs::read_dir(&projects_dir) + .map(|entries| { + entries + .filter_map(|e| e.ok()) + .filter(|e| e.path().is_dir()) + .count() + }) + .unwrap_or(0) + } else { + 0 + }; + let projects_exists = projects_dir.exists(); + let label = format!( + "Projects directory exists ({} — {} subdirs)", + projects_dir.display(), + subdir_count + ); + if !check(projects_exists, &label) { + all_ok = false; + } + + // 3. Database + FTS5 test + let db_path = config.db_path()?; + let db_exists = db_path.exists(); + if db_exists { + let db_size = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0); + let label = format!( + "Database exists ({} — {})", + db_path.display(), + humanize_bytes(db_size) + ); + check(true, &label); + + // Test FTS5 availability + let fts5_ok = test_fts5(&db_path); + if !check(fts5_ok, "SQLite FTS5 extension available") { + all_ok = false; + } + } else { + warn(&format!("Database not yet created ({})", db_path.display())); + } + + // 4. Config file + let config_path = Config::config_path()?; + if config_path.exists() { + let label = format!("Config file exists ({})", config_path.display()); + check(true, &label); + } else { + warn(&format!( + "Config file not found ({} — using defaults)", + config_path.display() + )); + } + + // 5. claude binary in PATH + let claude_found = which_claude(); + let label = if let Some(ref path) = claude_found { + format!("claude binary in PATH ({})", path) + } else { + "claude binary in PATH".to_string() + }; + if !check(claude_found.is_some(), &label) { + all_ok = false; + } + + println!(); + if all_ok { + println!("{}", "All checks passed.".green().bold()); + } else { + println!("{}", "Some checks failed.".red().bold()); + } + + Ok(()) +} + +fn test_fts5(db_path: &std::path::Path) -> bool { + let conn = match rusqlite::Connection::open(db_path) { + Ok(c) => c, + Err(_) => return false, + }; + let create = conn.execute_batch( + "CREATE VIRTUAL TABLE IF NOT EXISTS _doctor_fts5_test \ + USING fts5(content); \ + DROP TABLE IF EXISTS _doctor_fts5_test;", + ); + create.is_ok() +} + +fn which_claude() -> Option { + let output = std::process::Command::new("which") + .arg("claude") + .output() + .ok()?; + if output.status.success() { + let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if path.is_empty() { None } else { Some(path) } + } else { + None + } +} diff --git a/crates/tb-session/src/commands/index_cmd.rs b/crates/tb-session/src/commands/index_cmd.rs new file mode 100644 index 0000000..89c8a1d --- /dev/null +++ b/crates/tb-session/src/commands/index_cmd.rs @@ -0,0 +1,34 @@ +use crate::config::Config; +use crate::error::Result; + +fn humanize_bytes(bytes: u64) -> String { + if bytes < 1024 { + format!("{} B", bytes) + } else if bytes < 1024 * 1024 { + format!("{:.1} KB", bytes as f64 / 1024.0) + } else { + format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0)) + } +} + +pub fn run(all_projects: bool) -> Result<()> { + let config = Config::load()?; + let conn = crate::index::open_db(true)?; + + let projects_dir = config.projects_dir(); + let cwd = std::env::current_dir().ok(); + let scope: Option<&std::path::Path> = if all_projects { None } else { cwd.as_deref() }; + + println!("Indexing sessions..."); + crate::index::ensure_fresh(&conn, &projects_dir, scope)?; + + let stats = crate::index::get_stats(&conn)?; + println!("Indexed {} sessions", stats.session_count); + println!( + "Total: {} sessions across {} projects", + stats.session_count, stats.project_count + ); + println!("Database: {}", humanize_bytes(stats.db_size_bytes)); + + Ok(()) +} diff --git a/crates/tb-session/src/commands/list.rs b/crates/tb-session/src/commands/list.rs new file mode 100644 index 0000000..b9d374d --- /dev/null +++ b/crates/tb-session/src/commands/list.rs @@ -0,0 +1,140 @@ +use rusqlite::Connection; + +use crate::error::Result; +use crate::models::{SessionList, SessionSummary}; + +#[allow(clippy::too_many_arguments)] +pub fn run( + conn: &Connection, + branch: Option<&str>, + from: Option<&str>, + to: Option<&str>, + repo_paths: &[String], + limit: usize, + page: usize, + json: bool, +) -> Result<()> { + // Build WHERE clause + let mut where_parts = vec!["is_sidechain = 0".to_string()]; + let mut params: Vec> = Vec::new(); + + if !repo_paths.is_empty() { + let placeholders: Vec = (0..repo_paths.len()) + .map(|i| format!("?{}", i + 1)) + .collect(); + where_parts.push(format!("project_path IN ({})", placeholders.join(", "))); + for path in repo_paths { + params.push(Box::new(path.clone())); + } + } + + if let Some(b) = branch { + let idx = params.len() + 1; + where_parts.push(format!("git_branch = ?{idx}")); + params.push(Box::new(b.to_string())); + } + + if let Some(f) = from { + let idx = params.len() + 1; + where_parts.push(format!("modified_at >= ?{idx}")); + params.push(Box::new(f.to_string())); + } + + if let Some(t) = to { + let idx = params.len() + 1; + where_parts.push(format!("modified_at <= ?{idx}")); + params.push(Box::new(t.to_string())); + } + + let where_clause = where_parts.join(" AND "); + + // COUNT query for pagination + let count_sql = format!("SELECT COUNT(*) FROM sessions WHERE {where_clause}"); + + let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + + let total: usize = conn + .query_row( + &count_sql, + rusqlite::params_from_iter(param_refs.iter().copied()), + |row| row.get::<_, i64>(0), + ) + .map(|n| n as usize)?; + + // Paginated data query + let offset = (page.saturating_sub(1)) * limit; + let data_sql = format!( + "SELECT session_id, summary, git_branch, project_path, message_count, \ + created_at, modified_at FROM sessions WHERE {where_clause} \ + ORDER BY modified_at DESC LIMIT {} OFFSET {}", + limit, offset + ); + + let mut stmt = conn.prepare(&data_sql)?; + let results: Vec = stmt + .query_map( + rusqlite::params_from_iter(param_refs.iter().copied()), + |row| { + Ok(SessionSummary { + session_id: row.get(0)?, + summary: row.get(1)?, + git_branch: row.get(2)?, + project_path: row.get(3)?, + message_count: row.get(4)?, + created_at: row.get(5)?, + modified_at: row.get(6)?, + }) + }, + )? + .collect::>()?; + + let list = SessionList { + total_results: total, + page: Some(page), + results, + }; + + if json { + println!("{}", toolbox_core::output::render_json(&list)); + return Ok(()); + } + + if list.results.is_empty() { + println!( + "{}", + toolbox_core::output::empty_hint("sessions", "Try --all-projects or wider date range.") + ); + return Ok(()); + } + + // Human-readable table + println!( + "{:<36} {:<40} {:<20} {:<6} {:<12}", + "SESSION ID", "SUMMARY", "BRANCH", "MSGS", "MODIFIED" + ); + for s in &list.results { + let summary = s.summary.as_deref().unwrap_or("(no summary)"); + let branch = s.git_branch.as_deref().unwrap_or("-"); + let modified = s + .modified_at + .as_deref() + .map(toolbox_core::output::relative_time) + .unwrap_or_else(|| "-".to_string()); + println!( + "{:<36} {:<40} {:<20} {:<6} {:<12}", + toolbox_core::output::truncate(&s.session_id, 36), + toolbox_core::output::truncate(summary, 38), + toolbox_core::output::truncate(branch, 18), + s.message_count, + modified, + ); + } + + if let Some(hint) = + toolbox_core::output::pagination_hint(page as u32, limit as u32, total as u32) + { + eprintln!("{}", hint); + } + + Ok(()) +} diff --git a/crates/tb-session/src/commands/mod.rs b/crates/tb-session/src/commands/mod.rs new file mode 100644 index 0000000..a66ae6e --- /dev/null +++ b/crates/tb-session/src/commands/mod.rs @@ -0,0 +1,9 @@ +pub mod cache_clear; +pub mod config_cmd; +pub mod doctor; +pub mod index_cmd; +pub mod list; +pub mod prime; +pub mod resume; +pub mod search; +pub mod show; diff --git a/crates/tb-session/src/commands/prime.rs b/crates/tb-session/src/commands/prime.rs new file mode 100644 index 0000000..c39a90b --- /dev/null +++ b/crates/tb-session/src/commands/prime.rs @@ -0,0 +1,96 @@ +use chrono::TimeZone; + +use crate::config::Config; +use crate::error::Result; +use crate::index; + +pub fn run() -> Result<()> { + let config = Config::load()?; + + // Render the commands/options section with the configured default_limit + let default_limit = config.default_limit; + + print!( + r#"# tb-session — Claude Code session search + +## Commands + +- `tb-session search ` — full-text search across sessions + - `--branch ` — filter by git branch + - `--after ` — created after (YYYY-MM-DD or relative: 7d, 2w, today) + - `--before ` — created before + - `--project ` — filter by project path (substring match) + - `--all-projects` — search across all projects (default: current dir) + - `--limit ` — max results (default: {default_limit}) + - `--json` — structured output + - `--no-cache` — force index rebuild first +- `tb-session list` — browse sessions by metadata (no full-text) + - Same filters as search + `--page ` +- `tb-session show ` — session detail and conversation preview +- `tb-session resume ` — resume session (execs claude --resume) +- `tb-session index [--all-projects]` — rebuild search index +- `tb-session doctor` — verify setup health +- `tb-session cache-clear` — delete index for clean rebuild + +## Index Status + +"# + ); + + // Try to open the DB and get stats; if absent, show a friendly message + match index::open_db(false) { + Ok(conn) => match index::get_stats(&conn) { + Ok(stats) => { + println!( + "- {} sessions | {} projects", + stats.session_count, stats.project_count + ); + + // Last updated: check mtime of the DB file + let last_updated = config + .db_path() + .ok() + .and_then(|p| std::fs::metadata(&p).ok()) + .and_then(|m| m.modified().ok()) + .map(|t| { + // Convert SystemTime to ISO 8601 string for relative_time + let duration = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); + let secs = duration.as_secs() as i64; + // Build an ISO 8601 timestamp + chrono::Utc + .timestamp_opt(secs, 0) + .single() + .map(|dt| dt.to_rfc3339()) + .unwrap_or_else(|| "unknown".to_string()) + }) + .unwrap_or_else(|| "unknown".to_string()); + + let relative = if last_updated == "unknown" { + "unknown".to_string() + } else { + toolbox_core::output::relative_time(&last_updated) + }; + + println!("- Last updated: {relative}"); + } + Err(_) => { + println!("- Not yet built — run `tb-session index` to build"); + } + }, + Err(_) => { + println!("- Not yet built — run `tb-session index` to build"); + } + } + + // Current project + let cwd = std::env::current_dir() + .map(|p| p.display().to_string()) + .unwrap_or_else(|_| "(unknown)".to_string()); + + println!(); + println!("## Current Project"); + println!(); + println!("- {cwd}"); + + Ok(()) +} diff --git a/crates/tb-session/src/commands/resume.rs b/crates/tb-session/src/commands/resume.rs new file mode 100644 index 0000000..d7fb8c2 --- /dev/null +++ b/crates/tb-session/src/commands/resume.rs @@ -0,0 +1,296 @@ +use std::io::IsTerminal; + +use rusqlite::Connection; + +use crate::error::{Error, Result}; + +/// Returns true if the input looks like a UUID or UUID prefix (8+ hex chars with optional dashes). +fn looks_like_uuid(s: &str) -> bool { + let s = s.trim(); + s.len() >= 8 && s.chars().all(|c| c.is_ascii_hexdigit() || c == '-') +} + +/// Search sessions by summary or first_prompt, returning the most recently modified match. +fn resolve_by_name(conn: &Connection, query: &str) -> Option<(String, Option)> { + let pattern = format!("%{}%", query); + conn.query_row( + "SELECT session_id, project_path FROM sessions \ + WHERE (summary LIKE ?1 OR first_prompt LIKE ?1) \ + AND is_sidechain = 0 \ + ORDER BY modified_at DESC \ + LIMIT 1", + rusqlite::params![pattern], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .ok() +} + +pub fn run(conn: &Connection, session_id: &str) -> Result<()> { + let claude_path = which_claude()?; + + // Resolve full session ID and project_path from the index. + // Try UUID prefix match first, then fall back to name/summary search. + let prefix_pattern = format!("{}%", session_id); + let resolved: Option<(String, Option)> = conn + .query_row( + "SELECT session_id, project_path FROM sessions WHERE session_id = ?1 OR session_id LIKE ?2 ORDER BY modified_at DESC LIMIT 1", + rusqlite::params![session_id, prefix_pattern], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .ok() + .or_else(|| resolve_by_name(conn, session_id)); + + if resolved.is_none() && !looks_like_uuid(session_id) { + return Err(Error::Other(format!( + "No session found matching '{}'. Try: tb-session search \"{}\" --all-projects", + session_id, session_id + ))); + } + + let full_session_id = resolved + .as_ref() + .map(|(id, _)| id.as_str()) + .unwrap_or(session_id); + + // Resolve the project directory from the session metadata. + let project_dir = resolved.as_ref().and_then(|(_, path)| { + let path = path.as_deref()?; + let target = std::path::Path::new(path); + if target.is_dir() { + Some(path) + } else { + eprintln!( + "Warning: original project directory no longer exists: {}", + path + ); + None + } + }); + + // If stdin is not a TTY, we're likely running inside Claude Code or a script. + // Spawn a new terminal window instead of exec'ing (which would kill the parent). + // Always pass project_dir — the new tab doesn't inherit our cwd. + if !std::io::stdin().is_terminal() { + return open_in_terminal(&claude_path, full_session_id, project_dir); + } + + // Interactive: cd into the original project and exec claude directly + // Only cd if we're not already there. + if let Some(path) = project_dir { + let cwd = std::env::current_dir().unwrap_or_default(); + if cwd != std::path::Path::new(path) { + eprintln!("Resuming in {}", path); + std::env::set_current_dir(path) + .map_err(|e| Error::Other(format!("Failed to cd into {}: {}", path, e)))?; + } + } + + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + let err = std::process::Command::new(&claude_path) + .arg("--resume") + .arg(full_session_id) + .exec(); + Err(Error::Other(format!("Failed to exec claude: {}", err))) + } + + #[cfg(not(unix))] + { + let status = std::process::Command::new(&claude_path) + .arg("--resume") + .arg(full_session_id) + .status() + .map_err(|e| Error::Other(format!("Failed to run claude: {}", e)))?; + + if !status.success() { + return Err(Error::Other(format!( + "claude exited with status: {}", + status + ))); + } + Ok(()) + } +} + +/// Open a new terminal tab and run `claude --resume` there. +fn open_in_terminal(claude_path: &str, session_id: &str, resume_dir: Option<&str>) -> Result<()> { + if !cfg!(target_os = "macos") { + return Err(Error::Other( + "resume in new terminal tab is only supported on macOS. \ + Run manually: claude --resume " + .into(), + )); + } + + let resume_cmd = format!( + "{} --resume {}", + shell_escape(claude_path), + shell_escape(session_id) + ); + let full_cmd = match resume_dir { + Some(dir) => format!("cd {} && {}", shell_escape(dir), resume_cmd), + None => resume_cmd, + }; + + // Pass command via TB_SESSION_CMD env var to avoid AppleScript injection. + // The osascript reads it with `do shell script "echo $TB_SESSION_CMD"` or + // writes it directly via `write text` using `(system attribute "TB_SESSION_CMD")`. + let terminal = std::env::var("TERM_PROGRAM").unwrap_or_default(); + let script = build_osascript(&terminal); + + let output = std::process::Command::new("osascript") + .arg("-e") + .arg(&script) + .env("TB_SESSION_CMD", &full_cmd) + .output() + .map_err(|e| Error::Other(format!("Failed to run osascript: {}", e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(Error::Other(format!( + "Failed to open terminal window: {}", + stderr.trim() + ))); + } + + if let Some(dir) = resume_dir { + eprintln!("Opened new terminal tab in {} to resume session", dir); + } else { + eprintln!("Opened new terminal tab to resume session"); + } + Ok(()) +} + +fn build_osascript(terminal: &str) -> String { + // Command is passed via TB_SESSION_CMD env var to avoid AppleScript string injection. + match terminal { + "iTerm.app" => r#"tell application "iTerm2" + tell current window + create tab with default profile + tell current session + write text (system attribute "TB_SESSION_CMD") + end tell + end tell +end tell"# + .to_string(), + // Terminal.app and anything else + _ => r#"tell application "Terminal" + activate + do script (system attribute "TB_SESSION_CMD") +end tell"# + .to_string(), + } +} + +fn shell_escape(s: &str) -> String { + if s.is_empty() { + return s.to_string(); + } + format!("'{}'", s.replace('\'', "'\\''")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_looks_like_uuid() { + assert!(looks_like_uuid("9a06add5-028b-484c-a0bf-f4fc08921042")); + assert!(looks_like_uuid("9a06add5")); + assert!(looks_like_uuid("abcdef12")); + assert!(!looks_like_uuid("auth-refactor")); + assert!(!looks_like_uuid("fix bug")); + assert!(!looks_like_uuid("PR #6")); + assert!(!looks_like_uuid("abc")); // too short + } + + #[test] + fn test_shell_escape() { + // Plain strings — always single-quoted + assert_eq!(shell_escape("claude"), "'claude'"); + assert_eq!(shell_escape("/usr/bin/claude"), "'/usr/bin/claude'"); + + // String with spaces + assert_eq!(shell_escape("/path/to my dir"), "'/path/to my dir'"); + + // String with single quote — escaped within single quotes + assert_eq!(shell_escape("it's"), "'it'\\''s'"); + + // String with double quote + assert_eq!(shell_escape("say \"hi\""), "'say \"hi\"'"); + + // String with backslash + assert_eq!(shell_escape("back\\slash"), "'back\\slash'"); + + // Shell metacharacters — safely quoted + assert_eq!(shell_escape("$(whoami)"), "'$(whoami)'"); + assert_eq!(shell_escape("foo;rm -rf /"), "'foo;rm -rf /'"); + assert_eq!(shell_escape("a|b"), "'a|b'"); + assert_eq!(shell_escape("a&b"), "'a&b'"); + assert_eq!(shell_escape("`cmd`"), "'`cmd`'"); + + // Empty string — returned as-is + assert_eq!(shell_escape(""), ""); + } + + #[test] + fn test_build_osascript_iterm() { + let script = build_osascript("iTerm.app"); + assert!(script.contains("iTerm2")); + assert!(script.contains("create tab with default profile")); + assert!(script.contains("system attribute \"TB_SESSION_CMD\"")); + } + + #[test] + fn test_build_osascript_terminal() { + let script = build_osascript("Apple_Terminal"); + assert!(script.contains("Terminal")); + assert!(script.contains("do script")); + assert!(script.contains("system attribute \"TB_SESSION_CMD\"")); + } + + #[test] + fn test_build_osascript_unknown_falls_to_terminal() { + let script = build_osascript(""); + assert!(script.contains("Terminal")); + assert!(script.contains("do script")); + } + + #[test] + fn test_looks_like_uuid_edge_cases() { + // Exactly 7 hex chars — too short + assert!(!looks_like_uuid("abcdef1")); + + // Exactly 8 hex chars — minimum valid + assert!(looks_like_uuid("abcdef12")); + + // All dashes — currently true (8+ chars, all are hex or dash) + // This documents current behavior: dashes-only passes the check + assert!(looks_like_uuid("--------")); + + // Whitespace-padded — trim should handle + assert!(looks_like_uuid(" 9a06add5 ")); + } +} + +fn which_claude() -> Result { + let output = std::process::Command::new("which") + .arg("claude") + .output() + .map_err(|e| Error::Other(format!("Failed to run 'which claude': {}", e)))?; + + if !output.status.success() { + return Err(Error::Other( + "claude CLI not found in PATH. Install it from https://docs.anthropic.com/s/claude-code" + .to_string(), + )); + } + + let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if path.is_empty() { + return Err(Error::Other("claude CLI not found in PATH".to_string())); + } + + Ok(path) +} diff --git a/crates/tb-session/src/commands/search.rs b/crates/tb-session/src/commands/search.rs new file mode 100644 index 0000000..ab8b1b7 --- /dev/null +++ b/crates/tb-session/src/commands/search.rs @@ -0,0 +1,345 @@ +use rusqlite::Connection; +use rusqlite::types::ToSql; + +use crate::error::Result; +use crate::models::{SearchFilters, SearchResult, SessionMatch}; + +#[allow(clippy::too_many_arguments)] +pub fn run( + conn: &Connection, + query: &str, + branch: Option<&str>, + from: Option<&str>, + to: Option<&str>, + project: Option<&str>, + repo_paths: &[String], + limit: usize, + json: bool, +) -> Result<()> { + // -- Build dynamic SQL ------------------------------------------------ + let mut where_clauses = vec![ + "messages_fts MATCH ?1".to_string(), + "s.is_sidechain = 0".to_string(), + ]; + let sanitized = sanitize_fts5_query(query); + if sanitized.is_empty() { + return Err(crate::error::Error::Other( + "search query cannot be empty".into(), + )); + } + let mut params: Vec> = vec![Box::new(sanitized)]; + let mut param_idx: usize = 2; + + // Project scope + if let Some(proj) = project { + // --project flag: LIKE filter (works with or without --all-projects) + let pattern = format!("%{proj}%"); + where_clauses.push(format!("s.project_path LIKE ?{param_idx}")); + params.push(Box::new(pattern)); + param_idx += 1; + } else if !repo_paths.is_empty() { + // Default: scope to current repo worktrees + let placeholders: Vec = (0..repo_paths.len()) + .map(|i| format!("?{}", param_idx + i)) + .collect(); + where_clauses.push(format!("s.project_path IN ({})", placeholders.join(", "))); + for path in repo_paths { + params.push(Box::new(path.clone())); + param_idx += 1; + } + } + // else: no project filter (all projects) + + // Branch filter + if let Some(br) = branch { + where_clauses.push(format!("s.git_branch = ?{param_idx}")); + params.push(Box::new(br.to_string())); + param_idx += 1; + } + + // Date filters + if let Some(after) = from { + where_clauses.push(format!("s.modified_at >= ?{param_idx}")); + params.push(Box::new(after.to_string())); + param_idx += 1; + } + if let Some(before) = to { + where_clauses.push(format!("s.modified_at <= ?{param_idx}")); + params.push(Box::new(before.to_string())); + param_idx += 1; + } + + let where_sql = where_clauses.join(" AND "); + + // Use a subquery to find best-matching sessions first, then fetch snippets. + // snippet() cannot be used with GROUP BY directly in FTS5. + let sql = format!( + "SELECT + s.session_id, + s.summary, + s.first_prompt, + s.git_branch, + s.project_path, + s.message_count, + s.created_at, + s.modified_at, + best.best_rank, + snippet(messages_fts, 2, '«', '»', '…', 20), + messages_fts.role + FROM ( + SELECT messages_fts.session_id, MIN(rank) AS best_rank + FROM messages_fts + JOIN sessions s ON s.session_id = messages_fts.session_id + WHERE {where_sql} + GROUP BY messages_fts.session_id + ORDER BY best_rank + LIMIT ?{param_idx} + ) best + JOIN sessions s ON s.session_id = best.session_id + JOIN messages_fts ON messages_fts.rowid = ( + SELECT rowid FROM messages_fts + WHERE messages_fts.session_id = best.session_id + AND messages_fts MATCH ?1 + LIMIT 1 + ) + ORDER BY best.best_rank" + ); + + params.push(Box::new(limit as i64)); + + // Convert to &dyn ToSql slice + let param_refs: Vec<&dyn ToSql> = params.iter().map(|p| p.as_ref()).collect(); + + // -- Execute ---------------------------------------------------------- + let mut stmt = conn.prepare(&sql)?; + + let raw_rows: Vec = stmt + .query_map(param_refs.as_slice(), |row| { + Ok(RawRow { + session_id: row.get(0)?, + summary: row.get(1)?, + first_prompt: row.get(2)?, + git_branch: row.get(3)?, + project_path: row.get(4)?, + message_count: row.get(5)?, + created_at: row.get(6)?, + modified_at: row.get(7)?, + rank: row.get(8)?, + snippet: row.get(9)?, + matched_role: row.get(10)?, + }) + })? + .collect::>>()?; + + // -- BM25 normalization: min-max scaling ------------------------------ + let results: Vec = if raw_rows.is_empty() { + Vec::new() + } else if raw_rows.len() == 1 { + vec![raw_rows[0].to_match(1.0)] + } else { + // rank is negative in FTS5 (more negative = better match) + // worst = least negative (highest value), best = most negative (lowest value) + let worst = raw_rows + .iter() + .map(|r| r.rank) + .fold(f64::NEG_INFINITY, f64::max); + let best = raw_rows + .iter() + .map(|r| r.rank) + .fold(f64::INFINITY, f64::min); + let range = worst - best; + + raw_rows + .iter() + .map(|r| { + let score = if range.abs() < f64::EPSILON { + 1.0 + } else { + ((worst - r.rank) / range).clamp(0.0, 1.0) + }; + r.to_match(score) + }) + .collect() + }; + + let total_results = results.len(); + + // -- Build active filters for output ---------------------------------- + let all_projects = repo_paths.is_empty() && project.is_none(); + let has_filters = + branch.is_some() || from.is_some() || to.is_some() || project.is_some() || all_projects; + + let filters = if has_filters { + Some(SearchFilters { + project: project.map(|s| s.to_string()), + branch: branch.map(|s| s.to_string()), + from: from.map(|s| s.to_string()), + to: to.map(|s| s.to_string()), + all_projects, + }) + } else { + None + }; + + // -- Output ----------------------------------------------------------- + if json { + let output = SearchResult { + query: query.to_string(), + filters, + total_results, + results, + }; + println!("{}", toolbox_core::output::render_json(&output)); + return Ok(()); + } + + // Human-readable output + if results.is_empty() { + println!( + "{}", + toolbox_core::output::empty_hint("sessions", "Try broader terms or --all-projects.") + ); + return Ok(()); + } + + use colored::Colorize; + use toolbox_core::output::{relative_time, truncate}; + + eprintln!( + "{} results for '{}'", + total_results.to_string().bold(), + query.bold() + ); + + // Table header + println!( + "\n{:<12} {:<40} {:<25} {:<6} {:<10} {:<5}", + "SESSION", "SUMMARY", "BRANCH", "MSGS", "MODIFIED", "SCORE" + ); + + for m in &results { + let session_short = truncate(&m.session_id, 10); + let summary = m.summary.as_deref().unwrap_or("-"); + let branch_str = m.git_branch.as_deref().unwrap_or("-"); + let modified = m + .modified_at + .as_deref() + .map(relative_time) + .unwrap_or_else(|| "-".to_string()); + let score_pct = format!("{:.0}%", m.relevance_score * 100.0); + + println!( + "{:<12} {:<40} {:<25} {:<6} {:<10} {:<5}", + session_short, + truncate(summary, 38), + truncate(branch_str, 23), + m.message_count, + modified, + score_pct, + ); + } + + // Matched snippets below the table + println!(); + for m in &results { + if let Some(ref snippet) = m.matched_snippet { + let role = m.matched_role.as_deref().unwrap_or("?"); + let session_short = truncate(&m.session_id, 10); + println!( + " {} [{}] {}", + session_short.dimmed(), + role.cyan(), + truncate(snippet, 120), + ); + } + } + + Ok(()) +} + +/// Intermediate row from the SQL query, before BM25 normalization. +struct RawRow { + session_id: String, + summary: Option, + first_prompt: Option, + git_branch: Option, + project_path: Option, + message_count: i64, + created_at: Option, + modified_at: Option, + rank: f64, + snippet: Option, + matched_role: Option, +} + +impl RawRow { + /// Convert to a `SessionMatch` with a pre-computed relevance score. + fn to_match(&self, score: f64) -> SessionMatch { + SessionMatch { + session_id: self.session_id.clone(), + summary: self.summary.clone(), + first_prompt: self.first_prompt.clone(), + git_branch: self.git_branch.clone(), + project_path: self.project_path.clone(), + message_count: self.message_count, + created_at: self.created_at.clone(), + modified_at: self.modified_at.clone(), + relevance_score: score, + matched_snippet: self.snippet.clone(), + matched_role: self.matched_role.clone(), + } + } +} + +/// Sanitize user input for FTS5 MATCH by quoting each whitespace-separated term. +/// +/// FTS5 has its own query language where `:`, `*`, `(`, `)`, `AND`, `OR`, `NOT`, etc. +/// are operators. Wrapping terms in double quotes forces literal matching. +/// Any embedded double quotes in the input are removed. +fn sanitize_fts5_query(query: &str) -> String { + query + .split_whitespace() + .map(|term| format!("\"{}\"", term.replace('"', ""))) + .collect::>() + .join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sanitize_fts5_query() { + assert_eq!( + sanitize_fts5_query("https://github.com/org/repo/pull/1"), + "\"https://github.com/org/repo/pull/1\"" + ); + assert_eq!(sanitize_fts5_query("fix bug"), "\"fix\" \"bug\""); + assert_eq!(sanitize_fts5_query("hello"), "\"hello\""); + assert_eq!( + sanitize_fts5_query("column:value OR other"), + "\"column:value\" \"OR\" \"other\"" + ); + } + + #[test] + fn test_sanitize_fts5_query_edge_cases() { + // Empty string → empty string (caller guards this) + assert_eq!(sanitize_fts5_query(""), ""); + + // Whitespace-only → empty string (split_whitespace yields nothing) + assert_eq!(sanitize_fts5_query(" "), ""); + + // Embedded double quotes are stripped + assert_eq!( + sanitize_fts5_query("say \"hi\" world"), + "\"say\" \"hi\" \"world\"" + ); + + // FTS5 operators get quoted as literal terms + assert_eq!(sanitize_fts5_query("AND OR NOT"), "\"AND\" \"OR\" \"NOT\""); + + // Single special character + assert_eq!(sanitize_fts5_query("*"), "\"*\""); + } +} diff --git a/crates/tb-session/src/commands/show.rs b/crates/tb-session/src/commands/show.rs new file mode 100644 index 0000000..07807bb --- /dev/null +++ b/crates/tb-session/src/commands/show.rs @@ -0,0 +1,194 @@ +use rusqlite::Connection; +use std::path::Path; + +use colored::Colorize; + +use crate::error::{Error, Result}; +use crate::index::parser; +use crate::models::{MessagePreview, SessionDetail}; + +pub fn run(conn: &Connection, session_id: &str, json: bool) -> Result<()> { + // Look up session by full ID or prefix match + let prefix_pattern = format!("{}%", session_id); + let mut stmt = conn.prepare( + "SELECT session_id, summary, first_prompt, git_branch, project_path, + message_count, created_at, modified_at, is_sidechain, file_path + FROM sessions + WHERE session_id = ?1 OR session_id LIKE ?2 + LIMIT 1", + )?; + + let row = stmt + .query_row(rusqlite::params![session_id, prefix_pattern], |row| { + Ok(RawSession { + session_id: row.get(0)?, + summary: row.get(1)?, + first_prompt: row.get(2)?, + git_branch: row.get(3)?, + project_path: row.get(4)?, + message_count: row.get(5)?, + created_at: row.get(6)?, + modified_at: row.get(7)?, + is_sidechain: row.get::<_, i64>(8)? != 0, + file_path: row.get(9)?, + }) + }) + .map_err(|e| match e { + rusqlite::Error::QueryReturnedNoRows => { + Error::Other(format!("Session '{}' not found", session_id)) + } + other => Error::Db(other), + })?; + + // Parse JSONL file for message preview if it exists + let messages = if Path::new(&row.file_path).exists() { + match parser::parse_session(Path::new(&row.file_path)) { + Ok(parsed) => Some(parsed.messages), + Err(_) => None, + } + } else { + None + }; + + if json { + output_json(&row, messages.as_deref()); + } else { + output_human(&row, messages.as_deref()); + } + + Ok(()) +} + +fn output_json(row: &RawSession, messages: Option<&[parser::ParsedMessage]>) { + let total_messages = messages.map(|m| m.len()); + + let previews = messages.map(|msgs| { + msgs.iter() + .take(20) + .map(|m| MessagePreview { + role: m.role.clone(), + content: truncate_string(&m.content, 2000), + timestamp: m.timestamp.clone(), + }) + .collect() + }); + + let detail = SessionDetail { + session_id: row.session_id.clone(), + summary: row.summary.clone(), + first_prompt: row.first_prompt.clone(), + git_branch: row.git_branch.clone(), + project_path: row.project_path.clone(), + message_count: row.message_count, + created_at: row.created_at.clone(), + modified_at: row.modified_at.clone(), + is_sidechain: row.is_sidechain, + messages: previews, + total_messages, + }; + + println!("{}", toolbox_core::output::render_json(&detail)); +} + +fn output_human(row: &RawSession, messages: Option<&[parser::ParsedMessage]>) { + // Metadata header + println!("{} {}", "Session:".bold(), row.session_id); + if let Some(ref summary) = row.summary { + println!("{} {}", "Summary:".bold(), summary); + } + if let Some(ref prompt) = row.first_prompt { + println!( + "{} {}", + "First prompt:".bold(), + toolbox_core::output::truncate(prompt, 120) + ); + } + if let Some(ref branch) = row.git_branch { + println!("{} {}", "Branch:".bold(), branch); + } + if let Some(ref path) = row.project_path { + println!("{} {}", "Project:".bold(), path); + } + println!("{} {}", "Messages:".bold(), row.message_count); + if let Some(ref created) = row.created_at { + println!( + "{} {} ({})", + "Created:".bold(), + created, + toolbox_core::output::relative_time(created) + ); + } + if let Some(ref modified) = row.modified_at { + println!( + "{} {} ({})", + "Modified:".bold(), + modified, + toolbox_core::output::relative_time(modified) + ); + } + if row.is_sidechain { + println!("{} yes", "Sidechain:".bold()); + } + + // Conversation preview + if let Some(msgs) = messages + && !msgs.is_empty() + { + println!("\n{}", "--- Conversation Preview ---".dimmed()); + + let total = msgs.len(); + if total <= 10 { + for m in msgs { + print_message(m); + } + } else { + // First 5 + for m in &msgs[..5] { + print_message(m); + } + println!(" {}", format!("... ({} omitted) ...", total - 10).dimmed()); + // Last 5 + for m in &msgs[total - 5..] { + print_message(m); + } + } + } + + println!( + "\n{} tb-session resume {}", + "Resume:".bold(), + row.session_id + ); +} + +fn print_message(m: &parser::ParsedMessage) { + let content = toolbox_core::output::truncate(&m.content, 500); + let role_label = match m.role.as_str() { + "user" => "user".blue().bold(), + "assistant" => "assistant".green().bold(), + other => other.normal(), + }; + println!("\n [{}] {}", role_label, content); +} + +/// Truncate a string to at most `max` characters, appending "..." if truncated. +fn truncate_string(s: &str, max: usize) -> String { + if s.len() <= max { + return s.to_string(); + } + let boundary = s.floor_char_boundary(max.saturating_sub(3)); + format!("{}...", &s[..boundary]) +} + +struct RawSession { + session_id: String, + summary: Option, + first_prompt: Option, + git_branch: Option, + project_path: Option, + message_count: i64, + created_at: Option, + modified_at: Option, + is_sidechain: bool, + file_path: String, +} diff --git a/crates/tb-session/src/config.rs b/crates/tb-session/src/config.rs new file mode 100644 index 0000000..95eb0a6 --- /dev/null +++ b/crates/tb-session/src/config.rs @@ -0,0 +1,93 @@ +use std::path::PathBuf; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +use crate::error::{Error, Result}; + +fn default_claude_home() -> String { + "~/.claude".to_string() +} + +fn default_ttl_minutes() -> u64 { + 60 +} + +fn default_limit() -> usize { + 10 +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct Config { + #[serde(default = "default_claude_home")] + pub claude_home: String, + + #[serde(default = "default_ttl_minutes")] + pub ttl_minutes: u64, + + #[serde(default = "default_limit")] + pub default_limit: usize, +} + +impl Default for Config { + fn default() -> Self { + Self { + claude_home: default_claude_home(), + ttl_minutes: default_ttl_minutes(), + default_limit: default_limit(), + } + } +} + +impl Config { + pub fn config_path() -> Result { + toolbox_core::config::config_path("tb-session").map_err(|e| Error::Config(e.to_string())) + } + + pub fn load() -> Result { + let path = Self::config_path()?; + let config = toolbox_core::config::load_standalone::(&path) + .map_err(|e| Error::Config(e.to_string()))? + .unwrap_or_default(); + Ok(config) + } + + pub fn save(&self) -> Result<()> { + let path = Self::config_path()?; + toolbox_core::config::save_config(&path, self).map_err(|e| Error::Config(e.to_string())) + } + + /// Resolves `~` in `claude_home` to an absolute PathBuf. + pub fn claude_home_path(&self) -> PathBuf { + if self.claude_home.starts_with('~') + && let Some(home) = dirs::home_dir() + { + let stripped = self.claude_home.trim_start_matches('~'); + let stripped = stripped.trim_start_matches('/'); + if stripped.is_empty() { + return home; + } + return home.join(stripped); + } + PathBuf::from(&self.claude_home) + } + + /// Returns `{claude_home}/projects/`. + pub fn projects_dir(&self) -> PathBuf { + self.claude_home_path().join("projects") + } + + /// Returns `~/.cache/tb-session/index.db`, creating the parent dir. + pub fn db_path(&self) -> Result { + let cache_dir = dirs::cache_dir() + .ok_or_else(|| Error::Config("cannot determine cache directory".to_string()))?; + let db_dir = cache_dir.join("tb-session"); + std::fs::create_dir_all(&db_dir).map_err(|e| Error::Config(e.to_string()))?; + Ok(db_dir.join("index.db")) + } + + /// Returns the TTL as a `Duration`. + pub fn ttl(&self) -> Duration { + Duration::from_secs(self.ttl_minutes * 60) + } +} diff --git a/crates/tb-session/src/error.rs b/crates/tb-session/src/error.rs new file mode 100644 index 0000000..69f4db3 --- /dev/null +++ b/crates/tb-session/src/error.rs @@ -0,0 +1,25 @@ +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("config error: {0}")] + Config(String), + + #[error("database error: {0}")] + Db(#[from] rusqlite::Error), + + #[error("io error: {0}")] + Io(#[from] std::io::Error), + + #[error("json error: {0}")] + Json(#[from] serde_json::Error), + + #[error("toml deserialize error: {0}")] + TomlDeserialize(#[from] toml::de::Error), + + #[error("toml serialize error: {0}")] + TomlSerialize(#[from] toml::ser::Error), + + #[error("{0}")] + Other(String), +} + +pub type Result = std::result::Result; diff --git a/crates/tb-session/src/git.rs b/crates/tb-session/src/git.rs new file mode 100644 index 0000000..2dce950 --- /dev/null +++ b/crates/tb-session/src/git.rs @@ -0,0 +1,77 @@ +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// Parse the porcelain output of `git worktree list --porcelain` into paths. +fn parse_worktree_output(stdout: &str) -> Vec { + stdout + .lines() + .filter_map(|line| line.strip_prefix("worktree ")) + .map(PathBuf::from) + .collect() +} + +/// Get all worktree paths for the git repo containing `cwd`. +/// Returns the main worktree + all linked worktrees. +/// Returns just `[cwd]` if not in a git repo or git is unavailable. +pub fn repo_paths(cwd: &Path) -> Vec { + let output = match Command::new("git") + .args(["worktree", "list", "--porcelain"]) + .current_dir(cwd) + .output() + { + Ok(o) if o.status.success() => o, + _ => return vec![cwd.to_path_buf()], + }; + + let stdout = String::from_utf8_lossy(&output.stdout); + let paths = parse_worktree_output(&stdout); + + if paths.is_empty() { + vec![cwd.to_path_buf()] + } else { + paths + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_repo_paths_returns_cwd_outside_git() { + let tmp = tempfile::tempdir().unwrap(); + let paths = repo_paths(tmp.path()); + assert_eq!(paths, vec![tmp.path().to_path_buf()]); + } + + #[test] + fn test_repo_paths_returns_at_least_one_in_git() { + let cwd = std::env::current_dir().unwrap(); + let paths = repo_paths(&cwd); + assert!(!paths.is_empty()); + } + + #[test] + fn test_parse_worktree_output_multiple() { + let output = "worktree /Users/test/repo\nHEAD abc123\nbranch refs/heads/main\n\nworktree /Users/test/worktrees/feature\nHEAD def456\nbranch refs/heads/feature\n\n"; + let paths = parse_worktree_output(output); + assert_eq!( + paths, + vec![ + PathBuf::from("/Users/test/repo"), + PathBuf::from("/Users/test/worktrees/feature"), + ] + ); + } + + #[test] + fn test_parse_worktree_output_empty() { + assert!(parse_worktree_output("").is_empty()); + } + + #[test] + fn test_parse_worktree_output_no_worktree_lines() { + let output = "HEAD abc123\nbranch refs/heads/main\n"; + assert!(parse_worktree_output(output).is_empty()); + } +} diff --git a/crates/tb-session/src/index/builder.rs b/crates/tb-session/src/index/builder.rs new file mode 100644 index 0000000..cdc596c --- /dev/null +++ b/crates/tb-session/src/index/builder.rs @@ -0,0 +1,242 @@ +use super::parser::ParsedSession; +use super::scanner::FileInfo; +use crate::error::Result; +use rusqlite::{Connection, params}; + +pub fn index_session( + conn: &Connection, + file_info: &FileInfo, + parsed: &ParsedSession, +) -> Result<()> { + // Determine metadata: prefer index_metadata fields over parsed fields. + let meta = file_info.index_metadata.as_ref(); + + let summary = meta + .and_then(|m| m.summary.as_deref()) + .or(parsed.summary.as_deref()); + + let first_prompt = meta + .and_then(|m| m.first_prompt.as_deref()) + .or(parsed.first_prompt.as_deref()); + + let git_branch = meta + .and_then(|m| m.git_branch.as_deref()) + .or(parsed.git_branch.as_deref()); + + let message_count: i64 = meta + .and_then(|m| m.message_count) + .unwrap_or(parsed.message_count) as i64; + + let created_at = meta + .and_then(|m| m.created.as_deref()) + .or(parsed.created_at.as_deref()); + + let modified_at = meta + .and_then(|m| m.modified.as_deref()) + .or(parsed.modified_at.as_deref()); + + let is_sidechain: i64 = meta + .and_then(|m| m.is_sidechain) + .unwrap_or(parsed.is_sidechain) as i64; + + // INSERT or REPLACE the session row. + conn.execute( + "INSERT OR REPLACE INTO sessions + (session_id, project_path, project_dir, file_path, file_mtime, + summary, first_prompt, git_branch, message_count, + created_at, modified_at, is_sidechain) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + params![ + file_info.session_id, + file_info.project_path, + file_info.project_dir, + file_info.file_path.to_string_lossy().as_ref(), + file_info.file_mtime as i64, + summary, + first_prompt, + git_branch, + message_count, + created_at, + modified_at, + is_sidechain, + ], + )?; + + // DELETE existing FTS rows for this session (handles re-indexing). + conn.execute( + "DELETE FROM messages_fts WHERE session_id = ?1", + params![file_info.session_id], + )?; + + // INSERT each message into the FTS5 table. + for msg in &parsed.messages { + conn.execute( + "INSERT INTO messages_fts (session_id, role, content, timestamp) + VALUES (?1, ?2, ?3, ?4)", + params![file_info.session_id, msg.role, msg.content, msg.timestamp,], + )?; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::super::parser::ParsedMessage; + use super::super::scanner::IndexEntry; + use super::*; + use crate::index::schema; + use rusqlite::Connection; + use std::path::PathBuf; + + fn in_memory() -> Connection { + let conn = Connection::open_in_memory().expect("in-memory DB"); + schema::create_tables(&conn).expect("create_tables"); + conn + } + + fn make_file_info(session_id: &str) -> FileInfo { + FileInfo { + session_id: session_id.to_string(), + file_path: PathBuf::from(format!("/tmp/{session_id}.jsonl")), + file_mtime: 1700000000, + project_path: "/Users/test/myapp".to_string(), + project_dir: "-Users-test-myapp".to_string(), + index_metadata: None, + } + } + + fn make_parsed(summary: Option<&str>, messages: Vec) -> ParsedSession { + ParsedSession { + summary: summary.map(|s| s.to_string()), + first_prompt: messages + .iter() + .find(|m| m.role == "user") + .map(|m| m.content.clone()), + git_branch: Some("main".to_string()), + message_count: messages.len(), + created_at: Some("2024-01-01T00:00:00Z".to_string()), + modified_at: Some("2024-01-02T00:00:00Z".to_string()), + is_sidechain: false, + messages, + } + } + + fn msg(role: &str, content: &str) -> ParsedMessage { + ParsedMessage { + role: role.to_string(), + content: content.to_string(), + timestamp: Some("2024-01-01T00:00:00Z".to_string()), + } + } + + #[test] + fn test_index_session_inserts_metadata() { + let conn = in_memory(); + let file_info = make_file_info("session-1"); + let parsed = make_parsed(Some("My summary"), vec![msg("user", "Hello")]); + + index_session(&conn, &file_info, &parsed).unwrap(); + + let summary: String = conn + .query_row( + "SELECT summary FROM sessions WHERE session_id = 'session-1'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(summary, "My summary"); + } + + #[test] + fn test_index_session_inserts_fts_messages() { + let conn = in_memory(); + let file_info = make_file_info("session-2"); + let parsed = make_parsed( + Some("summary"), + vec![ + msg("user", "First message"), + msg("assistant", "Second message"), + msg("user", "Third message"), + ], + ); + + index_session(&conn, &file_info, &parsed).unwrap(); + + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM messages_fts WHERE session_id = 'session-2'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count, 3); + } + + #[test] + fn test_index_session_prefers_index_metadata() { + let conn = in_memory(); + let mut file_info = make_file_info("session-3"); + file_info.index_metadata = Some(IndexEntry { + session_id: "session-3".to_string(), + summary: Some("Index summary".to_string()), + first_prompt: Some("Index first prompt".to_string()), + message_count: Some(99), + git_branch: Some("feature/index-branch".to_string()), + created: Some("2023-06-01T00:00:00Z".to_string()), + modified: Some("2023-06-02T00:00:00Z".to_string()), + is_sidechain: Some(true), + project_path: Some("/Users/test/myapp".to_string()), + }); + + let parsed = make_parsed(Some("Parsed summary"), vec![msg("user", "Parsed prompt")]); + + index_session(&conn, &file_info, &parsed).unwrap(); + + let (summary, first_prompt, message_count, git_branch, created_at, modified_at, is_sidechain): ( + String, String, i64, String, String, String, i64, + ) = conn + .query_row( + "SELECT summary, first_prompt, message_count, git_branch, created_at, modified_at, is_sidechain + FROM sessions WHERE session_id = 'session-3'", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?)), + ) + .unwrap(); + + assert_eq!(summary, "Index summary"); + assert_eq!(first_prompt, "Index first prompt"); + assert_eq!(message_count, 99); + assert_eq!(git_branch, "feature/index-branch"); + assert_eq!(created_at, "2023-06-01T00:00:00Z"); + assert_eq!(modified_at, "2023-06-02T00:00:00Z"); + assert_eq!(is_sidechain, 1); + } + + #[test] + fn test_fts_search_finds_content() { + let conn = in_memory(); + let file_info = make_file_info("session-4"); + let parsed = make_parsed( + Some("summary"), + vec![ + msg("user", "implement the authentication middleware"), + msg( + "assistant", + "Sure, I will implement the authentication middleware", + ), + ], + ); + + index_session(&conn, &file_info, &parsed).unwrap(); + + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM messages_fts WHERE messages_fts MATCH 'authentication'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count, 2); + } +} diff --git a/crates/tb-session/src/index/mod.rs b/crates/tb-session/src/index/mod.rs new file mode 100644 index 0000000..95dbe19 --- /dev/null +++ b/crates/tb-session/src/index/mod.rs @@ -0,0 +1,144 @@ +pub mod builder; +pub mod parser; +pub mod scanner; +pub mod schema; + +use crate::error::{Error, Result}; +use rusqlite::Connection; +use scanner::FileInfo; +use std::path::{Path, PathBuf}; + +/// Statistics about the current index. +#[derive(Debug)] +pub struct IndexStats { + pub session_count: u64, + pub project_count: u64, + pub db_size_bytes: u64, +} + +/// Open (or create) the SQLite database at `~/.cache/tb-session/index.db`. +/// +/// Enables WAL journal mode for better concurrent read performance, then +/// creates or resets the schema depending on `no_cache`. +pub fn open_db(no_cache: bool) -> Result { + let db_path = db_path()?; + if let Some(parent) = db_path.parent() { + std::fs::create_dir_all(parent)?; + } + + let conn = Connection::open(&db_path)?; + + // Enable WAL mode — best-effort, ignore errors on read-only filesystems. + let _ = conn.execute_batch("PRAGMA journal_mode=WAL;"); + + if no_cache { + schema::reset_tables(&conn)?; + } else { + schema::create_tables(&conn)?; + } + + Ok(conn) +} + +/// Return the path to the index database file. +fn db_path() -> Result { + let cache_dir = dirs::cache_dir() + .ok_or_else(|| Error::Other("cannot determine cache directory".to_string()))?; + Ok(cache_dir.join("tb-session").join("index.db")) +} + +/// Return `true` if the on-disk file is newer than what's recorded in the DB. +/// +/// A file is considered stale when: +/// - it has no entry in `sessions`, or +/// - its recorded `file_mtime` is less than the current mtime on disk. +pub fn is_stale(conn: &Connection, file_info: &FileInfo) -> Result { + let result: rusqlite::Result = conn.query_row( + "SELECT file_mtime FROM sessions WHERE session_id = ?1", + rusqlite::params![file_info.session_id], + |row| row.get(0), + ); + + match result { + Ok(recorded_mtime) => Ok(file_info.file_mtime > recorded_mtime as u64), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(true), + Err(e) => Err(e.into()), + } +} + +/// Scan for JSONL session files, re-index any that are stale. +/// +/// `projects_dir` is the root Claude projects directory +/// (typically `~/.claude/projects`). `scope_to_cwd` narrows the scan to a +/// single project path when provided. +pub fn ensure_fresh( + conn: &Connection, + projects_dir: &Path, + scope_to_cwd: Option<&Path>, +) -> Result<()> { + let files = scanner::scan_projects(projects_dir, scope_to_cwd)?; + + for file_info in &files { + if is_stale(conn, file_info)? { + let parsed = parser::parse_session(&file_info.file_path)?; + builder::index_session(conn, file_info, &parsed)?; + } + } + + Ok(()) +} + +/// Remove index rows for session files that no longer exist on disk. +/// +/// Uses parameterized queries — no string interpolation of user data. +pub fn cleanup_deleted(conn: &Connection) -> Result<()> { + // Collect all file paths currently in the index. + let mut stmt = conn.prepare("SELECT session_id, file_path FROM sessions")?; + let rows: Vec<(String, String)> = stmt + .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))? + .collect::>()?; + + for (session_id, file_path) in rows { + if !std::path::Path::new(&file_path).exists() { + // Delete FTS rows first (no foreign-key cascade in FTS5). + conn.execute( + "DELETE FROM messages_fts WHERE session_id = ?1", + rusqlite::params![session_id], + )?; + conn.execute( + "DELETE FROM sessions WHERE session_id = ?1", + rusqlite::params![session_id], + )?; + } + } + + Ok(()) +} + +/// Return aggregate statistics about the index. +pub fn get_stats(conn: &Connection) -> Result { + let session_count: u64 = conn + .query_row("SELECT COUNT(*) FROM sessions", [], |row| { + row.get::<_, i64>(0) + }) + .map(|n| n as u64)?; + + let project_count: u64 = conn + .query_row( + "SELECT COUNT(DISTINCT project_path) FROM sessions", + [], + |row| row.get::<_, i64>(0), + ) + .map(|n| n as u64)?; + + let db_size_bytes: u64 = db_path() + .and_then(|p| std::fs::metadata(&p).map_err(Into::into)) + .map(|m| m.len()) + .unwrap_or(0); + + Ok(IndexStats { + session_count, + project_count, + db_size_bytes, + }) +} diff --git a/crates/tb-session/src/index/parser.rs b/crates/tb-session/src/index/parser.rs new file mode 100644 index 0000000..136dfa3 --- /dev/null +++ b/crates/tb-session/src/index/parser.rs @@ -0,0 +1,312 @@ +use crate::error::Result; +use std::fs::File; +use std::io::{BufRead, BufReader}; +use std::path::Path; + +#[derive(Debug)] +pub struct ParsedSession { + pub summary: Option, + pub first_prompt: Option, + pub git_branch: Option, + pub message_count: usize, + pub created_at: Option, + pub modified_at: Option, + pub is_sidechain: bool, + pub messages: Vec, +} + +#[derive(Debug)] +pub struct ParsedMessage { + pub role: String, + pub content: String, + pub timestamp: Option, +} + +fn extract_content(message: &serde_json::Value) -> String { + match &message["content"] { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Array(arr) => arr + .iter() + .filter(|item| item.get("type").and_then(|t| t.as_str()) == Some("text")) + .filter_map(|item| item.get("text").and_then(|t| t.as_str())) + .collect::>() + .join("\n"), + _ => String::new(), + } +} + +pub fn parse_session(file_path: &Path) -> Result { + let file = File::open(file_path)?; + let reader = BufReader::new(file); + + let mut first_timestamp: Option = None; + let mut last_timestamp: Option = None; + let mut git_branch: Option = None; + let mut is_sidechain = false; + let mut messages: Vec = Vec::new(); + let mut first_prompt: Option = None; + let mut summary: Option = None; + + for line in reader.lines() { + let line = line?; + if line.trim().is_empty() { + continue; + } + + let entry: serde_json::Value = match serde_json::from_str(&line) { + Ok(v) => v, + Err(_) => continue, + }; + + // Track timestamps + if let Some(ts) = entry.get("timestamp").and_then(|t| t.as_str()) { + if first_timestamp.is_none() { + first_timestamp = Some(ts.to_string()); + } + last_timestamp = Some(ts.to_string()); + } + + // Extract gitBranch from first entry that has it + if git_branch.is_none() + && let Some(branch) = entry.get("gitBranch").and_then(|b| b.as_str()) + { + git_branch = Some(branch.to_string()); + } + + // Check isSidechain + if let Some(sc) = entry.get("isSidechain").and_then(|v| v.as_bool()) + && sc + { + is_sidechain = true; + } + + // Extract user/assistant messages + if let Some(message) = entry.get("message") { + let role = match message.get("role").and_then(|r| r.as_str()) { + Some(r) if r == "user" || r == "assistant" => r.to_string(), + _ => continue, + }; + + let content = extract_content(message); + + let timestamp = entry + .get("timestamp") + .and_then(|t| t.as_str()) + .map(|s| s.to_string()); + + if role == "user" && first_prompt.is_none() && !content.is_empty() { + first_prompt = Some(content.clone()); + } + + if role == "assistant" && summary.is_none() && !content.is_empty() { + let boundary = content.floor_char_boundary(197); + summary = Some(if boundary < content.len() { + format!("{}...", &content[..boundary]) + } else { + content.clone() + }); + } + + messages.push(ParsedMessage { + role, + content, + timestamp, + }); + } + } + + Ok(ParsedSession { + summary, + first_prompt, + git_branch, + message_count: messages.len(), + created_at: first_timestamp, + modified_at: last_timestamp, + is_sidechain, + messages, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + fn write_jsonl(lines: &[&str]) -> NamedTempFile { + let mut file = NamedTempFile::new().unwrap(); + for line in lines { + writeln!(file, "{line}").unwrap(); + } + file + } + + #[test] + fn test_parse_user_message() { + let file = write_jsonl(&[ + r#"{"timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello, world!"}}"#, + ]); + + let parsed = parse_session(file.path()).unwrap(); + + assert_eq!(parsed.messages.len(), 1); + assert_eq!(parsed.messages[0].role, "user"); + assert_eq!(parsed.messages[0].content, "Hello, world!"); + assert_eq!(parsed.first_prompt.as_deref(), Some("Hello, world!")); + } + + #[test] + fn test_parse_assistant_message_with_content_array() { + let file = write_jsonl(&[ + r#"{"timestamp":"2024-01-01T00:00:00Z","message":{"role":"assistant","content":[{"type":"text","text":"Here is the answer."},{"type":"tool_use","id":"123","name":"bash"}]}}"#, + ]); + + let parsed = parse_session(file.path()).unwrap(); + + assert_eq!(parsed.messages.len(), 1); + assert_eq!(parsed.messages[0].role, "assistant"); + assert_eq!(parsed.messages[0].content, "Here is the answer."); + } + + #[test] + fn test_parse_extracts_metadata() { + let file = write_jsonl(&[ + r#"{"timestamp":"2024-01-01T00:00:00Z","gitBranch":"feature/my-branch","isSidechain":true,"message":{"role":"user","content":"First message"}}"#, + r#"{"timestamp":"2024-01-02T00:00:00Z","message":{"role":"assistant","content":"Response"}}"#, + ]); + + let parsed = parse_session(file.path()).unwrap(); + + assert_eq!(parsed.git_branch.as_deref(), Some("feature/my-branch")); + assert_eq!(parsed.created_at.as_deref(), Some("2024-01-01T00:00:00Z")); + assert_eq!(parsed.modified_at.as_deref(), Some("2024-01-02T00:00:00Z")); + assert_eq!(parsed.message_count, 2); + assert!(parsed.is_sidechain); + } + + #[test] + fn test_parse_skips_non_message_lines() { + let file = write_jsonl(&[ + r#"{"type":"progress","text":"Thinking..."}"#, + r#"{"type":"file-history-snapshot","files":[]}"#, + r#"{"timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Real message"}}"#, + ]); + + let parsed = parse_session(file.path()).unwrap(); + + assert_eq!(parsed.messages.len(), 1); + assert_eq!(parsed.messages[0].content, "Real message"); + // message_count reflects actual user/assistant messages, not all JSON lines + assert_eq!(parsed.message_count, 1); + } + + #[test] + fn test_parse_handles_malformed_lines() { + let file = write_jsonl(&[ + r#"this is not valid json {"#, + r#"{"timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Valid"}}"#, + ]); + + let parsed = parse_session(file.path()).unwrap(); + + assert_eq!(parsed.messages.len(), 1); + assert_eq!(parsed.messages[0].content, "Valid"); + // Only 1 valid JSON line (the malformed one is skipped) + assert_eq!(parsed.message_count, 1); + } + + #[test] + fn test_parse_zero_messages() { + // All lines are non-message entries — message_count should be 0 + let file = write_jsonl(&[ + r#"{"type":"progress","text":"Thinking..."}"#, + r#"{"type":"file-history-snapshot","files":[]}"#, + r#"{"cwd":"/Users/test/myapp"}"#, + ]); + + let parsed = parse_session(file.path()).unwrap(); + assert_eq!(parsed.messages.len(), 0); + assert_eq!(parsed.message_count, 0); + assert!(parsed.first_prompt.is_none()); + assert!(parsed.summary.is_none()); + } + + #[test] + fn test_parse_skips_non_user_assistant_roles() { + let file = write_jsonl(&[ + r#"{"timestamp":"2024-01-01T00:00:00Z","message":{"role":"system","content":"System prompt"}}"#, + r#"{"timestamp":"2024-01-01T00:00:01Z","message":{"role":"tool","content":"Tool output"}}"#, + r#"{"timestamp":"2024-01-01T00:00:02Z","message":{"role":"user","content":"Hello"}}"#, + ]); + + let parsed = parse_session(file.path()).unwrap(); + assert_eq!(parsed.messages.len(), 1); + assert_eq!(parsed.message_count, 1); + assert_eq!(parsed.messages[0].role, "user"); + } + + #[test] + fn test_parse_first_prompt_skips_empty_content() { + let file = write_jsonl(&[ + r#"{"timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":""}}"#, + r#"{"timestamp":"2024-01-01T00:00:01Z","message":{"role":"user","content":"Real prompt"}}"#, + ]); + + let parsed = parse_session(file.path()).unwrap(); + assert_eq!(parsed.first_prompt.as_deref(), Some("Real prompt")); + } + + #[test] + fn test_parse_summary_skips_empty_assistant() { + let file = write_jsonl(&[ + r#"{"timestamp":"2024-01-01T00:00:00Z","message":{"role":"assistant","content":""}}"#, + r#"{"timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Real summary"}}"#, + ]); + + let parsed = parse_session(file.path()).unwrap(); + assert_eq!(parsed.summary.as_deref(), Some("Real summary")); + } + + #[test] + fn test_parse_summary_not_overwritten_by_second_assistant() { + let file = write_jsonl(&[ + r#"{"timestamp":"2024-01-01T00:00:00Z","message":{"role":"assistant","content":"First response"}}"#, + r#"{"timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Second response"}}"#, + ]); + + let parsed = parse_session(file.path()).unwrap(); + assert_eq!(parsed.summary.as_deref(), Some("First response")); + } + + #[test] + fn test_parse_extract_content_non_standard_types() { + // Content that is null/number/object should produce empty string + let file = write_jsonl(&[ + r#"{"timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":null}}"#, + ]); + + let parsed = parse_session(file.path()).unwrap(); + assert_eq!(parsed.messages.len(), 1); + assert_eq!(parsed.messages[0].content, ""); + } + + #[test] + fn test_parse_summary_from_first_assistant() { + let long_text = "A".repeat(300); + let line = format!( + r#"{{"timestamp":"2024-01-01T00:00:00Z","message":{{"role":"assistant","content":"{long_text}"}}}}"# + ); + let file = write_jsonl(&[&line]); + + let parsed = parse_session(file.path()).unwrap(); + + assert!(parsed.summary.is_some()); + let summary = parsed.summary.unwrap(); + assert!( + summary.len() <= 200, + "summary length {} > 200", + summary.len() + ); + assert!(summary.ends_with("...")); + } +} diff --git a/crates/tb-session/src/index/scanner.rs b/crates/tb-session/src/index/scanner.rs new file mode 100644 index 0000000..e9e7e0b --- /dev/null +++ b/crates/tb-session/src/index/scanner.rs @@ -0,0 +1,476 @@ +use std::collections::HashMap; +use std::fs; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; + +use crate::error::Result; + +#[derive(Debug)] +pub struct FileInfo { + pub session_id: String, + pub file_path: PathBuf, + pub file_mtime: u64, + pub project_path: String, + pub project_dir: String, + pub index_metadata: Option, +} + +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IndexEntry { + pub session_id: String, + pub summary: Option, + pub first_prompt: Option, + pub message_count: Option, + pub git_branch: Option, + pub created: Option, + pub modified: Option, + pub is_sidechain: Option, + pub project_path: Option, +} + +/// Encode a filesystem path into the directory-name format used by Claude. +/// +/// Claude encodes project directories by replacing `/` with `-`, e.g. +/// `/Users/test/Projects/myapp` becomes `-Users-test-Projects-myapp`. +/// This encoding is lossy — original hyphens are indistinguishable from +/// path separators — so it can only be used for forward matching, never +/// for decoding back to the original path. +pub fn encode_path(path: &Path) -> String { + path.to_string_lossy().replace('/', "-") +} + +/// Discover JSONL session files under `projects_dir`. +/// +/// When `scope_to_cwd` is `Some`, only the project directory whose encoded +/// name matches the encoded `cwd` is scanned. Otherwise every project +/// directory is visited. +pub fn scan_projects(projects_dir: &Path, scope_to_cwd: Option<&Path>) -> Result> { + let mut results = Vec::new(); + + let entries = match fs::read_dir(projects_dir) { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(results), + Err(e) => return Err(e.into()), + }; + + let encoded_cwd = scope_to_cwd.map(encode_path); + + for entry in entries { + let entry = entry?; + let dir_path = entry.path(); + + if !dir_path.is_dir() { + continue; + } + + let dir_name = match dir_path.file_name().and_then(|n| n.to_str()) { + Some(name) => name.to_string(), + None => continue, + }; + + // When scoping, skip directories that don't match the encoded cwd. + if let Some(ref encoded) = encoded_cwd + && dir_name != *encoded + { + continue; + } + + // Load sessions-index.json for this project directory. + let index = load_sessions_index(&dir_path); + + // Resolve the project path: prefer sessions-index.json projectPath, + // fall back to extracting cwd from the first JSONL file found. + let project_path_from_index = index.values().find_map(|e| e.project_path.clone()); + + // Iterate over JSONL files in this project directory. + let dir_entries = match fs::read_dir(&dir_path) { + Ok(de) => de, + Err(_) => continue, + }; + + let mut fallback_project_path: Option = None; + + // Collect JSONL file info first so we can resolve project_path lazily. + let mut pending: Vec<(String, PathBuf, u64, Option)> = Vec::new(); + + for file_entry in dir_entries { + let file_entry = match file_entry { + Ok(fe) => fe, + Err(_) => continue, + }; + + let file_path = file_entry.path(); + + let file_name = match file_path.file_name().and_then(|n| n.to_str()) { + Some(name) => name.to_string(), + None => continue, + }; + + if !file_name.ends_with(".jsonl") { + continue; + } + + // Session ID is the filename without extension. + let session_id = file_name.trim_end_matches(".jsonl").to_string(); + + let file_mtime = file_path + .metadata() + .and_then(|m| m.modified()) + .map(|t| { + t.duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + }) + .unwrap_or(0); + + let index_entry = index.get(&session_id).cloned(); + + pending.push((session_id, file_path, file_mtime, index_entry)); + } + + // Resolve fallback project_path if the index didn't have one. + let resolved_project_path = if let Some(ref pp) = project_path_from_index { + pp.clone() + } else { + // Try to extract cwd from the first JSONL file. + if fallback_project_path.is_none() { + for (_, path, _, _) in &pending { + if let Some(cwd) = extract_cwd_from_jsonl(path) { + fallback_project_path = Some(cwd); + break; + } + } + } + fallback_project_path.unwrap_or_default() + }; + + for (session_id, file_path, file_mtime, index_entry) in pending { + results.push(FileInfo { + session_id, + file_path, + file_mtime, + project_path: resolved_project_path.clone(), + project_dir: dir_name.clone(), + index_metadata: index_entry, + }); + } + } + + Ok(results) +} + +/// Read the first few lines of a JSONL file looking for a `cwd` field. +/// +/// Returns the first `cwd` value found, or `None` if the file cannot be +/// read or none of the first 5 lines contain a `cwd` key. +pub fn extract_cwd_from_jsonl(path: &Path) -> Option { + let file = fs::File::open(path).ok()?; + let reader = BufReader::new(file); + + for line in reader.lines().take(5) { + let line = line.ok()?; + if let Ok(value) = serde_json::from_str::(&line) + && let Some(cwd) = value.get("cwd").and_then(|v| v.as_str()) + { + return Some(cwd.to_string()); + } + } + + None +} + +/// Versioned wrapper format used by Claude: `{"version": N, "entries": [...]}`. +#[derive(serde::Deserialize)] +struct VersionedIndex { + #[allow(dead_code)] + version: u32, + entries: Vec, +} + +/// Load and parse `sessions-index.json` from a project directory. +/// +/// Returns a map from `session_id` to `IndexEntry`. Returns an empty map +/// if the file doesn't exist or can't be parsed. +/// +/// Supports both the versioned format `{"version": N, "entries": [...]}` and +/// the legacy flat-array format `[...]`. +pub fn load_sessions_index(project_dir: &Path) -> HashMap { + let index_path = project_dir.join("sessions-index.json"); + + let content = match fs::read_to_string(&index_path) { + Ok(c) => c, + Err(_) => return HashMap::new(), + }; + + // Try versioned format first: {"version": N, "entries": [...]} + if let Ok(versioned) = serde_json::from_str::(&content) { + return versioned + .entries + .into_iter() + .map(|e| (e.session_id.clone(), e)) + .collect(); + } + + // Fallback: flat array format [...] + let entries: Vec = match serde_json::from_str(&content) { + Ok(e) => e, + Err(_) => return HashMap::new(), + }; + + entries + .into_iter() + .map(|e| (e.session_id.clone(), e)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn create_test_projects_dir(base: &Path) -> PathBuf { + let projects_dir = base.join(".claude").join("projects"); + fs::create_dir_all(&projects_dir).unwrap(); + projects_dir + } + + fn create_project_with_sessions( + projects_dir: &Path, + dir_name: &str, + sessions: &[&str], + ) -> PathBuf { + let project_dir = projects_dir.join(dir_name); + fs::create_dir_all(&project_dir).unwrap(); + + for session_id in sessions { + let file_path = project_dir.join(format!("{session_id}.jsonl")); + fs::write(&file_path, "{\"type\":\"init\"}\n").unwrap(); + } + + project_dir + } + + #[test] + fn test_scan_finds_jsonl_files() { + let tmp = tempfile::tempdir().unwrap(); + let projects_dir = create_test_projects_dir(tmp.path()); + + create_project_with_sessions(&projects_dir, "-Users-test-myapp", &["abc-123", "def-456"]); + + let results = scan_projects(&projects_dir, None).unwrap(); + assert_eq!(results.len(), 2); + + let mut ids: Vec<&str> = results.iter().map(|f| f.session_id.as_str()).collect(); + ids.sort(); + assert_eq!(ids, vec!["abc-123", "def-456"]); + } + + #[test] + fn test_scan_extracts_session_id_from_filename() { + let tmp = tempfile::tempdir().unwrap(); + let projects_dir = create_test_projects_dir(tmp.path()); + + create_project_with_sessions( + &projects_dir, + "-Users-test-myapp", + &["550e8400-e29b-41d4-a716-446655440000"], + ); + + let results = scan_projects(&projects_dir, None).unwrap(); + assert_eq!(results.len(), 1); + assert_eq!( + results[0].session_id, + "550e8400-e29b-41d4-a716-446655440000" + ); + } + + #[test] + fn test_scan_extracts_project_path_from_index() { + let tmp = tempfile::tempdir().unwrap(); + let projects_dir = create_test_projects_dir(tmp.path()); + + let project_dir = + create_project_with_sessions(&projects_dir, "-Users-test-myapp", &["session-1"]); + + // Write sessions-index.json with a projectPath. + let index_content = serde_json::json!([ + { + "sessionId": "session-1", + "summary": "Test session", + "projectPath": "/Users/test/myapp" + } + ]); + fs::write( + project_dir.join("sessions-index.json"), + index_content.to_string(), + ) + .unwrap(); + + let results = scan_projects(&projects_dir, None).unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].project_path, "/Users/test/myapp"); + assert!(results[0].index_metadata.is_some()); + + let meta = results[0].index_metadata.as_ref().unwrap(); + assert_eq!(meta.summary.as_deref(), Some("Test session")); + } + + #[test] + fn test_scan_scoped_to_cwd() { + let tmp = tempfile::tempdir().unwrap(); + let projects_dir = create_test_projects_dir(tmp.path()); + + // Two project directories — one for /Users/test/myapp, one for + // /Users/test/e2e-tests (note the hyphen in the real path). + create_project_with_sessions(&projects_dir, "-Users-test-myapp", &["session-a"]); + create_project_with_sessions(&projects_dir, "-Users-test-e2e-tests", &["session-b"]); + + // Scope to /Users/test/myapp — should only find session-a. + let scoped = scan_projects(&projects_dir, Some(Path::new("/Users/test/myapp"))).unwrap(); + assert_eq!(scoped.len(), 1); + assert_eq!(scoped[0].session_id, "session-a"); + + // Scope to /Users/test/e2e-tests — should only find session-b. + // This works because the encoded form "-Users-test-e2e-tests" matches + // the directory name exactly (even though the encoding is lossy for + // paths that originally contained hyphens). + let scoped = + scan_projects(&projects_dir, Some(Path::new("/Users/test/e2e-tests"))).unwrap(); + assert_eq!(scoped.len(), 1); + assert_eq!(scoped[0].session_id, "session-b"); + + // Unscoped scan should find both. + let all = scan_projects(&projects_dir, None).unwrap(); + assert_eq!(all.len(), 2); + } + + #[test] + fn test_encode_path() { + assert_eq!( + encode_path(Path::new("/Users/test/Projects/myapp")), + "-Users-test-Projects-myapp" + ); + assert_eq!( + encode_path(Path::new("/Users/test/e2e-tests")), + "-Users-test-e2e-tests" + ); + } + + #[test] + fn test_extract_cwd_from_jsonl() { + let tmp = tempfile::tempdir().unwrap(); + let file_path = tmp.path().join("test.jsonl"); + + // cwd on line 3 (within the first 5 lines). + fs::write( + &file_path, + "{\"type\":\"init\"}\n{\"role\":\"user\"}\n{\"cwd\":\"/Users/test/myapp\"}\n", + ) + .unwrap(); + + assert_eq!( + extract_cwd_from_jsonl(&file_path), + Some("/Users/test/myapp".to_string()) + ); + } + + #[test] + fn test_extract_cwd_from_jsonl_missing() { + let tmp = tempfile::tempdir().unwrap(); + let file_path = tmp.path().join("test.jsonl"); + + fs::write(&file_path, "{\"type\":\"init\"}\n{\"role\":\"user\"}\n").unwrap(); + + assert_eq!(extract_cwd_from_jsonl(&file_path), None); + } + + #[test] + fn test_load_sessions_index_missing_file() { + let tmp = tempfile::tempdir().unwrap(); + let index = load_sessions_index(tmp.path()); + assert!(index.is_empty()); + } + + #[test] + fn test_load_sessions_index_valid() { + let tmp = tempfile::tempdir().unwrap(); + let content = serde_json::json!([ + { + "sessionId": "abc-123", + "summary": "Refactored auth module", + "messageCount": 42, + "gitBranch": "feature/auth", + "projectPath": "/Users/test/myapp" + }, + { + "sessionId": "def-456", + "firstPrompt": "Fix the login bug" + } + ]); + fs::write(tmp.path().join("sessions-index.json"), content.to_string()).unwrap(); + + let index = load_sessions_index(tmp.path()); + assert_eq!(index.len(), 2); + + let entry = index.get("abc-123").unwrap(); + assert_eq!(entry.summary.as_deref(), Some("Refactored auth module")); + assert_eq!(entry.message_count, Some(42)); + assert_eq!(entry.git_branch.as_deref(), Some("feature/auth")); + + let entry2 = index.get("def-456").unwrap(); + assert_eq!(entry2.first_prompt.as_deref(), Some("Fix the login bug")); + assert!(entry2.summary.is_none()); + } + + #[test] + fn test_scan_fallback_project_path_from_jsonl() { + let tmp = tempfile::tempdir().unwrap(); + let projects_dir = create_test_projects_dir(tmp.path()); + + let project_dir = create_project_with_sessions(&projects_dir, "-Users-test-myapp", &[]); + + // Write a JSONL file with a cwd field (no sessions-index.json). + let file_path = project_dir.join("session-1.jsonl"); + fs::write(&file_path, "{\"cwd\":\"/Users/test/myapp\"}\n").unwrap(); + + let results = scan_projects(&projects_dir, None).unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].project_path, "/Users/test/myapp"); + } + + #[test] + fn test_scan_nonexistent_projects_dir() { + let tmp = tempfile::tempdir().unwrap(); + let nonexistent = tmp.path().join("does-not-exist"); + + let results = scan_projects(&nonexistent, None).unwrap(); + assert!(results.is_empty()); + } + + #[test] + fn test_load_sessions_index_versioned_format() { + let tmp = tempfile::tempdir().unwrap(); + let content = serde_json::json!({ + "version": 1, + "entries": [ + { + "sessionId": "abc-123", + "summary": "Refactored auth", + "messageCount": 42, + "gitBranch": "feature/auth", + "projectPath": "/Users/test/myapp" + } + ], + "originalPath": "/Users/test/myapp" + }); + fs::write(tmp.path().join("sessions-index.json"), content.to_string()).unwrap(); + + let index = load_sessions_index(tmp.path()); + assert_eq!(index.len(), 1); + assert_eq!( + index.get("abc-123").unwrap().summary.as_deref(), + Some("Refactored auth") + ); + } +} diff --git a/crates/tb-session/src/index/schema.rs b/crates/tb-session/src/index/schema.rs new file mode 100644 index 0000000..746b502 --- /dev/null +++ b/crates/tb-session/src/index/schema.rs @@ -0,0 +1,139 @@ +use crate::error::Result; +use rusqlite::Connection; + +/// Create all tables and FTS5 virtual table if they don't exist. +pub fn create_tables(conn: &Connection) -> Result<()> { + conn.execute_batch( + " + CREATE TABLE IF NOT EXISTS sessions ( + session_id TEXT PRIMARY KEY, + project_path TEXT NOT NULL, + project_dir TEXT NOT NULL, + file_path TEXT NOT NULL, + file_mtime INTEGER NOT NULL, + summary TEXT, + first_prompt TEXT, + git_branch TEXT, + message_count INTEGER DEFAULT 0, + created_at TEXT, + modified_at TEXT, + is_sidechain INTEGER DEFAULT 0 + ); + + -- Note: NOT contentless (spec says content='') because contentless FTS5 + -- does not support snippet(), COUNT(*), or simple DELETE. + CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5( + session_id, + role, + content, + timestamp + ); + + CREATE INDEX IF NOT EXISTS idx_sessions_project_path ON sessions(project_path); + CREATE INDEX IF NOT EXISTS idx_sessions_git_branch ON sessions(git_branch); + CREATE INDEX IF NOT EXISTS idx_sessions_modified_at ON sessions(modified_at); + ", + )?; + Ok(()) +} + +/// Drop all tables and recreate (for --no-cache full rebuild). +pub fn reset_tables(conn: &Connection) -> Result<()> { + conn.execute_batch( + " + DROP TABLE IF EXISTS messages_fts; + DROP TABLE IF EXISTS sessions; + ", + )?; + create_tables(conn) +} + +#[cfg(test)] +mod tests { + use super::*; + use rusqlite::Connection; + + fn in_memory() -> Connection { + Connection::open_in_memory().expect("in-memory DB") + } + + #[test] + fn test_create_tables() { + let conn = in_memory(); + create_tables(&conn).expect("create_tables should succeed"); + + // Verify sessions table exists by inserting a row + conn.execute( + "INSERT INTO sessions (session_id, project_path, project_dir, file_path, file_mtime) + VALUES ('sid1', '/proj', 'mydir', '/proj/file.jsonl', 1234567890)", + [], + ) + .expect("sessions table should exist and accept inserts"); + + // Verify messages_fts table exists by inserting a row + conn.execute( + "INSERT INTO messages_fts (session_id, role, content, timestamp) + VALUES ('sid1', 'user', 'hello world', '2024-01-01T00:00:00Z')", + [], + ) + .expect("messages_fts table should exist and accept inserts"); + + // Verify data is retrievable + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM sessions", [], |r| r.get(0)) + .expect("COUNT query should succeed"); + assert_eq!(count, 1); + } + + #[test] + fn test_create_tables_idempotent() { + let conn = in_memory(); + create_tables(&conn).expect("first call should succeed"); + create_tables(&conn).expect("second call should not error"); + + // Tables should still be usable + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM sessions", [], |r| r.get(0)) + .expect("COUNT query should succeed"); + assert_eq!(count, 0); + } + + #[test] + fn test_reset_tables() { + let conn = in_memory(); + create_tables(&conn).expect("create_tables should succeed"); + + // Insert data into both tables + conn.execute( + "INSERT INTO sessions (session_id, project_path, project_dir, file_path, file_mtime) + VALUES ('sid1', '/proj', 'mydir', '/proj/file.jsonl', 1234567890)", + [], + ) + .expect("insert into sessions"); + conn.execute( + "INSERT INTO messages_fts (session_id, role, content, timestamp) + VALUES ('sid1', 'user', 'some content', '2024-01-01T00:00:00Z')", + [], + ) + .expect("insert into messages_fts"); + + // Confirm data exists + let session_count: i64 = conn + .query_row("SELECT COUNT(*) FROM sessions", [], |r| r.get(0)) + .expect("COUNT sessions"); + assert_eq!(session_count, 1); + + // Reset and verify tables are empty + reset_tables(&conn).expect("reset_tables should succeed"); + + let session_count_after: i64 = conn + .query_row("SELECT COUNT(*) FROM sessions", [], |r| r.get(0)) + .expect("COUNT sessions after reset"); + assert_eq!(session_count_after, 0); + + let fts_count_after: i64 = conn + .query_row("SELECT COUNT(*) FROM messages_fts", [], |r| r.get(0)) + .expect("COUNT messages_fts after reset"); + assert_eq!(fts_count_after, 0); + } +} diff --git a/crates/tb-session/src/lib.rs b/crates/tb-session/src/lib.rs new file mode 100644 index 0000000..99eb2ef --- /dev/null +++ b/crates/tb-session/src/lib.rs @@ -0,0 +1,6 @@ +pub mod commands; +pub mod config; +pub mod error; +pub mod git; +pub mod index; +pub mod models; diff --git a/crates/tb-session/src/main.rs b/crates/tb-session/src/main.rs new file mode 100644 index 0000000..b0c8875 --- /dev/null +++ b/crates/tb-session/src/main.rs @@ -0,0 +1,328 @@ +use clap::Parser; + +use tb_session::config::Config; + +#[derive(Parser)] +#[command( + name = "tb-session", + disable_version_flag = true, + about = "Claude Code session search CLI" +)] +struct Cli { + #[command(subcommand)] + command: Option, + + /// Bypass index cache (force rebuild) + #[arg(long, global = true)] + no_cache: bool, + + /// Output as JSON + #[arg(long, global = true)] + json: bool, + + /// Print version info + #[arg(short = 'V', long = "version")] + version: bool, +} + +#[derive(clap::Subcommand)] +enum Commands { + /// Full-text search across sessions + Search { + /// Search query (optional when --pr is used) + query: Option, + + /// Filter by git branch name + #[arg(long)] + branch: Option, + + /// Filter by project path (substring match) + #[arg(long)] + project: Option, + + /// Search across all projects (default: current directory only) + #[arg(long)] + all_projects: bool, + + /// Maximum number of results + #[arg(long)] + limit: Option, + + /// Only sessions modified on or after this date (ISO 8601) + #[arg(long)] + after: Option, + + /// Only sessions modified on or before this date (ISO 8601) + #[arg(long)] + before: Option, + + /// Search for sessions mentioning a PR (number or URL) + #[arg(long)] + pr: Option, + }, + + /// List sessions by metadata (no full-text search) + List { + /// Filter by git branch name + #[arg(long)] + branch: Option, + + /// List sessions across all projects + #[arg(long)] + all_projects: bool, + + /// Maximum number of results per page + #[arg(long)] + limit: Option, + + /// Page number (starts at 1) + #[arg(long, default_value = "1")] + page: usize, + + /// Only sessions modified on or after this date + #[arg(long)] + after: Option, + + /// Only sessions modified on or before this date + #[arg(long)] + before: Option, + }, + + /// Show session detail and conversation preview + Show { + /// Session ID (full or prefix) + session_id: String, + }, + + /// Resume a session (execs into claude --resume) + Resume { + /// Session ID, UUID prefix, or search term (matches summary/first prompt) + session_id: String, + }, + + /// Rebuild the search index + Index { + /// Index all projects (default: current dir only) + #[arg(long)] + all_projects: bool, + }, + + /// Verify setup and diagnose issues + Doctor, + + /// Delete the index database for a clean rebuild + CacheClear, + + /// AI-optimized context dump + Prime, + + /// Manage Claude Code skill file + Skill { + #[command(subcommand)] + action: toolbox_core::skill::SkillAction, + }, + + /// Manage configuration + Config { + #[command(subcommand)] + action: ConfigAction, + }, +} + +#[derive(clap::Subcommand)] +enum ConfigAction { + /// Write default config to disk + Init, + /// Show current config and resolved paths + Show, +} + +fn main() { + if let Err(e) = run() { + use colored::Colorize; + eprintln!("{} {e}", "Error:".red().bold()); + std::process::exit(1); + } +} + +/// Build an FTS5 search query from a PR reference (number or URL). +fn build_pr_query(pr_ref: &str) -> String { + if pr_ref.starts_with("http") { + // Full URL — search for it directly + pr_ref.to_string() + } else { + // PR number — search for "pull/" which matches GitHub PR URLs + format!("pull/{}", pr_ref) + } +} + +fn resolve_and_freshen( + conn: &rusqlite::Connection, + projects_dir: &std::path::Path, + all_projects: bool, +) -> tb_session::error::Result> { + if all_projects { + tb_session::index::ensure_fresh(conn, projects_dir, None)?; + return Ok(vec![]); + } + let cwd = std::env::current_dir()?; + let repo_paths: Vec = tb_session::git::repo_paths(&cwd) + .into_iter() + .map(|p| p.to_string_lossy().into_owned()) + .collect(); + for path in &repo_paths { + tb_session::index::ensure_fresh(conn, projects_dir, Some(std::path::Path::new(path)))?; + } + Ok(repo_paths) +} + +fn run() -> tb_session::error::Result<()> { + let cli = Cli::parse(); + + if cli.version { + toolbox_core::version_check::print_version("tb-session", env!("CARGO_PKG_VERSION")); + return Ok(()); + } + + let Some(command) = cli.command else { + Cli::parse_from(["tb-session", "--help"]); + unreachable!() + }; + + match command { + Commands::Search { + query, + branch, + project, + all_projects, + limit, + after, + before, + pr, + } => { + let effective_query = match (&query, &pr) { + (Some(_), Some(_)) => { + return Err(tb_session::error::Error::Other( + "cannot use --pr with a positional query — use one or the other".into(), + )); + } + (Some(q), None) => q.clone(), + (None, Some(pr_ref)) => build_pr_query(pr_ref), + (None, None) => { + return Err(tb_session::error::Error::Other( + "provide a search query or use --pr ".into(), + )); + } + }; + + let config = Config::load()?; + let conn = tb_session::index::open_db(cli.no_cache)?; + let projects_dir = config.projects_dir(); + let repo_paths = resolve_and_freshen(&conn, &projects_dir, all_projects)?; + let effective_limit = limit.unwrap_or(config.default_limit); + + tb_session::commands::search::run( + &conn, + &effective_query, + branch.as_deref(), + after.as_deref(), + before.as_deref(), + project.as_deref(), + &repo_paths, + effective_limit, + cli.json, + )?; + } + Commands::List { + branch, + all_projects, + limit, + page, + after, + before, + } => { + let config = Config::load()?; + let conn = tb_session::index::open_db(cli.no_cache)?; + let projects_dir = config.projects_dir(); + let repo_paths = resolve_and_freshen(&conn, &projects_dir, all_projects)?; + let effective_limit = limit.unwrap_or(config.default_limit); + + tb_session::commands::list::run( + &conn, + branch.as_deref(), + after.as_deref(), + before.as_deref(), + &repo_paths, + effective_limit, + page, + cli.json, + )?; + } + Commands::Show { session_id } => { + let config = Config::load()?; + let conn = tb_session::index::open_db(cli.no_cache)?; + let projects_dir = config.projects_dir(); + resolve_and_freshen(&conn, &projects_dir, false)?; + tb_session::commands::show::run(&conn, &session_id, cli.json)?; + } + Commands::Resume { session_id } => { + let config = Config::load()?; + let conn = tb_session::index::open_db(cli.no_cache)?; + let projects_dir = config.projects_dir(); + resolve_and_freshen(&conn, &projects_dir, false)?; + tb_session::commands::resume::run(&conn, &session_id)?; + } + Commands::Index { all_projects } => { + tb_session::commands::index_cmd::run(all_projects)?; + } + Commands::Doctor => { + tb_session::commands::doctor::run()?; + toolbox_core::version_check::print_update_hint("tb-session", env!("CARGO_PKG_VERSION")); + } + Commands::CacheClear => { + tb_session::commands::cache_clear::run()?; + } + Commands::Prime => { + tb_session::commands::prime::run()?; + toolbox_core::version_check::print_update_hint("tb-session", env!("CARGO_PKG_VERSION")); + } + Commands::Skill { action } => { + let skill = toolbox_core::skill::SkillConfig { + tool_name: "tb-session", + content: include_str!("../SKILL.md"), + }; + toolbox_core::skill::run(&skill, &action).map_err(tb_session::error::Error::Other)?; + } + Commands::Config { action } => match action { + ConfigAction::Init => tb_session::commands::config_cmd::init()?, + ConfigAction::Show => tb_session::commands::config_cmd::show()?, + }, + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_pr_query() { + // PR number → pull/N + assert_eq!(build_pr_query("557"), "pull/557"); + assert_eq!(build_pr_query("1"), "pull/1"); + + // Full URL → returned verbatim + assert_eq!( + build_pr_query("https://github.com/org/repo/pull/557"), + "https://github.com/org/repo/pull/557" + ); + assert_eq!( + build_pr_query("http://github.com/org/repo/pull/1"), + "http://github.com/org/repo/pull/1" + ); + + // Non-numeric non-URL string (documents current behavior) + assert_eq!(build_pr_query("feature-branch"), "pull/feature-branch"); + } +} diff --git a/crates/tb-session/src/models.rs b/crates/tb-session/src/models.rs new file mode 100644 index 0000000..9c7d422 --- /dev/null +++ b/crates/tb-session/src/models.rs @@ -0,0 +1,108 @@ +use serde::Serialize; + +/// Top-level response for the `search` command. +#[derive(Debug, Serialize)] +pub struct SearchResult { + pub query: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub filters: Option, + pub total_results: usize, + pub results: Vec, +} + +/// Active filters applied to a search. +#[derive(Debug, Serialize)] +pub struct SearchFilters { + #[serde(skip_serializing_if = "Option::is_none")] + pub project: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub branch: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub from: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub to: Option, + pub all_projects: bool, +} + +/// A single session that matched a search query. +#[derive(Debug, Serialize)] +pub struct SessionMatch { + pub session_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub first_prompt: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub git_branch: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub project_path: Option, + pub message_count: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub created_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub modified_at: Option, + pub relevance_score: f64, + #[serde(skip_serializing_if = "Option::is_none")] + pub matched_snippet: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub matched_role: Option, +} + +/// Detailed view of a single session (for `show` command). +#[derive(Debug, Serialize)] +pub struct SessionDetail { + pub session_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub first_prompt: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub git_branch: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub project_path: Option, + pub message_count: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub created_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub modified_at: Option, + pub is_sidechain: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub messages: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub total_messages: Option, +} + +/// A single message preview within a session. +#[derive(Debug, Serialize)] +pub struct MessagePreview { + pub role: String, + pub content: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub timestamp: Option, +} + +/// Top-level response for the `list` command. +#[derive(Debug, Serialize)] +pub struct SessionList { + pub total_results: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub page: Option, + pub results: Vec, +} + +/// Summary of a session in list output. +#[derive(Debug, Serialize)] +pub struct SessionSummary { + pub session_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub git_branch: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub project_path: Option, + pub message_count: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub created_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub modified_at: Option, +} diff --git a/scripts/bump.sh b/scripts/bump.sh index 57116f0..e348924 100755 --- a/scripts/bump.sh +++ b/scripts/bump.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -VALID_TOOLS="tb-prod tb-sem tb-bug tb-lf tb-devctl" +VALID_TOOLS="tb-prod tb-sem tb-bug tb-lf tb-devctl tb-session" usage() { echo "Usage: $0 " diff --git a/scripts/install.sh b/scripts/install.sh index 1132b61..bab29e0 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2,7 +2,7 @@ set -euo pipefail REPO="productiveio/cli-toolbox" -ALL_TOOLS="tb-prod tb-sem tb-bug tb-lf tb-devctl" +ALL_TOOLS="tb-prod tb-sem tb-bug tb-lf tb-devctl tb-session" INSTALL_DIR="$HOME/.local/bin" # --- Flags ---