Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 81 additions & 8 deletions cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf>,
/// Only sessions on this git branch (exact match)
#[arg(long, value_name = "BRANCH")]
git_branch: Option<String>,
/// Only sessions that used this model (case-insensitive substring)
#[arg(long, value_name = "MODEL")]
model: Option<String>,
/// 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<chrono::DateTime<chrono::Utc>>,
/// 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<chrono::DateTime<chrono::Utc>>,
},
}

Expand Down Expand Up @@ -402,7 +416,21 @@ pub fn run_session(command: SessionCommand, options: &Options) -> Result<ExitCod
with,
from,
cwd,
} => 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,
),
}
}

Expand Down Expand Up @@ -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<HarnessId>,
cwd: Option<&Path>,
git_branch: Option<&str>,
model: Option<&str>,
since: Option<chrono::DateTime<chrono::Utc>>,
until: Option<chrono::DateTime<chrono::Utc>>,
cache: Option<&Path>,
) -> Result<Index, String> {
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
Expand All @@ -2175,14 +2208,19 @@ mod query {
q
}

#[allow(clippy::too_many_arguments)]
pub(super) fn cmd_query(
pattern: Option<String>,
with: Option<HarnessId>,
from: Option<HarnessId>,
cwd: Option<&Path>,
git_branch: Option<&str>,
model: Option<&str>,
since: Option<chrono::DateTime<chrono::Utc>>,
until: Option<chrono::DateTime<chrono::Utc>>,
cache: Option<&Path>,
) -> Result<std::process::ExitCode, String> {
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() {
Expand Down Expand Up @@ -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
Expand All @@ -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<HarnessId>,
cwd: Option<&Path>,
git_branch: Option<&str>,
model: Option<&str>,
since: Option<chrono::DateTime<chrono::Utc>>,
until: Option<chrono::DateTime<chrono::Utc>>,
cache: Option<&Path>,
) -> Result<(Index, Sessions), String> {
let found = super::discover_with_spinner(from)?;
Expand All @@ -2251,10 +2296,38 @@ mod query {
} else {
HashSet::new()
};
let scoped: Vec<local::Session> = found
.into_iter()
.filter(|session| super::selected(session, from, cwd))
.collect();
let scoped: Vec<local::Session> =
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
Expand Down
38 changes: 35 additions & 3 deletions cli/src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// Only sessions on this git branch (exact match). Omit to include all
/// branches.
git_branch: Option<String>,
/// Only sessions that used this model (case-insensitive substring).
/// Omit to include all models.
model: Option<String>,
/// 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<String>,
/// 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<String>,
}

#[derive(Debug, Deserialize, JsonSchema)]
Expand Down Expand Up @@ -291,7 +303,7 @@ impl SessionServer {
/// Search local session content with the same matching, harness, and
/// working-directory behavior as `txcript query <pattern>`.
#[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(
Expand All @@ -300,8 +312,28 @@ impl SessionServer {
) -> Result<Json<SearchResults>, 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);
Expand Down
11 changes: 9 additions & 2 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,16 @@ txcript query # interactive picker; Enter continues
[--from <harness>] # search only <harness> (default: all)
[--with <harness>] # continue the pick in <harness>
[--cwd <dir>] # only sessions recorded under <dir>
[--git-branch <branch>] # only sessions on this branch (exact)
[--model <substr>] # only sessions using this model (case-insensitive substring)
[--since <when>] # only sessions started at or after <when>
[--until <when>] # only sessions started at or before <when>
```

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 <path>` (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.
Expand All @@ -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?)`

<sub>\* Omitting `from` includes every harness; omitting `cwd` applies no directory filter. Sessions without a recorded working directory match only when `cwd` is omitted.</sub>
<sub>\* 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.</sub>

`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
Expand Down
73 changes: 69 additions & 4 deletions src/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -122,6 +124,21 @@ pub struct Query {
skip_serializing_if = "Option::is_none"
)]
pub hits_per_doc: Option<usize>,
/// Only sessions recorded in or under this working directory.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
/// Only sessions recorded on this git branch.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub git_branch: Option<String>,
/// Only sessions recorded with this model (case-insensitive substring match).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
/// Only sessions started on or after this timestamp.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub since: Option<DateTime<Utc>>,
/// Only sessions started on or before this timestamp.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub until: Option<DateTime<Utc>>,
}

#[allow(clippy::unnecessary_wraps)] // serde default for an Option field
Expand Down Expand Up @@ -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,
}
}

Expand Down Expand Up @@ -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)>);
Expand Down
Loading
Loading