From f4e42ba23d92f7a38ea12c6a76d3ec1845f4dd4d Mon Sep 17 00:00:00 2001 From: Yury Tsukerman Date: Mon, 13 Jul 2026 16:41:15 +0700 Subject: [PATCH] Improve ctx discovery and search --- crates/ctx-cli/src/commands/search.rs | 2 + crates/ctx-cli/src/main.rs | 11 ++++ crates/ctx-cli/src/mcp.rs | 1 + crates/ctx-cli/src/progress.rs | 6 ++ crates/ctx-cli/src/search_filters.rs | 9 ++- crates/ctx-cli/src/search_render.rs | 17 ++---- .../ctx-cli/tests/search_show_locate_sql.rs | 59 ++++++++++++++++--- .../src/provider/codex/catalog.rs | 10 +++- .../src/provider/codex/events.rs | 37 ++++++++++-- .../src/provider/codex/fast_import.rs | 10 +--- .../src/provider/codex/session.rs | 7 +-- .../src/provider_sources/discovery.rs | 23 ++++++++ .../src/provider_sources/specs.rs | 5 ++ .../provider_sources/tests/probe_status.rs | 27 +++++++++ crates/ctx-history-capture/src/tests/codex.rs | 28 +++++---- crates/ctx-history-search/src/filters.rs | 5 ++ crates/ctx-history-search/src/query.rs | 5 ++ crates/ctx-history-search/src/search.rs | 44 +++++++++++++- crates/ctx-history-search/src/tests/perf.rs | 2 + .../ctx-history-search/src/tests/support.rs | 1 + docs/getting-started.md | 55 +++++++++++++---- 21 files changed, 297 insertions(+), 67 deletions(-) diff --git a/crates/ctx-cli/src/commands/search.rs b/crates/ctx-cli/src/commands/search.rs index 585bacce6b..083abb40fb 100644 --- a/crates/ctx-cli/src/commands/search.rs +++ b/crates/ctx-cli/src/commands/search.rs @@ -270,6 +270,7 @@ pub(crate) fn run_search( primary_only: args.primary_only, include_subagents: args.include_subagents, event_type: args.event_type.clone(), + role: args.role.clone(), file: args.file.clone(), include_current_session: args.include_current_session, }, @@ -280,6 +281,7 @@ pub(crate) fn run_search( } else { ctx_history_search::SearchResultMode::Sessions }, + exact_phrase: args.exact_phrase, ..ctx_history_search::PacketOptions::default() }; let uses_composed_terms = args.term.iter().any(|term| !term.trim().is_empty()); diff --git a/crates/ctx-cli/src/main.rs b/crates/ctx-cli/src/main.rs index 3233dfabbb..b69dac858b 100644 --- a/crates/ctx-cli/src/main.rs +++ b/crates/ctx-cli/src/main.rs @@ -397,6 +397,17 @@ struct SearchArgs { help = "Filter by event type: message, tool_call, tool_output, command_started, command_output, command_finished, file_touched, vcs_change, artifact, summary, or notice" )] event_type: Option, + #[arg( + long, + help = "Filter message events by role: user, assistant, system, tool, or unknown" + )] + role: Option, + #[arg( + long = "exact-phrase", + alias = "exact", + help = "Require the query text to appear as one contiguous phrase in matched event text" + )] + exact_phrase: bool, #[arg( long, help = "Filter by indexed touched-file path metadata, not the current filesystem" diff --git a/crates/ctx-cli/src/mcp.rs b/crates/ctx-cli/src/mcp.rs index e6ba6630d2..1fb5d88d52 100644 --- a/crates/ctx-cli/src/mcp.rs +++ b/crates/ctx-cli/src/mcp.rs @@ -647,6 +647,7 @@ fn tool_search( primary_only, include_subagents, event_type, + role: None, file, include_current_session, }, diff --git a/crates/ctx-cli/src/progress.rs b/crates/ctx-cli/src/progress.rs index 0ecc767ede..03a7c9fdd0 100644 --- a/crates/ctx-cli/src/progress.rs +++ b/crates/ctx-cli/src/progress.rs @@ -40,6 +40,7 @@ struct ProgressState { started: Instant, last_emit: Option, last_line_len: usize, + last_plain_line: Option, } #[derive(Clone)] @@ -75,6 +76,7 @@ impl ProgressReporter { started: Instant::now(), last_emit: None, last_line_len: 0, + last_plain_line: None, })), } } @@ -342,6 +344,10 @@ impl ProgressReporter { let _ = std::io::stderr().flush(); } } else { + if line.done && state.last_plain_line.as_deref() == Some(rendered.as_str()) { + return; + } + state.last_plain_line = Some(rendered.clone()); eprintln!("{rendered}"); } } diff --git a/crates/ctx-cli/src/search_filters.rs b/crates/ctx-cli/src/search_filters.rs index 2015dc5d0c..c39b9af740 100644 --- a/crates/ctx-cli/src/search_filters.rs +++ b/crates/ctx-cli/src/search_filters.rs @@ -8,7 +8,7 @@ use std::{ use anyhow::{anyhow, Context, Result}; use chrono::{Duration, Utc}; -use ctx_history_core::{utc_now, CaptureProvider, EventType}; +use ctx_history_core::{utc_now, CaptureProvider, EventRole, EventType}; use ctx_history_store::Store; use crate::history_source_plugins::HistorySourcePluginSource; @@ -26,6 +26,7 @@ pub(crate) struct SearchFilterInput { pub(crate) primary_only: bool, pub(crate) include_subagents: bool, pub(crate) event_type: Option, + pub(crate) role: Option, pub(crate) file: Option, pub(crate) include_current_session: bool, } @@ -188,6 +189,12 @@ pub(crate) fn search_filters( .map(EventType::from_str) .transpose() .map_err(|err| anyhow!("{err}"))?, + role: input + .role + .as_deref() + .map(EventRole::from_str) + .transpose() + .map_err(|err| anyhow!("{err}"))?, file: input.file.and_then(|path| { let s = path.display().to_string(); if s.trim().is_empty() { diff --git a/crates/ctx-cli/src/search_render.rs b/crates/ctx-cli/src/search_render.rs index ec7decb78a..408fc12979 100644 --- a/crates/ctx-cli/src/search_render.rs +++ b/crates/ctx-cli/src/search_render.rs @@ -305,16 +305,13 @@ pub(crate) fn search_result_summary( { summary.push(format!("{provider_key}/{source_id}")); } + if let Some(source_format) = &result.source_format { + summary.push(source_format.clone()); + } if result.result_scope == ctx_history_search::SearchResultScope::Session { - summary.push(format!("importance {:.2}", result.session_importance)); + summary.push("session".to_owned()); } else { - summary.push(format!("rank {:.2}", result.rank)); - } - if let Some(session_id) = result.session_id { - summary.push(format!("session {}", short_uuid(session_id))); - } - if let Some(event_id) = result.event_id { - summary.push(format!("event {}", short_uuid(event_id))); + summary.push("event".to_owned()); } if let Some(timestamp) = result.timestamp { summary.push(timestamp.to_rfc3339()); @@ -322,10 +319,6 @@ pub(crate) fn search_result_summary( summary } -pub(crate) fn short_uuid(id: Uuid) -> String { - id.to_string().chars().take(8).collect() -} - pub(crate) fn search_inspect_command( result: &ctx_history_search::SearchPacketResult, ) -> Option { diff --git a/crates/ctx-cli/tests/search_show_locate_sql.rs b/crates/ctx-cli/tests/search_show_locate_sql.rs index 1cea569d76..488aa6498e 100644 --- a/crates/ctx-cli/tests/search_show_locate_sql.rs +++ b/crates/ctx-cli/tests/search_show_locate_sql.rs @@ -5,7 +5,8 @@ use support::*; #[test] fn search_defaults_to_exact_current_git_workspace_scope() { let temp = tempdir(); - let workspace_parent = temp.path().join("workspace-parent"); + let temp_root = temp.path().canonicalize().unwrap(); + let workspace_parent = temp_root.join("workspace-parent"); let workspace = workspace_parent.join("workspace"); init_git_workspace(&workspace); initialize_empty_store(&temp); @@ -72,7 +73,7 @@ fn search_defaults_to_exact_current_git_workspace_scope() { } let fixture = custom_history_fixture("basic.jsonl"); - let scoped_fixture = temp.path().join("scoped-history.jsonl"); + let scoped_fixture = temp_root.join("scoped-history.jsonl"); let body = fs::read_to_string(fixture) .unwrap() .lines() @@ -469,6 +470,44 @@ fn fresh_home_search_mvp_flow() { ])); assert_event_search_provider_oracle(&event_search, "codex", "onboarding", 1, "message"); + let user_role_search = json_output(ctx(&temp).args([ + "search", + "onboarding", + "--provider", + "codex", + "--events", + "--role", + "user", + "--json", + ])); + assert_eq!(user_role_search["filters"]["role"], "user"); + assert_event_search_provider_oracle(&user_role_search, "codex", "onboarding", 1, "message"); + + let exact_phrase_search = json_output(ctx(&temp).args([ + "search", + "Fix the onboarding bug", + "--provider", + "codex", + "--events", + "--exact-phrase", + "--json", + ])); + assert_eq!(exact_phrase_search["results"].as_array().unwrap().len(), 1); + + let reordered_exact_phrase_search = json_output(ctx(&temp).args([ + "search", + "onboarding Fix", + "--provider", + "codex", + "--events", + "--exact-phrase", + "--json", + ])); + assert!(reordered_exact_phrase_search["results"] + .as_array() + .unwrap() + .is_empty()); + let session_events = json_output(ctx(&temp).args([ "search", "onboarding", @@ -517,10 +556,11 @@ fn fresh_home_search_mvp_flow() { .clone(); let human_search = String::from_utf8(human_search).unwrap(); assert!(human_search.contains("1. ")); - assert!(human_search.contains("importance")); - assert!(human_search.contains("session ")); - assert!(human_search.contains("event ")); + assert!(human_search.contains("codex")); + assert!(human_search.contains("codex_session_jsonl")); assert!(human_search.contains("inspect: ctx show event")); + assert!(!human_search.contains("importance")); + assert!(!human_search.contains("rank ")); assert!(!human_search.contains("ctx_event_id")); assert!(!human_search.contains("provider_session_id")); assert!(!human_search.contains("next:")); @@ -1368,7 +1408,8 @@ fn search_normalizes_blank_file_and_rejects_blank_workspace() { #[test] fn pi_cli_imports_directory_tree_path() { let temp = tempdir(); - let path = temp.path().join("pi-sessions-dir"); + let temp_root = temp.path().canonicalize().unwrap(); + let path = temp_root.join("pi-sessions-dir"); let project = path.join("--workspace--"); fs::create_dir_all(&project).unwrap(); write_pi_session_jsonl( @@ -1406,7 +1447,8 @@ fn pi_cli_imports_directory_tree_path() { #[test] fn pi_cli_discovers_env_session_dir_for_sources_and_search_refresh() { let temp = tempdir(); - let path = temp.path().join("pi-env-sessions"); + let temp_root = temp.path().canonicalize().unwrap(); + let path = temp_root.join("pi-env-sessions"); let project = path.join("--workspace--"); fs::create_dir_all(&project).unwrap(); write_pi_session_jsonl( @@ -1577,7 +1619,8 @@ fn import_reports_unreadable_directory_with_path_context() { fn codex_cli_marks_deleted_raw_source_citations_unavailable() { let temp = tempdir(); let source = PathBuf::from(provider_history_fixture("codex-sessions")); - let copied = temp.path().join("copied-codex-sessions"); + let temp_root = temp.path().canonicalize().unwrap(); + let copied = temp_root.join("copied-codex-sessions"); copy_dir_all(&source, &copied); let copied_text = copied.to_str().unwrap().to_owned(); diff --git a/crates/ctx-history-capture/src/provider/codex/catalog.rs b/crates/ctx-history-capture/src/provider/codex/catalog.rs index f0e8d80303..026e8fd0fc 100644 --- a/crates/ctx-history-capture/src/provider/codex/catalog.rs +++ b/crates/ctx-history-capture/src/provider/codex/catalog.rs @@ -19,6 +19,7 @@ use crate::{ CaptureError, CatalogSummary, CodexSessionCatalogOptions, Result, CODEX_SESSION_SOURCE_FORMAT, }; +use crate::provider::codex::events::{codex_header_is_reviewer, codex_session_header}; use crate::provider::codex::session::{apply_codex_session_import_bounds, contains_bytes}; pub fn catalog_codex_session_tree( @@ -335,7 +336,13 @@ pub(crate) fn catalog_codex_session_file( }) .and_then(parse_rfc3339_utc) .map(|timestamp| timestamp.timestamp_millis()); - let agent_type = if parent_external_session_id.is_some() { + let parsed_header = session_meta + .clone() + .and_then(|value| codex_session_header(value).ok()); + let is_reviewer = parsed_header.as_ref().is_some_and(codex_header_is_reviewer); + let agent_type = if is_reviewer { + AgentType::Reviewer + } else if parent_external_session_id.is_some() { AgentType::Subagent } else { AgentType::Primary @@ -345,6 +352,7 @@ pub(crate) fn catalog_codex_session_file( .and_then(Value::as_str) .filter(|role| !role.trim().is_empty()) .map(str::to_owned) + .or_else(|| is_reviewer.then(|| "reviewer".to_owned())) .or_else(|| Some(agent_type.as_str().to_owned())); Ok(CatalogSession { diff --git a/crates/ctx-history-capture/src/provider/codex/events.rs b/crates/ctx-history-capture/src/provider/codex/events.rs index 3b3a0d415e..ca66a38bea 100644 --- a/crates/ctx-history-capture/src/provider/codex/events.rs +++ b/crates/ctx-history-capture/src/provider/codex/events.rs @@ -131,11 +131,20 @@ pub(crate) fn codex_session_capture( }), }); let is_subagent = header.parent_session.is_some(); + let is_reviewer = codex_header_is_reviewer(header); let role_hint = header .agent_role .clone() + .or_else(|| is_reviewer.then(|| "reviewer".to_owned())) .or_else(|| is_subagent.then(|| "subagent".to_owned())) .or_else(|| Some("primary".to_owned())); + let agent_type = if is_reviewer { + AgentType::Reviewer + } else if is_subagent { + AgentType::Subagent + } else { + AgentType::Primary + }; ProviderCaptureEnvelope { schema_version: PROVIDER_CAPTURE_ENVELOPE_SCHEMA_VERSION, @@ -166,13 +175,9 @@ pub(crate) fn codex_session_capture( parent_provider_session_id: header.parent_session.clone(), root_provider_session_id: header.parent_session.clone(), external_agent_id: header.agent_nickname.clone(), - agent_type: if is_subagent { - AgentType::Subagent - } else { - AgentType::Primary - }, + agent_type, role_hint, - is_primary: !is_subagent, + is_primary: agent_type == AgentType::Primary, status: SessionStatus::Imported, started_at: header.timestamp, ended_at: None, @@ -202,6 +207,26 @@ pub(crate) fn codex_session_capture( event, } } + +pub(crate) fn codex_header_is_reviewer(header: &CodexSessionHeader) -> bool { + [ + Some(header.id.as_str()), + header.agent_role.as_deref(), + header.agent_nickname.as_deref(), + header.originator.as_deref(), + ] + .into_iter() + .flatten() + .map(|value| value.to_ascii_lowercase()) + .any(|value| { + value.contains("reviewer") + || value.contains("review-agent") + || value.contains("review_agent") + || value + .split(|ch: char| !ch.is_alphanumeric()) + .any(|part| part == "review") + }) +} pub(crate) struct CodexSessionLineContext<'a> { pub(crate) line_number: usize, pub(crate) occurred_at: DateTime, diff --git a/crates/ctx-history-capture/src/provider/codex/fast_import.rs b/crates/ctx-history-capture/src/provider/codex/fast_import.rs index 07f6cdeffc..7ec133d4c2 100644 --- a/crates/ctx-history-capture/src/provider/codex/fast_import.rs +++ b/crates/ctx-history-capture/src/provider/codex/fast_import.rs @@ -214,14 +214,8 @@ pub(crate) fn import_codex_session_path_fast( .saturating_add(conversation_scan.oversized_events); return Ok(()); } - summary.failed += 1; - summary.failures.push(ProviderImportFailure { - line: 0, - error: codex_session_file_failure( - path, - "codex session JSONL contained no real message content", - ), - }); + summary.skipped += 1; + summary.skipped_sessions += 1; return Ok(()); } let file = File::open(path)?; diff --git a/crates/ctx-history-capture/src/provider/codex/session.rs b/crates/ctx-history-capture/src/provider/codex/session.rs index 0f2df6321a..8d482f8b30 100644 --- a/crates/ctx-history-capture/src/provider/codex/session.rs +++ b/crates/ctx-history-capture/src/provider/codex/session.rs @@ -193,11 +193,8 @@ impl ProviderCaptureAdapter for CodexSessionJsonlAdapter { result.captures.clear(); result.files_touched.clear(); if skipped_oversized_events == 0 && result.summary.failed == 0 { - result.summary.failed += 1; - result.summary.failures.push(ProviderImportFailure { - line: line_number, - error: "codex session JSONL contained no real message content".to_owned(), - }); + result.summary.skipped += 1; + result.summary.skipped_sessions += 1; } } diff --git a/crates/ctx-history-capture/src/provider_sources/discovery.rs b/crates/ctx-history-capture/src/provider_sources/discovery.rs index bd696e6d66..003ca5d642 100644 --- a/crates/ctx-history-capture/src/provider_sources/discovery.rs +++ b/crates/ctx-history-capture/src/provider_sources/discovery.rs @@ -80,6 +80,9 @@ fn discover_provider_sources_for_spec( )); } } + CaptureProvider::Claude => { + sources.extend(discover_claude_profile_sources(home, spec)); + } CaptureProvider::Pi => { sources.extend(discover_pi_custom_session_sources(home, spec)); } @@ -789,6 +792,26 @@ fn current_dir_ancestors_with(matches: impl Fn(&Path) -> bool) -> Vec { .collect() } +fn discover_claude_profile_sources(home: &Path, spec: &ProviderSourceSpec) -> Vec { + let profile_root = home.join(".claude-profiles"); + let Ok(entries) = fs::read_dir(&profile_root) else { + return Vec::new(); + }; + entries + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.is_dir()) + .map(|path| { + provider_source_from_parts( + spec, + path.join("projects"), + "claude_projects_jsonl_tree", + ProviderSourceKind::NativeHistory, + ) + }) + .collect() +} + fn dedupe_sources(sources: Vec) -> Vec { let mut seen = HashSet::new(); sources diff --git a/crates/ctx-history-capture/src/provider_sources/specs.rs b/crates/ctx-history-capture/src/provider_sources/specs.rs index 16c134d721..c77087f2a2 100644 --- a/crates/ctx-history-capture/src/provider_sources/specs.rs +++ b/crates/ctx-history-capture/src/provider_sources/specs.rs @@ -13,6 +13,11 @@ const CODEX_DEFAULTS: &[ProviderDefaultLocation] = &[ source_format: "codex_session_jsonl_tree", source_kind: ProviderSourceKind::NativeHistory, }, + ProviderDefaultLocation { + path_components: &[".codex", "archived_sessions"], + source_format: "codex_session_jsonl_tree", + source_kind: ProviderSourceKind::NativeHistory, + }, ProviderDefaultLocation { path_components: &[".codex", "history.jsonl"], source_format: "codex_history_jsonl", diff --git a/crates/ctx-history-capture/src/provider_sources/tests/probe_status.rs b/crates/ctx-history-capture/src/provider_sources/tests/probe_status.rs index df2490939d..bbdd90c20d 100644 --- a/crates/ctx-history-capture/src/provider_sources/tests/probe_status.rs +++ b/crates/ctx-history-capture/src/provider_sources/tests/probe_status.rs @@ -104,3 +104,30 @@ fn default_source_probe_skips_unreadable_child_directory() { assert_eq!(source.status, ProviderSourceStatus::Available); assert_eq!(source.unsupported_reason, None); } + +#[test] +fn default_discovery_includes_archived_codex_and_claude_profile_roots() { + let temp = tempfile::tempdir().unwrap(); + let archived = temp.path().join(".codex/archived_sessions"); + std::fs::create_dir_all(&archived).unwrap(); + std::fs::write(archived.join("archived-session.jsonl"), "{}\n").unwrap(); + + let profile_projects = temp.path().join(".claude-profiles/work/projects"); + std::fs::create_dir_all(&profile_projects).unwrap(); + std::fs::write(profile_projects.join("claude-session.jsonl"), "{}\n").unwrap(); + + let sources = discover_provider_sources(temp.path()); + let archived_source = sources + .iter() + .find(|source| source.provider == CaptureProvider::Codex && source.path == archived) + .unwrap_or_else(|| panic!("missing archived Codex source in {sources:#?}")); + assert_eq!(archived_source.status, ProviderSourceStatus::Available); + + let profile_source = sources + .iter() + .find(|source| { + source.provider == CaptureProvider::Claude && source.path == profile_projects + }) + .unwrap_or_else(|| panic!("missing Claude profile source in {sources:#?}")); + assert_eq!(profile_source.status, ProviderSourceStatus::Available); +} diff --git a/crates/ctx-history-capture/src/tests/codex.rs b/crates/ctx-history-capture/src/tests/codex.rs index b004e98fe7..fe831a6c2b 100644 --- a/crates/ctx-history-capture/src/tests/codex.rs +++ b/crates/ctx-history-capture/src/tests/codex.rs @@ -1057,9 +1057,13 @@ fn codex_session_jsonl_rejects_malformed_event_timestamp() { } #[test] -fn codex_session_jsonl_rejects_metadata_only_without_real_messages() { +fn codex_session_jsonl_skips_metadata_only_without_real_messages() { let temp = tempdir(); - let path = temp.path().join("metadata-only-codex.jsonl"); + let path = temp + .path() + .canonicalize() + .unwrap() + .join("metadata-only-codex.jsonl"); fs::write( &path, [ @@ -1100,17 +1104,19 @@ fn codex_session_jsonl_rejects_metadata_only_without_real_messages() { ) .unwrap(); - assert_eq!(summary.failed, 1, "{:?}", summary.failures); - assert!(summary.failures[0] - .error - .contains("no real message content")); + assert_eq!(summary.failed, 0, "{:?}", summary.failures); + assert_eq!(summary.skipped_sessions, 1); assert!(store.list_sessions().unwrap().is_empty()); } #[test] -fn codex_session_jsonl_fast_rejects_metadata_only_without_real_messages() { +fn codex_session_jsonl_fast_skips_metadata_only_without_real_messages() { let temp = tempdir(); - let path = temp.path().join("metadata-only-codex-fast.jsonl"); + let path = temp + .path() + .canonicalize() + .unwrap() + .join("metadata-only-codex-fast.jsonl"); fs::write( &path, [ @@ -1150,10 +1156,8 @@ fn codex_session_jsonl_fast_rejects_metadata_only_without_real_messages() { ) .unwrap(); - assert_eq!(summary.failed, 1, "{:?}", summary.failures); - assert!(summary.failures[0] - .error - .contains("no real message content")); + assert_eq!(summary.failed, 0, "{:?}", summary.failures); + assert_eq!(summary.skipped_sessions, 1); assert!(store.list_sessions().unwrap().is_empty()); assert!(store .search_event_hits("echo tool-only", 10) diff --git a/crates/ctx-history-search/src/filters.rs b/crates/ctx-history-search/src/filters.rs index 813839fd09..b0784b63b8 100644 --- a/crates/ctx-history-search/src/filters.rs +++ b/crates/ctx-history-search/src/filters.rs @@ -48,6 +48,11 @@ pub(crate) fn event_hit_matches_filters( return false; } } + if let Some(role) = filters.role { + if hit.role != Some(role) { + return false; + } + } if let Some(repo) = filters .repo .as_deref() diff --git a/crates/ctx-history-search/src/query.rs b/crates/ctx-history-search/src/query.rs index 7aa3a2411f..a3bd70c455 100644 --- a/crates/ctx-history-search/src/query.rs +++ b/crates/ctx-history-search/src/query.rs @@ -27,6 +27,7 @@ pub struct PacketOptions { pub snippet_chars: usize, pub filters: SearchFilters, pub result_mode: SearchResultMode, + pub exact_phrase: bool, } impl Default for PacketOptions { @@ -36,6 +37,7 @@ impl Default for PacketOptions { snippet_chars: DEFAULT_SNIPPET_CHARS, filters: SearchFilters::default(), result_mode: SearchResultMode::Sessions, + exact_phrase: false, } } } @@ -73,6 +75,8 @@ pub struct SearchFilters { #[serde(default, skip_serializing_if = "Option::is_none")] pub event_type: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub role: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub file: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub exclude_provider_session: Option, @@ -92,6 +96,7 @@ pub(crate) fn normalized_options(options: &PacketOptions) -> PacketOptions { snippet_chars: options.snippet_chars.clamp(32, 2_000), filters: options.filters.clone(), result_mode: options.result_mode, + exact_phrase: options.exact_phrase, } } diff --git a/crates/ctx-history-search/src/search.rs b/crates/ctx-history-search/src/search.rs index 2eb29d4754..639f0d0e96 100644 --- a/crates/ctx-history-search/src/search.rs +++ b/crates/ctx-history-search/src/search.rs @@ -46,6 +46,7 @@ pub fn search_packet(store: &Store, query: &str, options: &PacketOptions) -> Res let mut results = Vec::new(); push_candidate_results(&mut results, &candidates, query, &options); + retain_exact_phrase_results(&mut results, query, options.exact_phrase); let has_more = candidates.len() > results.len() || scan_budget_exhausted; if scan_budget_exhausted { @@ -94,7 +95,9 @@ pub fn semantic_event_search_packet( let page_size = FILTERED_SEARCH_PAGE_SIZE.max(target_results.saturating_mul(8).max(50)); let mut lexical_rank = 0_usize; for hit in store.search_event_hits_page(query, page_size, 0)? { - if !event_hit_matches_filters(&hit, &options.filters, file_scope) { + if !event_hit_matches_filters(&hit, &options.filters, file_scope) + || !event_hit_matches_exact_phrase(&hit, query, options.exact_phrase) + { continue; } lexical_rank = lexical_rank.saturating_add(1); @@ -116,7 +119,9 @@ pub fn semantic_event_search_packet( let mut semantic_rank = 0_usize; for semantic_hit in semantic_hits { let hit = &semantic_hit.hit; - if !event_hit_matches_filters(hit, &options.filters, file_scope) { + if !event_hit_matches_filters(hit, &options.filters, file_scope) + || !event_hit_matches_exact_phrase(hit, query, options.exact_phrase) + { continue; } semantic_rank = semantic_rank.saturating_add(1); @@ -199,6 +204,41 @@ pub fn semantic_event_search_packet( }) } +fn event_hit_matches_exact_phrase( + hit: &ctx_history_store::EventSearchHit, + query: &str, + exact_phrase: bool, +) -> bool { + if !exact_phrase { + return true; + } + normalized_phrase_contains(&hit.preview, query) +} + +fn retain_exact_phrase_results( + results: &mut Vec, + query: &str, + exact_phrase: bool, +) { + if !exact_phrase { + return; + } + results.retain(|result| normalized_phrase_contains(&result.snippet, query)); +} + +fn normalized_phrase_contains(haystack: &str, needle: &str) -> bool { + let needle = normalize_phrase_text(needle); + !needle.is_empty() && normalize_phrase_text(haystack).contains(&needle) +} + +fn normalize_phrase_text(value: &str) -> String { + value + .split_whitespace() + .collect::>() + .join(" ") + .to_lowercase() +} + pub fn search_packet_terms( store: &Store, query: &str, diff --git a/crates/ctx-history-search/src/tests/perf.rs b/crates/ctx-history-search/src/tests/perf.rs index c26580a7e5..565ba1d544 100644 --- a/crates/ctx-history-search/src/tests/perf.rs +++ b/crates/ctx-history-search/src/tests/perf.rs @@ -52,6 +52,7 @@ fn synthetic_search_perf_records_thresholded_evidence() { snippet_chars: 320, filters: SearchFilters::default(), result_mode: SearchResultMode::Sessions, + exact_phrase: false, }; let filtered_search_options = PacketOptions { limit: 24, @@ -64,6 +65,7 @@ fn synthetic_search_perf_records_thresholded_evidence() { ..SearchFilters::default() }, result_mode: SearchResultMode::Sessions, + exact_phrase: false, }; let search_warmup = search_packet(&store, "perfneedle", &search_options).unwrap(); diff --git a/crates/ctx-history-search/src/tests/support.rs b/crates/ctx-history-search/src/tests/support.rs index a4ce489106..7d94ff37df 100644 --- a/crates/ctx-history-search/src/tests/support.rs +++ b/crates/ctx-history-search/src/tests/support.rs @@ -103,6 +103,7 @@ pub(super) fn maybe_write_synthetic_search_smoke_artifact() { snippet_chars: 180, filters: SearchFilters::default(), result_mode: SearchResultMode::Sessions, + exact_phrase: false, }; let search_started = std::time::Instant::now(); let search = search_packet(&store, "syntheticneedle", &options).unwrap(); diff --git a/docs/getting-started.md b/docs/getting-started.md index d01e651d48..19624216d9 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -12,18 +12,49 @@ git checkout cargo install --locked --path crates/ctx-cli ``` -Use Rust 1.88 or newer. The same source-build commands work on Windows from a -Git and Rust-enabled terminal. Do not pipe remote scripts into a shell; pin and -review the fork commit or tag before building. - -Then install the bundled agent skill explicitly: - -```bash -ctx integrations install skills -``` - -See [Source Builds And Upgrades](unmanaged-installs.md) for the fork's manual -upgrade process. +The Unix installer requires `curl` and OpenSSL to verify signed release +metadata. On Windows, use `irm https://ctx.rs/install.ps1 | iex`. + +On Unix, the installer places `ctx` in `${CTX_BIN_DIR:-$HOME/.local/bin}`. If +that directory is not already on `PATH`, the installer adds an idempotent ctx +PATH snippet to your shell startup file and prints the command to use for the +current shell session. On Windows, the installer places `ctx.exe` in +`$HOME\.local\bin` by default, adds that directory to the user `Path`, and +updates the current PowerShell session. Use `sh -s -- --no-modify-path` on Unix, +`-NoModifyPath` on Windows, or set `CTX_INSTALL_NO_MODIFY_PATH=1` when you want +to manage `PATH` yourself. + +The install script installs `ctx`, runs the bundled agent-history skill +installer, and runs `ctx setup` so discovered local history is indexed before it +exits. If daemon maintenance has been explicitly enabled, that setup run can +also start the ctx-owned background daemon for native-history freshness and +semantic catch-up. The skill installer opens an agent picker when interactive; +otherwise it installs the universal `~/.agents/skills` copy plus detected +agent-specific folders for tools that need them. Use `sh -s -- --no-setup` on +Unix, or set `CTX_INSTALL_NO_SETUP=1` on Windows, for install-only CI or +packaging flows. Install-only mode also skips skill setup unless you explicitly +pass a skill option. + +To keep installer setup but opt out of setup daemon autostart, use +`sh -s -- --no-daemon` on Unix, `-NoDaemon` on Windows, or set +`CTX_INSTALL_NO_DAEMON=1`. + +To skip only the skill step, use `--no-skill` on Unix or `-NoSkill` on Windows, +or set `CTX_INSTALL_NO_SKILL=1`. To target agent-specific skill folders during +install, use `--skill-agent codex`, repeat `--skill-agent`, or use +`--all-skill-agents`; Windows exposes the same controls as `-SkillAgent` and +`-AllSkillAgents`. + +When working from source, use `cargo build -p ctx` or +`cargo install --path crates/ctx-cli`. + +When validating a local release binary or installer metadata, run +`bash scripts/install-path-smoke.sh`. The smoke test verifies the installer +places `ctx` on `PATH` for supported shell and CI paths instead of relying on an +ad hoc manually built binary. + +For GitHub release binaries, mise, Homebrew, and source builds, see +[Package Managers And Unmanaged Installs](unmanaged-installs.md). ## 2. Set Up And Index