diff --git a/cli/src/lib.rs b/cli/src/lib.rs index 4bb7309..aaaa8ee 100644 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -269,6 +269,20 @@ pub enum SessionCommand { /// Only sessions recorded in or under this working directory #[arg(long, value_name = "DIR", value_hint = clap::ValueHint::DirPath)] cwd: Option, + /// Only sessions on this git branch (exact match) + #[arg(long, value_name = "BRANCH")] + git_branch: Option, + /// Only sessions that used this model (case-insensitive substring) + #[arg(long, value_name = "MODEL")] + model: Option, + /// Only sessions started at or after this time (RFC3339 or + /// YYYY-MM-DD, a bare date meaning that local midnight) + #[arg(long, value_name = "WHEN", value_parser = parse_since)] + since: Option>, + /// Only sessions started at or before this time (RFC3339 or + /// YYYY-MM-DD, a bare date meaning the end of that local day) + #[arg(long, value_name = "WHEN", value_parser = parse_until)] + until: Option>, }, } @@ -402,7 +416,21 @@ pub fn run_session(command: SessionCommand, options: &Options) -> Result query::cmd_query(pattern, with, from, cwd.as_deref(), cache), + git_branch, + model, + since, + until, + } => query::cmd_query( + pattern, + with, + from, + cwd.as_deref(), + git_branch.as_deref(), + model.as_deref(), + since, + until, + cache, + ), } } @@ -2157,12 +2185,17 @@ mod query { /// Build the same filtered index used by the CLI for the MCP search tool. #[cfg(feature = "mcp")] + #[allow(clippy::too_many_arguments)] pub(super) fn index_for( from: Option, cwd: Option<&Path>, + git_branch: Option<&str>, + model: Option<&str>, + since: Option>, + until: Option>, cache: Option<&Path>, ) -> Result { - build_index(from, cwd, cache).map(|(index, _)| index) + build_index(from, cwd, git_branch, model, since, until, cache).map(|(index, _)| index) } /// The query behind `txcript query` and the MCP search tool: the pattern @@ -2175,14 +2208,19 @@ mod query { q } + #[allow(clippy::too_many_arguments)] pub(super) fn cmd_query( pattern: Option, with: Option, from: Option, cwd: Option<&Path>, + git_branch: Option<&str>, + model: Option<&str>, + since: Option>, + until: Option>, cache: Option<&Path>, ) -> Result { - let (index, sessions) = build_index(from, cwd, cache)?; + let (index, sessions) = build_index(from, cwd, git_branch, model, since, until, cache)?; match pattern { Some(pattern) => { if with.is_some() { @@ -2213,7 +2251,9 @@ mod query { } /// Build the search index and session lookup over every local session - /// passing the `from`/`cwd` filters. + /// passing the `from`/`cwd` filters, and applying the optional + /// `git_branch`, `model`, `since`, and `until` metadata filters so that + /// only matching sessions are indexed and returned. /// /// Sessions parse and extract on every core: workers pull the next /// undrained session, parse it, extract its searchable lines, and send @@ -2230,9 +2270,14 @@ mod query { /// # Errors /// Returns an error when an explicitly selected live store cannot be /// discovered or read. + #[allow(clippy::too_many_arguments, clippy::too_many_lines)] pub fn build_index( from: Option, cwd: Option<&Path>, + git_branch: Option<&str>, + model: Option<&str>, + since: Option>, + until: Option>, cache: Option<&Path>, ) -> Result<(Index, Sessions), String> { let found = super::discover_with_spinner(from)?; @@ -2251,10 +2296,38 @@ mod query { } else { HashSet::new() }; - let scoped: Vec = found - .into_iter() - .filter(|session| super::selected(session, from, cwd)) - .collect(); + let scoped: Vec = + found + .into_iter() + .filter(|session| { + if !super::selected(session, from, cwd) { + return false; + } + if let Some(branch) = git_branch + && session.meta.git_branch.as_deref() != Some(branch) + { + return false; + } + if let Some(model_filter) = model + && !session.meta.model.as_deref().is_some_and(|m| { + m.to_lowercase().contains(&model_filter.to_lowercase()) + }) + { + return false; + } + if let Some(since) = since + && session.meta.timestamp < since + { + return false; + } + if let Some(until) = until + && session.meta.timestamp > until + { + return false; + } + true + }) + .collect(); let total = scoped.len(); // Cursors for the cache check. Empty cursors never hit, so a session diff --git a/cli/src/mcp.rs b/cli/src/mcp.rs index 2d5fbd0..7cbb343 100644 --- a/cli/src/mcp.rs +++ b/cli/src/mcp.rs @@ -50,6 +50,18 @@ struct SearchSessionsRequest { /// Omit to search every directory. Sessions without a recorded cwd are /// excluded when this filter is present. cwd: Option, + /// Only sessions on this git branch (exact match). Omit to include all + /// branches. + git_branch: Option, + /// Only sessions that used this model (case-insensitive substring). + /// Omit to include all models. + model: Option, + /// Only sessions started at or after this time. RFC3339 string, e.g. + /// `2025-01-01T00:00:00Z` or bare date `2025-01-01` (UTC midnight). + since: Option, + /// Only sessions started at or before this time. RFC3339 string, e.g. + /// `2025-12-31T23:59:59Z` or bare date `2025-12-31` (end of UTC day). + until: Option, } #[derive(Debug, Deserialize, JsonSchema)] @@ -291,7 +303,7 @@ impl SessionServer { /// Search local session content with the same matching, harness, and /// working-directory behavior as `txcript query `. #[tool( - description = "Search local coding-agent sessions for a literal, case-insensitive pattern: it must appear in a line exactly as written, spaces included. Optional `from` and `cwd` filters match the txcript CLI; omitted filters search all harnesses or directories.", + description = "Search local coding-agent sessions for a literal, case-insensitive pattern: it must appear in a line exactly as written, spaces included. Optional `from`, `cwd`, `git_branch`, `model`, `since`, and `until` filters match the txcript CLI flags; omitted filters search all sessions.", annotations(title = "Search sessions", read_only_hint = true) )] fn search_sessions( @@ -300,8 +312,28 @@ impl SessionServer { ) -> Result, ErrorData> { let from = parse_from(request.from.as_deref())?; let cwd = request.cwd.as_deref().map(Path::new); - let index = super::query::index_for(from, cwd, self.cache.as_deref()) - .map_err(|error| ErrorData::internal_error(error, None))?; + let since = request + .since + .as_deref() + .map(super::parse_since) + .transpose() + .map_err(|e| ErrorData::invalid_params(format!("`since`: {e}"), None))?; + let until = request + .until + .as_deref() + .map(super::parse_until) + .transpose() + .map_err(|e| ErrorData::invalid_params(format!("`until`: {e}"), None))?; + let index = super::query::index_for( + from, + cwd, + request.git_branch.as_deref(), + request.model.as_deref(), + since, + until, + self.cache.as_deref(), + ) + .map_err(|error| ErrorData::internal_error(error, None))?; let mut query = super::query::user_query(&request.pattern); // Match the CLI's one-shot output bounds. query.limit = Some(20); diff --git a/docs/usage.md b/docs/usage.md index 8d52555..cfa68b4 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -65,10 +65,16 @@ txcript query # interactive picker; Enter continues [--from ] # search only (default: all) [--with ] # continue the pick in [--cwd ] # only sessions recorded under + [--git-branch ] # only sessions on this branch (exact) + [--model ] # only sessions using this model (case-insensitive substring) + [--since ] # only sessions started at or after + [--until ] # only sessions started at or before ``` A pattern matches literally and case-insensitively: `relay bug` finds lines containing that exact text, spaces and all. +`--since` / `--until` accept RFC3339 timestamps (`2025-06-01T00:00:00Z`) or bare dates (`2025-06-01`); bare dates mean local midnight (for `--since`) or end of local day (for `--until`). + In the picker, type to filter, arrows / ctrl-p/n to move, Enter to continue the selection in its own harness (or `--with`), Esc to cancel. Every row shows which kind of content matched: user text, assistant text, thinking, tool use, tool output, or session metadata. Without a cache, every run re-reads every session. Pass `--cache ` (or set `TXCRIPT_CACHE`) to keep a persistent search cache at that path, so `query` and the MCP search tool re-read only the sessions that changed since the last run. The flag is accepted by every subcommand. @@ -82,13 +88,14 @@ txcript mcp # stdio transport Exposes three read-only tools; their optional filters match the CLI: - `list_sessions(from?, cwd?, limit?, offset?)` -- `search_sessions(pattern, from?, cwd?)` +- `search_sessions(pattern, from?, cwd?, git_branch?, model?, since?, until?)` - `read_session(id, from?)` -\* Omitting `from` includes every harness; omitting `cwd` applies no directory filter. Sessions without a recorded working directory match only when `cwd` is omitted. +\* Omitting `from` includes every harness; omitting `cwd` applies no directory filter. Sessions without a recorded working directory match only when `cwd` is omitted. `git_branch` is exact; `model` is a case-insensitive substring; `since`/`until` are RFC3339 strings or bare dates. `list_sessions` pages with `limit` and `offset` and reports the total before paging; the live Claude Chat and ChatGPT sources are never listed. `read_session` takes the same `#range` suffix as `view` and returns the same compact text; a read too large to return whole is refused with suggested sub-ranges. `--cache` applies to the server too. + ### Shell integration ```sh diff --git a/src/search.rs b/src/search.rs index 6858914..dd65e8e 100644 --- a/src/search.rs +++ b/src/search.rs @@ -18,7 +18,9 @@ use std::collections::HashMap; use std::fmt; use std::ops::Range; +use std::path::Path; +use chrono::{DateTime, Utc}; use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern}; use nucleo_matcher::{Config, Matcher, Utf32Str, Utf32String}; use serde::{Deserialize, Serialize}; @@ -122,6 +124,21 @@ pub struct Query { skip_serializing_if = "Option::is_none" )] pub hits_per_doc: Option, + /// Only sessions recorded in or under this working directory. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option, + /// Only sessions recorded on this git branch. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub git_branch: Option, + /// Only sessions recorded with this model (case-insensitive substring match). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + /// Only sessions started on or after this timestamp. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub since: Option>, + /// Only sessions started on or before this timestamp. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub until: Option>, } #[allow(clippy::unnecessary_wraps)] // serde default for an Option field @@ -158,6 +175,11 @@ impl Query { harnesses: None, limit: None, hits_per_doc: default_hits_per_doc(), + cwd: None, + git_branch: None, + model: None, + since: None, + until: None, } } @@ -575,13 +597,56 @@ impl Index { impl Doc { fn selected(&self, query: &Query) -> bool { - query - .harnesses - .as_ref() - .is_none_or(|hs| hs.contains(&self.key.harness)) + if let Some(hs) = &query.harnesses + && !hs.contains(&self.key.harness) + { + return false; + } + if let Some(dir) = &query.cwd { + let Some(cwd) = self.meta.cwd.as_deref() else { + return false; + }; + if !path_under(cwd, dir) { + return false; + } + } + if let Some(branch) = &query.git_branch { + let Some(doc_branch) = self.meta.git_branch.as_deref() else { + return false; + }; + if doc_branch != branch { + return false; + } + } + if let Some(model) = &query.model { + let Some(doc_model) = self.meta.model.as_deref() else { + return false; + }; + if !doc_model.to_lowercase().contains(&model.to_lowercase()) { + return false; + } + } + if let Some(since) = query.since + && self.meta.timestamp < since + { + return false; + } + if let Some(until) = query.until + && self.meta.timestamp > until + { + return false; + } + true } } +fn path_under(session_cwd: &str, dir: &str) -> bool { + let p_cwd = Path::new(session_cwd); + let p_dir = Path::new(dir); + let canon = |p: &Path| p.canonicalize().unwrap_or_else(|_| p.to_path_buf()); + canon(p_cwd).starts_with(canon(p_dir)) +} + /// Pass-1 result for one document: its index, best line score, and each /// matched line with its score. type Scored = (usize, u32, Vec<(usize, u32)>); diff --git a/tests/integration/search.rs b/tests/integration/search.rs index 699d8b8..d9ef135 100644 --- a/tests/integration/search.rs +++ b/tests/integration/search.rs @@ -412,3 +412,133 @@ fn fragment_covers_ranges_and_rejects_out_of_bounds() { assert_eq!(all, t.body.as_slice()); assert_eq!(t.fragment(&Span(0..t.body.len() + 1)), None); } + +// ── multi-criteria filter tests ───────────────────────────────────────────── + +/// Build a two-document index: "alpha" on branch `main`, "beta" on `dev`. +/// The common search pattern "needle" appears in both. +fn two_doc_index() -> Index { + let make = |id: &str, branch: &str, model: Option<&str>, secs: i64| { + let mut m = meta(id, secs); + m.git_branch = Some(branch.to_string()); + m.model = model.map(str::to_string); + Transcript::new(m, vec![message(Role::User, vec![text("needle content")])]) + }; + let mut index = Index::new(); + index.insert( + key(HarnessId::ClaudeCode, "alpha"), + &make("alpha", "main", Some("claude-3-5-sonnet"), 0), + ); + index.insert( + key(HarnessId::ClaudeCode, "beta"), + &make("beta", "dev", Some("gpt-4o"), 3600), + ); + index +} + +#[test] +fn query_filter_git_branch_exact_match() { + let index = two_doc_index(); + + let mut q = Query::substring("needle"); + q.git_branch = Some("main".to_string()); + let hits = index.query(&q); + assert_eq!(hits.len(), 1, "only the 'main' branch session"); + assert_eq!(hits[0].key.id, "alpha"); + + // Unknown branch → no hits. + q.git_branch = Some("nonexistent".to_string()); + assert!(index.query(&q).is_empty()); + + // No filter → both sessions. + q.git_branch = None; + assert_eq!(index.query(&q).len(), 2); +} + +#[test] +fn query_filter_model_case_insensitive_substring() { + let index = two_doc_index(); + + // "sonnet" is a substring of "claude-3-5-sonnet". + let mut q = Query::substring("needle"); + q.model = Some("SONNET".to_string()); + let hits = index.query(&q); + assert_eq!(hits.len(), 1, "only the claude sonnet session"); + assert_eq!(hits[0].key.id, "alpha"); + + // "gpt" matches the beta session. + q.model = Some("gpt".to_string()); + let hits = index.query(&q); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].key.id, "beta"); + + // No filter → both. + q.model = None; + assert_eq!(index.query(&q).len(), 2); +} + +#[test] +fn query_filter_since_and_until() { + let index = two_doc_index(); + + // Base timestamp is 1_780_000_000; beta is +3600 s later. + let base = Utc.timestamp_opt(1_780_000_000, 0).single().unwrap(); + let after_alpha = Utc.timestamp_opt(1_780_001_000, 0).single().unwrap(); + let after_beta = Utc.timestamp_opt(1_780_010_000, 0).single().unwrap(); + + let mut q = Query::substring("needle"); + + // since=after_alpha only includes beta. + q.since = Some(after_alpha); + let hits = index.query(&q); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].key.id, "beta"); + + // until=base only includes alpha (beta is strictly after). + q.since = None; + q.until = Some(base); + let hits = index.query(&q); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].key.id, "alpha"); + + // since and until together can produce zero results. + q.since = Some(after_beta); + q.until = Some(after_beta); + assert!(index.query(&q).is_empty()); + + // No filter → both. + q.since = None; + q.until = None; + assert_eq!(index.query(&q).len(), 2); +} + +#[test] +fn query_filter_cwd_path_prefix() { + // The meta helper sets cwd = "/work/replay"; make a second with a sibling dir. + let t_a = Transcript::new( + meta("a", 0), + vec![message(Role::User, vec![text("needle content")])], + ); + let mut m_b = meta("b", 0); + m_b.cwd = Some("/work/other".to_string()); + let t_b = Transcript::new(m_b, vec![message(Role::User, vec![text("needle content")])]); + + let mut index = Index::new(); + index.insert(key(HarnessId::ClaudeCode, "a"), &t_a); + index.insert(key(HarnessId::ClaudeCode, "b"), &t_b); + + // "/work/replay" prefix: only session a. + let mut q = Query::substring("needle"); + q.cwd = Some("/work/replay".to_string()); + let hits = index.query(&q); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].key.id, "a"); + + // "/work" is a parent: both sessions are under it. + q.cwd = Some("/work".to_string()); + assert_eq!(index.query(&q).len(), 2); + + // No filter → both. + q.cwd = None; + assert_eq!(index.query(&q).len(), 2); +}