From 078b1a676242936c131b5babf2e71da27caf217a Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 4 Jul 2026 03:53:54 +0000 Subject: [PATCH] feat(sessions): add date filters to recall search --- src/agents/hermes/templates/plugin_init.py | 12 +- src/cli.rs | 6 + src/cli/parse_tests.rs | 25 ++++ src/global_db.rs | 56 +++++---- src/mcp/tools/definitions.rs | 46 +++++++- src/mcp/tools/handlers/session.rs | 79 ++++++++++--- src/sessions/mod.rs | 25 ++++ src/sessions_cmd.rs | 59 +++++++++- src/timeutil.rs | 122 +++++++++++++++++++ tests/agent_suite/agent_test.rs | 2 +- tests/mcp_suite/mcp_handler_test.rs | 130 +++++++++++++++++++++ tests/session_suite/global_db.rs | 73 +++++++++++- tests/transcript_ingest_suite/cursor.rs | 9 +- 13 files changed, 585 insertions(+), 59 deletions(-) diff --git a/src/agents/hermes/templates/plugin_init.py b/src/agents/hermes/templates/plugin_init.py index 88c03d81a..45751f4e0 100644 --- a/src/agents/hermes/templates/plugin_init.py +++ b/src/agents/hermes/templates/plugin_init.py @@ -156,11 +156,19 @@ def _resolve_auxiliary_client(agent=None): }, "time_from": { "anyOf": [{"type": "number"}, {"type": "string"}], - "description": "Optional inclusive minimum raw-message timestamp.", + "description": "Optional inclusive minimum raw-message timestamp. Accepts Unix seconds, RFC3339, YYYY-MM-DD, or relative time like 'last hour'.", }, "time_to": { "anyOf": [{"type": "number"}, {"type": "string"}], - "description": "Optional inclusive maximum raw-message timestamp.", + "description": "Optional inclusive maximum raw-message timestamp. Accepts Unix seconds, RFC3339, YYYY-MM-DD, or relative time like 'last hour'.", + }, + "since": { + "anyOf": [{"type": "number"}, {"type": "string"}], + "description": "Alias for time_from.", + }, + "until": { + "anyOf": [{"type": "number"}, {"type": "string"}], + "description": "Alias for time_to.", }, }, "required": ["query"], diff --git a/src/cli.rs b/src/cli.rs index 79666190e..68243da0c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -624,6 +624,12 @@ pub enum SessionsAction { /// Maximum number of matches #[arg(long, default_value_t = 10)] limit: usize, + /// Inclusive minimum message timestamp. Accepts Unix seconds, RFC3339, YYYY-MM-DD, or relative time like "last hour" + #[arg(long, alias = "time-from", alias = "start-time")] + since: Option, + /// Inclusive maximum message timestamp. Accepts Unix seconds, RFC3339, YYYY-MM-DD, or relative time like "last hour" + #[arg(long, alias = "time-to", alias = "end-time")] + until: Option, /// Registered project id whose session store should be searched #[arg(long)] project_id: Option, diff --git a/src/cli/parse_tests.rs b/src/cli/parse_tests.rs index 6c16a5a17..314b2e7b6 100644 --- a/src/cli/parse_tests.rs +++ b/src/cli/parse_tests.rs @@ -1550,6 +1550,8 @@ fn parses_sessions_ingest_and_search_commands() { limit, project_id, project_path, + since, + until, }, }) => { assert_eq!(query, "needle"); @@ -1557,6 +1559,29 @@ fn parses_sessions_ingest_and_search_commands() { assert_eq!(limit, 5); assert!(project_id.is_none()); assert!(project_path.is_none()); + assert!(since.is_none()); + assert!(until.is_none()); + } + _ => panic!("expected sessions search command"), + } + + let time_filtered_search = Cli::try_parse_from([ + "tracedecay", + "sessions", + "search", + "needle", + "--since", + "last hour", + "--until", + "2026-07-04T00:00:00Z", + ]) + .unwrap(); + match time_filtered_search.command { + Some(Commands::Sessions { + action: SessionsAction::Search { since, until, .. }, + }) => { + assert_eq!(since.as_deref(), Some("last hour")); + assert_eq!(until.as_deref(), Some("2026-07-04T00:00:00Z")); } _ => panic!("expected sessions search command"), } diff --git a/src/global_db.rs b/src/global_db.rs index a34f9f9b1..9fe92d166 100644 --- a/src/global_db.rs +++ b/src/global_db.rs @@ -16,7 +16,7 @@ use crate::sessions::{ LcmSourceRef, LcmSummaryNode, LcmSummaryNodeDraft, LcmSummaryRequest, LcmSummarySourceMessage, LcmSummarySourceRange, }, - SessionMessageRecord, SessionMessageSearchResult, SessionRecord, SessionSearchScope, + SessionMessageRecord, SessionMessageSearchResult, SessionRecord, SessionSearchFilters, }; const UNIX_TIMESTAMP_MILLIS_THRESHOLD: i64 = 1_000_000_000_000; @@ -3178,8 +3178,7 @@ impl GlobalDb { project_key, query, limit, - SessionSearchScope::All, - None, + SessionSearchFilters::default(), ) .await } @@ -3191,16 +3190,14 @@ impl GlobalDb { project_key: Option<&str>, query: &str, limit: usize, - scope: SessionSearchScope, - parent_session_id: Option<&str>, + filters: SessionSearchFilters<'_>, ) -> Vec { self.search_session_messages_filtered_inner( Some(provider), project_key, query, limit, - scope, - parent_session_id, + filters, ) .await } @@ -3211,18 +3208,10 @@ impl GlobalDb { project_key: Option<&str>, query: &str, limit: usize, - scope: SessionSearchScope, - parent_session_id: Option<&str>, + filters: SessionSearchFilters<'_>, ) -> Vec { - self.search_session_messages_filtered_inner( - None, - project_key, - query, - limit, - scope, - parent_session_id, - ) - .await + self.search_session_messages_filtered_inner(None, project_key, query, limit, filters) + .await } async fn search_session_messages_filtered_inner( @@ -3231,8 +3220,7 @@ impl GlobalDb { project_key: Option<&str>, query: &str, limit: usize, - scope: SessionSearchScope, - parent_session_id: Option<&str>, + filters: SessionSearchFilters<'_>, ) -> Vec { let fts_query = session_fts_query(query); if fts_query.is_empty() || limit == 0 { @@ -3265,14 +3253,36 @@ impl GlobalDb { query_params.push(Value::Text(project_key.to_string())); let _ = write!(sql, " AND s.project_key = ?{}", query_params.len()); } - if let Some(parent_session_id) = parent_session_id { + if let Some(parent_session_id) = filters.parent_session_id { query_params.push(Value::Text(parent_session_id.to_string())); let _ = write!(sql, " AND s.parent_session_id = ?{}", query_params.len()); } - if matches!(scope, SessionSearchScope::ParentsOnly) { + if let Some(start_time) = filters.time_range.start_time { + query_params.push(Value::Integer(start_time)); + let _ = write!( + sql, + " AND m.timestamp IS NOT NULL AND m.timestamp >= ?{}", + query_params.len() + ); + } + if let Some(end_time) = filters.time_range.end_time { + query_params.push(Value::Integer(end_time)); + let _ = write!( + sql, + " AND m.timestamp IS NOT NULL AND m.timestamp <= ?{}", + query_params.len() + ); + } + if matches!( + filters.scope, + crate::sessions::SessionSearchScope::ParentsOnly + ) { sql.push_str(" AND s.is_subagent = 0"); } - if matches!(scope, SessionSearchScope::SubagentsOnly) { + if matches!( + filters.scope, + crate::sessions::SessionSearchScope::SubagentsOnly + ) { sql.push_str(" AND s.is_subagent = 1"); } for term in &literal_terms { diff --git a/src/mcp/tools/definitions.rs b/src/mcp/tools/definitions.rs index 88202955b..dac049a89 100644 --- a/src/mcp/tools/definitions.rs +++ b/src/mcp/tools/definitions.rs @@ -2351,6 +2351,34 @@ fn def_message_search() -> ToolDefinition { "type": "string", "description": "Optional parent session id filter. Primarily useful with scope=subagents_only." }, + "since": { + "oneOf": [ + { "type": "integer", "minimum": 0 }, + { "type": "string" } + ], + "description": "Optional inclusive minimum message timestamp. Accepts Unix seconds, RFC3339, YYYY-MM-DD, or relative time like 'last hour'." + }, + "until": { + "oneOf": [ + { "type": "integer", "minimum": 0 }, + { "type": "string" } + ], + "description": "Optional inclusive maximum message timestamp. Accepts Unix seconds, RFC3339, YYYY-MM-DD, or relative time like 'last hour'." + }, + "time_from": { + "oneOf": [ + { "type": "integer", "minimum": 0 }, + { "type": "string" } + ], + "description": "Alias for since." + }, + "time_to": { + "oneOf": [ + { "type": "integer", "minimum": 0 }, + { "type": "string" } + ], + "description": "Alias for until." + }, "scope": { "type": "string", "description": "Relationship scope for search results (default: all).", @@ -2616,14 +2644,28 @@ fn def_lcm_grep() -> ToolDefinition { { "type": "integer", "minimum": 0 }, { "type": "string" } ], - "description": "Optional inclusive minimum raw-message timestamp. Integer strings and timezone-aware ISO/RFC3339 strings are accepted." + "description": "Optional inclusive minimum raw-message timestamp. Accepts Unix seconds, RFC3339, YYYY-MM-DD, or relative time like 'last hour'." }, "end_time": { "oneOf": [ { "type": "integer", "minimum": 0 }, { "type": "string" } ], - "description": "Optional inclusive maximum raw-message timestamp. Integer strings and timezone-aware ISO/RFC3339 strings are accepted." + "description": "Optional inclusive maximum raw-message timestamp. Accepts Unix seconds, RFC3339, YYYY-MM-DD, or relative time like 'last hour'." + }, + "since": { + "oneOf": [ + { "type": "integer", "minimum": 0 }, + { "type": "string" } + ], + "description": "Alias for start_time." + }, + "until": { + "oneOf": [ + { "type": "integer", "minimum": 0 }, + { "type": "string" } + ], + "description": "Alias for end_time." }, "limit": { "type": "integer", diff --git a/src/mcp/tools/handlers/session.rs b/src/mcp/tools/handlers/session.rs index d7db06b26..3de6c239b 100644 --- a/src/mcp/tools/handlers/session.rs +++ b/src/mcp/tools/handlers/session.rs @@ -21,7 +21,10 @@ use crate::sessions::lcm::{ LcmGrepSort, LcmLoadSessionRequest, LcmPreflightRequest, LcmScope, LcmSessionBoundaryRequest, LcmSummarizerMode, LCM_EXPAND_QUERY_SYNTHESIS_SYSTEM_PROMPT, }; -use crate::sessions::{ProviderScope, SessionSearchScope}; +use crate::sessions::{ + ProviderScope, SessionSearchFilters, SessionSearchScope, SessionSearchTimeRange, +}; +use crate::timeutil::SearchTimeBound; use crate::tracedecay::{current_timestamp, TraceDecay}; const DEFAULT_LCM_CONTENT_LIMIT: usize = 4096; @@ -1094,18 +1097,24 @@ fn non_negative_i64_arg_alias(args: &Value, primary: &str, alias: &str) -> Resul } } -fn non_negative_timestamp_arg_alias( +fn non_negative_timestamp_arg_aliases( args: &Value, - primary: &str, - alias: &str, + names: &[&str], + bound: SearchTimeBound, ) -> Result> { - match non_negative_timestamp_arg(args, primary)? { - Some(value) => Ok(Some(value)), - None => non_negative_timestamp_arg(args, alias), + for name in names { + if args.get(name).is_some() { + return non_negative_timestamp_arg(args, name, bound); + } } + Ok(None) } -fn non_negative_timestamp_arg(args: &Value, name: &str) -> Result> { +fn non_negative_timestamp_arg( + args: &Value, + name: &str, + bound: SearchTimeBound, +) -> Result> { let Some(value) = args.get(name) else { return Ok(None); }; @@ -1113,7 +1122,7 @@ fn non_negative_timestamp_arg(args: &Value, name: &str) -> Result> { Value::Number(number) => number .as_i64() .ok_or_else(|| timestamp_argument_error(name))?, - Value::String(text) => parse_timestamp_string(text, name)?, + Value::String(text) => parse_timestamp_string(text, name, bound)?, _ => return Err(timestamp_argument_error(name)), }; if timestamp < 0 { @@ -1122,7 +1131,7 @@ fn non_negative_timestamp_arg(args: &Value, name: &str) -> Result> { Ok(Some(timestamp)) } -fn parse_timestamp_string(value: &str, name: &str) -> Result { +fn parse_timestamp_string(value: &str, name: &str, bound: SearchTimeBound) -> Result { let text = value.trim(); if text.is_empty() { return Err(argument_error(format!("{name} must not be empty"))); @@ -1133,12 +1142,29 @@ fn parse_timestamp_string(value: &str, name: &str) -> Result { } return Err(argument_error(format!("{name} must be >= 0"))); } - crate::timeutil::parse_rfc3339_timestamp(text).ok_or_else(|| timestamp_argument_error(name)) + let now = crate::tracedecay::current_timestamp(); + crate::timeutil::parse_search_time_filter_bound(text, now, bound) + .ok_or_else(|| timestamp_argument_error(name)) +} + +fn message_search_time_range(args: &Value) -> Result { + Ok(SessionSearchTimeRange { + start_time: non_negative_timestamp_arg_aliases( + args, + &["since", "start_time", "time_from"], + SearchTimeBound::Start, + )?, + end_time: non_negative_timestamp_arg_aliases( + args, + &["until", "end_time", "time_to"], + SearchTimeBound::End, + )?, + }) } fn timestamp_argument_error(name: &str) -> TraceDecayError { argument_error(format!( - "{name} must be a non-negative Unix timestamp or timezone-aware ISO/RFC3339 string" + "{name} must be a non-negative Unix timestamp, timezone-aware ISO/RFC3339 string, YYYY-MM-DD date, or relative time like 'last hour'" )) } @@ -1779,6 +1805,7 @@ pub(super) async fn handle_message_search( .and_then(Value::as_u64) .unwrap_or(10) .clamp(1, 50) as usize; + let time_range = message_search_time_range(&args)?; let Some((db_path, target_root)) = selected_project_session_db_path( cg.project_root(), @@ -1827,8 +1854,11 @@ pub(super) async fn handle_message_search( project_key, query, limit, - scope, - parent_session_id, + SessionSearchFilters { + scope, + parent_session_id, + time_range, + }, ) .await } else { @@ -1836,8 +1866,11 @@ pub(super) async fn handle_message_search( project_key, query, limit, - scope, - parent_session_id, + SessionSearchFilters { + scope, + parent_session_id, + time_range, + }, ) .await }; @@ -1858,6 +1891,8 @@ pub(super) async fn handle_message_search( SessionSearchScope::ParentsOnly => "parents_only", SessionSearchScope::SubagentsOnly => "subagents_only", }, + "since": time_range.start_time, + "until": time_range.end_time, "query": query, "count": results.len(), "results": results, @@ -2069,8 +2104,16 @@ pub(super) async fn handle_lcm_grep( sort: parse_lcm_grep_sort(&args)?, source: string_arg(&args, "source").map(str::to_string), role: string_arg(&args, "role").map(str::to_string), - start_time: non_negative_timestamp_arg_alias(&args, "start_time", "time_from")?, - end_time: non_negative_timestamp_arg_alias(&args, "end_time", "time_to")?, + start_time: non_negative_timestamp_arg_aliases( + &args, + &["since", "start_time", "time_from"], + SearchTimeBound::Start, + )?, + end_time: non_negative_timestamp_arg_aliases( + &args, + &["until", "end_time", "time_to"], + SearchTimeBound::End, + )?, }) .await .map_err(lcm_error)?; diff --git a/src/sessions/mod.rs b/src/sessions/mod.rs index d0aedb2f1..6e9cebfa8 100644 --- a/src/sessions/mod.rs +++ b/src/sessions/mod.rs @@ -169,6 +169,31 @@ pub struct SessionMessageSearchResult { pub score: f64, } +/// Inclusive timestamp bounds for session-message full-text search. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionSearchTimeRange { + pub start_time: Option, + pub end_time: Option, +} + +/// Relationship and time filters for session-message full-text search. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SessionSearchFilters<'a> { + pub scope: SessionSearchScope, + pub parent_session_id: Option<&'a str>, + pub time_range: SessionSearchTimeRange, +} + +impl Default for SessionSearchFilters<'_> { + fn default() -> Self { + Self { + scope: SessionSearchScope::All, + parent_session_id: None, + time_range: SessionSearchTimeRange::default(), + } + } +} + /// Scope filter for session-message full-text search. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum SessionSearchScope { diff --git a/src/sessions_cmd.rs b/src/sessions_cmd.rs index cde8b443c..54153a412 100644 --- a/src/sessions_cmd.rs +++ b/src/sessions_cmd.rs @@ -1,7 +1,8 @@ use std::path::Path; use crate::{cli::SessionsAction, resolve_cli_project_root}; -use tracedecay::sessions::ProviderScope; +use tracedecay::sessions::{ProviderScope, SessionSearchFilters, SessionSearchTimeRange}; +use tracedecay::timeutil::SearchTimeBound; pub(crate) async fn handle_sessions_action( action: SessionsAction, @@ -32,6 +33,8 @@ pub(crate) async fn handle_sessions_action( query, provider, limit, + since, + until, project_id, project_path, } => { @@ -45,6 +48,21 @@ pub(crate) async fn handle_sessions_action( ), })?; let provider_scope = session_provider_scope(provider.as_deref())?; + let now = tracedecay::tracedecay::current_timestamp(); + let time_range = SessionSearchTimeRange { + start_time: parse_time_filter_arg( + "since", + since.as_deref(), + now, + SearchTimeBound::Start, + )?, + end_time: parse_time_filter_arg( + "until", + until.as_deref(), + now, + SearchTimeBound::End, + )?, + }; let _ = tracedecay::sessions::ingest_global_sources_for_provider( &db, &project_path, @@ -52,15 +70,28 @@ pub(crate) async fn handle_sessions_action( ) .await; let results = if let Some(provider) = provider_scope.provider() { - db.search_session_messages(provider.id(), None, &query, limit) - .await + db.search_session_messages_filtered( + provider.id(), + None, + &query, + limit, + SessionSearchFilters { + scope: tracedecay::sessions::SessionSearchScope::All, + parent_session_id: None, + time_range, + }, + ) + .await } else { db.search_session_messages_all_providers_filtered( None, &query, limit, - tracedecay::sessions::SessionSearchScope::All, - None, + SessionSearchFilters { + scope: tracedecay::sessions::SessionSearchScope::All, + parent_session_id: None, + time_range, + }, ) .await }; @@ -89,3 +120,21 @@ fn session_provider_scope(provider: Option<&str>) -> tracedecay::errors::Result< ProviderScope::parse_optional(provider) .map_err(|message| tracedecay::errors::TraceDecayError::Config { message }) } + +fn parse_time_filter_arg( + name: &str, + value: Option<&str>, + now: i64, + bound: SearchTimeBound, +) -> tracedecay::errors::Result> { + let Some(value) = value else { + return Ok(None); + }; + tracedecay::timeutil::parse_search_time_filter_bound(value, now, bound) + .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { + message: format!( + "{name} must be a non-negative Unix timestamp, timezone-aware ISO/RFC3339 string, YYYY-MM-DD date, or relative time like 'last hour'" + ), + }) + .map(Some) +} diff --git a/src/timeutil.rs b/src/timeutil.rs index b6d218f7b..4be6b92b5 100644 --- a/src/timeutil.rs +++ b/src/timeutil.rs @@ -84,6 +84,91 @@ pub fn parse_rfc3339_timestamp(value: &str) -> Option { (timestamp >= 0).then_some(timestamp) } +/// Parses search filter timestamps. Accepts Unix seconds, RFC3339, `YYYY-MM-DD` +/// UTC dates, `today`, `yesterday`, and relative forms like `last hour`. +pub fn parse_search_time_filter(value: &str, now: i64) -> Option { + parse_search_time_filter_bound(value, now, SearchTimeBound::Start) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SearchTimeBound { + Start, + End, +} + +pub fn parse_search_time_filter_bound( + value: &str, + now: i64, + bound: SearchTimeBound, +) -> Option { + let text = value.trim(); + if text.is_empty() { + return None; + } + if let Ok(timestamp) = text.parse::() { + return (timestamp >= 0).then_some(timestamp); + } + if let Some(timestamp) = parse_rfc3339_timestamp(text) { + return Some(timestamp); + } + if let Some(day_start) = parse_yyyy_mm_dd_utc_start(text) { + return Some(bound_day_timestamp(day_start, bound)); + } + + let normalized = text.to_ascii_lowercase(); + match normalized.as_str() { + "today" => return Some(bound_day_timestamp(now.div_euclid(86_400) * 86_400, bound)), + "yesterday" => { + return Some(bound_day_timestamp( + now.div_euclid(86_400) * 86_400 - 86_400, + bound, + )); + } + _ => {} + } + + let words: Vec<&str> = normalized.split_whitespace().collect(); + let (count, unit) = match words.as_slice() { + ["last", unit] => (1_i64, *unit), + ["last", count, unit] | [count, unit, "ago"] => (count.parse::().ok()?, *unit), + _ => return None, + }; + let seconds = match unit.trim_end_matches('s') { + "minute" | "min" => count.checked_mul(60)?, + "hour" | "hr" => count.checked_mul(3_600)?, + "day" => count.checked_mul(86_400)?, + "week" => count.checked_mul(604_800)?, + _ => return None, + }; + if count <= 0 || seconds < 0 { + return None; + } + Some(now.saturating_sub(seconds)) +} + +fn bound_day_timestamp(day_start: i64, bound: SearchTimeBound) -> i64 { + match bound { + SearchTimeBound::Start => day_start, + SearchTimeBound::End => day_start + 86_399, + } +} + +fn parse_yyyy_mm_dd_utc_start(value: &str) -> Option { + let bytes = value.as_bytes(); + if bytes.len() != 10 || bytes.get(4) != Some(&b'-') || bytes.get(7) != Some(&b'-') { + return None; + } + let year = parse_fixed_i32(value, 0, 4)?; + let month = parse_fixed_u32(value, 5, 7)?; + let day = parse_fixed_u32(value, 8, 10)?; + if !(1..=12).contains(&month) || day == 0 || day > days_in_month(year, month) { + return None; + } + let days = days_from_civil(year, month, day); + let timestamp = days.checked_mul(86_400)?; + (timestamp >= 0).then_some(timestamp) +} + /// Parses the human-readable timestamp Cursor injects into user prompts as /// `` (e.g. `Wednesday, Jun 10, 2026, 9:11 AM (UTC+2)`) /// into Unix epoch seconds. @@ -318,6 +403,43 @@ mod tests { assert!(parse_rfc3339_timestamp("").is_none()); } + #[test] + fn parses_search_time_filters() { + let now = 1_800_000_000; + assert_eq!(parse_search_time_filter("123", now), Some(123)); + assert_eq!( + parse_search_time_filter("1970-01-02T00:00:00Z", now), + Some(86_400) + ); + assert_eq!(parse_search_time_filter("1970-01-02", now), Some(86_400)); + assert_eq!( + parse_search_time_filter_bound("1970-01-02", now, SearchTimeBound::End), + Some(172_799) + ); + assert_eq!( + parse_search_time_filter("last hour", now), + Some(now - 3_600) + ); + assert_eq!( + parse_search_time_filter("last 2 days", now), + Some(now - 172_800) + ); + assert_eq!( + parse_search_time_filter("15 minutes ago", now), + Some(now - 900) + ); + assert_eq!( + parse_search_time_filter("today", now), + Some(now.div_euclid(86_400) * 86_400) + ); + assert_eq!( + parse_search_time_filter_bound("today", now, SearchTimeBound::End), + Some(now.div_euclid(86_400) * 86_400 + 86_399) + ); + assert!(parse_search_time_filter("last zero hours", now).is_none()); + assert!(parse_search_time_filter("tomorrow", now).is_none()); + } + #[test] fn parses_cursor_human_timestamp() { // 2026-06-10 09:11 at UTC+2 == 2026-06-10T07:11:00Z. diff --git a/tests/agent_suite/agent_test.rs b/tests/agent_suite/agent_test.rs index 42d10f917..edec8ab6f 100644 --- a/tests/agent_suite/agent_test.rs +++ b/tests/agent_suite/agent_test.rs @@ -1222,7 +1222,7 @@ fn test_hermes_plugin_init_snapshot_matches_embedded_asset() { hasher.update(body.as_bytes()); assert_eq!( hex::encode(hasher.finalize()), - "fe9f53b0721f9080ceb0fd0b16227efd66f550e1fca11a07b25abf8489e8435c", + "30608faf4faf37d78e23826031f7b5422ad81ef5030b8c8bc1a7d3381e1ba85a", "templates/plugin_init.py payload hash changed — verify the edit is intentional and update this snapshot" ); } diff --git a/tests/mcp_suite/mcp_handler_test.rs b/tests/mcp_suite/mcp_handler_test.rs index cf08db6a7..8da55778d 100644 --- a/tests/mcp_suite/mcp_handler_test.rs +++ b/tests/mcp_suite/mcp_handler_test.rs @@ -10142,6 +10142,136 @@ async fn lcm_grep_accepts_string_timestamp_filters() { ); } +#[tokio::test] +async fn lcm_grep_accepts_relative_time_filters() { + let dir = test_temp_dir(); + let (cg, _env) = init_test_project(dir.path()).await; + let now = SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + seed_lcm_session_message_with_role_source_timestamp( + &cg, + "lcm-relative-timestamps", + "lcm-relative-timestamps-old", + "orchard relative timestamp old", + 1, + "assistant", + "cli", + now - 7200, + ) + .await; + seed_lcm_session_message_with_role_source_timestamp( + &cg, + "lcm-relative-timestamps", + "lcm-relative-timestamps-new", + "orchard relative timestamp new", + 2, + "assistant", + "cli", + now - 300, + ) + .await; + + let grep = handle_tool_call( + &cg, + "tracedecay_lcm_grep", + json!({ + "provider": "cursor", + "query": "orchard relative timestamp", + "scope": "session", + "session_id": "lcm-relative-timestamps", + "since": "last hour", + "limit": 10 + }), + None, + None, + ) + .await + .unwrap(); + let payload: Value = serde_json::from_str(extract_text(&grep.value)).unwrap(); + assert_eq!(payload["status"], "ok"); + assert_eq!(payload["count"], 1); + assert_eq!( + payload["hits"][0]["message_id"], + "lcm-relative-timestamps-new" + ); +} + +#[tokio::test] +async fn message_search_filters_by_time_aliases() { + let (cg, _env, _dir) = setup_empty_project().await; + let db = open_active_project_session_db(&cg).await; + let session = SessionRecord { + provider: "cursor".to_string(), + session_id: "search-time-session".to_string(), + project_key: "project-a".to_string(), + project_path: cg.project_root().to_string_lossy().to_string(), + title: Some("Search time session".to_string()), + started_at: Some(1), + ended_at: Some(90_000), + transcript_path: Some("search-time-session.jsonl".to_string()), + metadata_json: None, + parent_session_id: None, + is_subagent: false, + agent_id: None, + parent_tool_use_id: None, + }; + db.upsert_session(&session).await; + + for (message_id, timestamp, text) in [ + ("search-time-old", 10, "orchard search clock marker old"), + ( + "search-time-target", + 20, + "orchard search clock marker target", + ), + ("search-time-new", 90_000, "orchard search clock marker new"), + ] { + db.upsert_session_message(&SessionMessageRecord { + provider: "cursor".to_string(), + message_id: message_id.to_string(), + session_id: "search-time-session".to_string(), + role: "assistant".to_string(), + timestamp: Some(timestamp), + ordinal: timestamp, + text: text.to_string(), + kind: Some("message".to_string()), + model: Some("test-model".to_string()), + tool_names: None, + source_path: Some("search-time-session.jsonl".to_string()), + source_offset: Some(timestamp), + metadata_json: None, + }) + .await; + } + + let search = handle_tool_call( + &cg, + "tracedecay_message_search", + json!({ + "provider": "cursor", + "query": "orchard search clock marker", + "project_key": "project-a", + "time_from": "15", + "until": "1970-01-01", + "catch_up": false, + "limit": 10 + }), + None, + None, + ) + .await + .unwrap(); + let payload: Value = serde_json::from_str(extract_text(&search.value)).unwrap(); + assert_eq!(payload["status"], "ok"); + assert_eq!(payload["count"], 1); + assert_eq!( + payload["results"][0]["message"]["message_id"], + "search-time-target" + ); +} + #[tokio::test] async fn lcm_status_uses_explicit_hermes_profile_session_db() { let dir = test_temp_dir(); diff --git a/tests/session_suite/global_db.rs b/tests/session_suite/global_db.rs index 4cfe08b60..2230bc8b8 100644 --- a/tests/session_suite/global_db.rs +++ b/tests/session_suite/global_db.rs @@ -2,7 +2,9 @@ use sha2::{Digest, Sha256}; use tempfile::TempDir; use tracedecay::global_db::{AnalyticsEventInsert, AnalyticsEventQuery, GlobalDb}; use tracedecay::sessions::lcm::LcmStorageKind; -use tracedecay::sessions::{SessionRecord, SessionSearchScope}; +use tracedecay::sessions::{ + SessionRecord, SessionSearchFilters, SessionSearchScope, SessionSearchTimeRange, +}; use crate::common::{ global_message as sample_message, global_session as sample_session, @@ -739,6 +741,61 @@ async fn search_session_messages_applies_hyphen_filter_before_limit() { assert_eq!(results[0].message.message_id, "hyphenated"); } +#[tokio::test] +async fn search_session_messages_filters_by_message_timestamp() { + let tmp = TempDir::new().unwrap(); + let db = open_isolated_db(&tmp).await; + let session = sample_session("cursor", "cursor-time", "project-a"); + db.upsert_session(&session).await; + + let mut old = sample_message( + "cursor", + "old-time-msg", + "cursor-time", + "the orchard clock marker appears before the window", + ); + old.timestamp = Some(10); + db.upsert_session_message(&old).await; + + let mut target = sample_message( + "cursor", + "target-time-msg", + "cursor-time", + "the orchard clock marker appears inside the window", + ); + target.timestamp = Some(20); + db.upsert_session_message(&target).await; + + let mut new = sample_message( + "cursor", + "new-time-msg", + "cursor-time", + "the orchard clock marker appears after the window", + ); + new.timestamp = Some(30); + db.upsert_session_message(&new).await; + + let results = db + .search_session_messages_filtered( + "cursor", + Some("project-a"), + "orchard clock marker", + 10, + SessionSearchFilters { + scope: SessionSearchScope::All, + parent_session_id: None, + time_range: SessionSearchTimeRange { + start_time: Some(15), + end_time: Some(25), + }, + }, + ) + .await; + + assert_eq!(results.len(), 1); + assert_eq!(results[0].message.message_id, "target-time-msg"); +} + #[tokio::test] async fn open_at_upgrades_existing_sessions_table_with_parent_columns() { let tmp = TempDir::new().unwrap(); @@ -842,8 +899,11 @@ async fn search_session_messages_filters_parent_and_subagent_scope() { Some("project-a"), "orchard dispatch", 10, - SessionSearchScope::ParentsOnly, - None, + SessionSearchFilters { + scope: SessionSearchScope::ParentsOnly, + parent_session_id: None, + time_range: SessionSearchTimeRange::default(), + }, ) .await; assert_eq!(parents_only.len(), 1); @@ -855,8 +915,11 @@ async fn search_session_messages_filters_parent_and_subagent_scope() { Some("project-a"), "orchard dispatch", 10, - SessionSearchScope::SubagentsOnly, - Some("parent"), + SessionSearchFilters { + scope: SessionSearchScope::SubagentsOnly, + parent_session_id: Some("parent"), + time_range: SessionSearchTimeRange::default(), + }, ) .await; assert_eq!(subagents_only.len(), 1); diff --git a/tests/transcript_ingest_suite/cursor.rs b/tests/transcript_ingest_suite/cursor.rs index bac23efdd..a642aebd1 100644 --- a/tests/transcript_ingest_suite/cursor.rs +++ b/tests/transcript_ingest_suite/cursor.rs @@ -10,7 +10,7 @@ use tracedecay::sessions::cursor::{ use tracedecay::sessions::cursor_agent::CursorAgentSummaryConfig; use tracedecay::sessions::lcm::{LcmDescribeRequest, LcmDescribeTarget}; use tracedecay::sessions::source::ingest_source; -use tracedecay::sessions::SessionSearchScope; +use tracedecay::sessions::{SessionSearchFilters, SessionSearchScope, SessionSearchTimeRange}; use crate::common::{EnvVarGuard, GLOBAL_DB_ENV, GLOBAL_DB_ENV_LOCK}; use crate::support::{assert_metadata_path_eq, init_git_repo, init_project, init_project_at}; @@ -735,8 +735,11 @@ async fn cursor_subagent_ingestion_is_incremental_per_file() { None, "orchard", 10, - SessionSearchScope::SubagentsOnly, - Some("parent-session"), + SessionSearchFilters { + scope: SessionSearchScope::SubagentsOnly, + parent_session_id: Some("parent-session"), + time_range: SessionSearchTimeRange::default(), + }, ) .await; assert_eq!(child_hits.len(), 2);