Skip to content
Merged
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
2 changes: 2 additions & 0 deletions crates/ctx-cli/src/commands/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand All @@ -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());
Expand Down
11 changes: 11 additions & 0 deletions crates/ctx-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
#[arg(
long,
help = "Filter message events by role: user, assistant, system, tool, or unknown"
)]
role: Option<String>,
#[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"
Expand Down
1 change: 1 addition & 0 deletions crates/ctx-cli/src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -647,6 +647,7 @@ fn tool_search(
primary_only,
include_subagents,
event_type,
role: None,
file,
include_current_session,
},
Expand Down
6 changes: 6 additions & 0 deletions crates/ctx-cli/src/progress.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ struct ProgressState {
started: Instant,
last_emit: Option<Instant>,
last_line_len: usize,
last_plain_line: Option<String>,
}

#[derive(Clone)]
Expand Down Expand Up @@ -75,6 +76,7 @@ impl ProgressReporter {
started: Instant::now(),
last_emit: None,
last_line_len: 0,
last_plain_line: None,
})),
}
}
Expand Down Expand Up @@ -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}");
}
}
Expand Down
9 changes: 8 additions & 1 deletion crates/ctx-cli/src/search_filters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -26,6 +26,7 @@ pub(crate) struct SearchFilterInput {
pub(crate) primary_only: bool,
pub(crate) include_subagents: bool,
pub(crate) event_type: Option<String>,
pub(crate) role: Option<String>,
pub(crate) file: Option<PathBuf>,
pub(crate) include_current_session: bool,
}
Expand Down Expand Up @@ -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() {
Expand Down
17 changes: 5 additions & 12 deletions crates/ctx-cli/src/search_render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -305,27 +305,20 @@ 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());
}
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<String> {
Expand Down
59 changes: 51 additions & 8 deletions crates/ctx-cli/tests/search_show_locate_sql.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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:"));
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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();

Expand Down
10 changes: 9 additions & 1 deletion crates/ctx-history-capture/src/provider/codex/catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down
37 changes: 31 additions & 6 deletions crates/ctx-history-capture/src/provider/codex/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<Utc>,
Expand Down
10 changes: 2 additions & 8 deletions crates/ctx-history-capture/src/provider/codex/fast_import.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?;
Expand Down
7 changes: 2 additions & 5 deletions crates/ctx-history-capture/src/provider/codex/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand Down
Loading
Loading