Skip to content

Commit e1f8fbf

Browse files
feat(sessions): add date filters to recall search
1 parent 6844e1b commit e1f8fbf

12 files changed

Lines changed: 525 additions & 23 deletions

File tree

src/agents/hermes/templates/plugin_init.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,11 +156,19 @@ def _resolve_auxiliary_client(agent=None):
156156
},
157157
"time_from": {
158158
"anyOf": [{"type": "number"}, {"type": "string"}],
159-
"description": "Optional inclusive minimum raw-message timestamp.",
159+
"description": "Optional inclusive minimum raw-message timestamp. Accepts Unix seconds, RFC3339, YYYY-MM-DD, or relative time like 'last hour'.",
160160
},
161161
"time_to": {
162162
"anyOf": [{"type": "number"}, {"type": "string"}],
163-
"description": "Optional inclusive maximum raw-message timestamp.",
163+
"description": "Optional inclusive maximum raw-message timestamp. Accepts Unix seconds, RFC3339, YYYY-MM-DD, or relative time like 'last hour'.",
164+
},
165+
"since": {
166+
"anyOf": [{"type": "number"}, {"type": "string"}],
167+
"description": "Alias for time_from.",
168+
},
169+
"until": {
170+
"anyOf": [{"type": "number"}, {"type": "string"}],
171+
"description": "Alias for time_to.",
164172
},
165173
},
166174
"required": ["query"],

src/cli.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -624,6 +624,12 @@ pub enum SessionsAction {
624624
/// Maximum number of matches
625625
#[arg(long, default_value_t = 10)]
626626
limit: usize,
627+
/// Inclusive minimum message timestamp. Accepts Unix seconds, RFC3339, YYYY-MM-DD, or relative time like "last hour"
628+
#[arg(long, alias = "time-from", alias = "start-time")]
629+
since: Option<String>,
630+
/// Inclusive maximum message timestamp. Accepts Unix seconds, RFC3339, YYYY-MM-DD, or relative time like "last hour"
631+
#[arg(long, alias = "time-to", alias = "end-time")]
632+
until: Option<String>,
627633
/// Registered project id whose session store should be searched
628634
#[arg(long)]
629635
project_id: Option<String>,

src/cli/parse_tests.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1550,13 +1550,38 @@ fn parses_sessions_ingest_and_search_commands() {
15501550
limit,
15511551
project_id,
15521552
project_path,
1553+
since,
1554+
until,
15531555
},
15541556
}) => {
15551557
assert_eq!(query, "needle");
15561558
assert_eq!(provider.as_deref(), Some("codex"));
15571559
assert_eq!(limit, 5);
15581560
assert!(project_id.is_none());
15591561
assert!(project_path.is_none());
1562+
assert!(since.is_none());
1563+
assert!(until.is_none());
1564+
}
1565+
_ => panic!("expected sessions search command"),
1566+
}
1567+
1568+
let time_filtered_search = Cli::try_parse_from([
1569+
"tracedecay",
1570+
"sessions",
1571+
"search",
1572+
"needle",
1573+
"--since",
1574+
"last hour",
1575+
"--until",
1576+
"2026-07-04T00:00:00Z",
1577+
])
1578+
.unwrap();
1579+
match time_filtered_search.command {
1580+
Some(Commands::Sessions {
1581+
action: SessionsAction::Search { since, until, .. },
1582+
}) => {
1583+
assert_eq!(since.as_deref(), Some("last hour"));
1584+
assert_eq!(until.as_deref(), Some("2026-07-04T00:00:00Z"));
15601585
}
15611586
_ => panic!("expected sessions search command"),
15621587
}

src/global_db.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ use crate::sessions::{
1717
LcmSummarySourceMessage, LcmSummarySourceRange,
1818
},
1919
SessionMessageRecord, SessionMessageSearchResult, SessionRecord, SessionSearchScope,
20+
SessionSearchTimeRange,
2021
};
2122

2223
const UNIX_TIMESTAMP_MILLIS_THRESHOLD: i64 = 1_000_000_000_000;
@@ -3180,6 +3181,7 @@ impl GlobalDb {
31803181
limit,
31813182
SessionSearchScope::All,
31823183
None,
3184+
SessionSearchTimeRange::default(),
31833185
)
31843186
.await
31853187
}
@@ -3193,6 +3195,7 @@ impl GlobalDb {
31933195
limit: usize,
31943196
scope: SessionSearchScope,
31953197
parent_session_id: Option<&str>,
3198+
time_range: SessionSearchTimeRange,
31963199
) -> Vec<SessionMessageSearchResult> {
31973200
self.search_session_messages_filtered_inner(
31983201
Some(provider),
@@ -3201,6 +3204,7 @@ impl GlobalDb {
32013204
limit,
32023205
scope,
32033206
parent_session_id,
3207+
time_range,
32043208
)
32053209
.await
32063210
}
@@ -3213,6 +3217,7 @@ impl GlobalDb {
32133217
limit: usize,
32143218
scope: SessionSearchScope,
32153219
parent_session_id: Option<&str>,
3220+
time_range: SessionSearchTimeRange,
32163221
) -> Vec<SessionMessageSearchResult> {
32173222
self.search_session_messages_filtered_inner(
32183223
None,
@@ -3221,6 +3226,7 @@ impl GlobalDb {
32213226
limit,
32223227
scope,
32233228
parent_session_id,
3229+
time_range,
32243230
)
32253231
.await
32263232
}
@@ -3233,6 +3239,7 @@ impl GlobalDb {
32333239
limit: usize,
32343240
scope: SessionSearchScope,
32353241
parent_session_id: Option<&str>,
3242+
time_range: SessionSearchTimeRange,
32363243
) -> Vec<SessionMessageSearchResult> {
32373244
let fts_query = session_fts_query(query);
32383245
if fts_query.is_empty() || limit == 0 {
@@ -3269,6 +3276,22 @@ impl GlobalDb {
32693276
query_params.push(Value::Text(parent_session_id.to_string()));
32703277
let _ = write!(sql, " AND s.parent_session_id = ?{}", query_params.len());
32713278
}
3279+
if let Some(start_time) = time_range.start_time {
3280+
query_params.push(Value::Integer(start_time));
3281+
let _ = write!(
3282+
sql,
3283+
" AND m.timestamp IS NOT NULL AND m.timestamp >= ?{}",
3284+
query_params.len()
3285+
);
3286+
}
3287+
if let Some(end_time) = time_range.end_time {
3288+
query_params.push(Value::Integer(end_time));
3289+
let _ = write!(
3290+
sql,
3291+
" AND m.timestamp IS NOT NULL AND m.timestamp <= ?{}",
3292+
query_params.len()
3293+
);
3294+
}
32723295
if matches!(scope, SessionSearchScope::ParentsOnly) {
32733296
sql.push_str(" AND s.is_subagent = 0");
32743297
}

src/mcp/tools/definitions.rs

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2351,6 +2351,34 @@ fn def_message_search() -> ToolDefinition {
23512351
"type": "string",
23522352
"description": "Optional parent session id filter. Primarily useful with scope=subagents_only."
23532353
},
2354+
"since": {
2355+
"oneOf": [
2356+
{ "type": "integer", "minimum": 0 },
2357+
{ "type": "string" }
2358+
],
2359+
"description": "Optional inclusive minimum message timestamp. Accepts Unix seconds, RFC3339, YYYY-MM-DD, or relative time like 'last hour'."
2360+
},
2361+
"until": {
2362+
"oneOf": [
2363+
{ "type": "integer", "minimum": 0 },
2364+
{ "type": "string" }
2365+
],
2366+
"description": "Optional inclusive maximum message timestamp. Accepts Unix seconds, RFC3339, YYYY-MM-DD, or relative time like 'last hour'."
2367+
},
2368+
"time_from": {
2369+
"oneOf": [
2370+
{ "type": "integer", "minimum": 0 },
2371+
{ "type": "string" }
2372+
],
2373+
"description": "Alias for since."
2374+
},
2375+
"time_to": {
2376+
"oneOf": [
2377+
{ "type": "integer", "minimum": 0 },
2378+
{ "type": "string" }
2379+
],
2380+
"description": "Alias for until."
2381+
},
23542382
"scope": {
23552383
"type": "string",
23562384
"description": "Relationship scope for search results (default: all).",
@@ -2616,14 +2644,28 @@ fn def_lcm_grep() -> ToolDefinition {
26162644
{ "type": "integer", "minimum": 0 },
26172645
{ "type": "string" }
26182646
],
2619-
"description": "Optional inclusive minimum raw-message timestamp. Integer strings and timezone-aware ISO/RFC3339 strings are accepted."
2647+
"description": "Optional inclusive minimum raw-message timestamp. Accepts Unix seconds, RFC3339, YYYY-MM-DD, or relative time like 'last hour'."
26202648
},
26212649
"end_time": {
26222650
"oneOf": [
26232651
{ "type": "integer", "minimum": 0 },
26242652
{ "type": "string" }
26252653
],
2626-
"description": "Optional inclusive maximum raw-message timestamp. Integer strings and timezone-aware ISO/RFC3339 strings are accepted."
2654+
"description": "Optional inclusive maximum raw-message timestamp. Accepts Unix seconds, RFC3339, YYYY-MM-DD, or relative time like 'last hour'."
2655+
},
2656+
"since": {
2657+
"oneOf": [
2658+
{ "type": "integer", "minimum": 0 },
2659+
{ "type": "string" }
2660+
],
2661+
"description": "Alias for start_time."
2662+
},
2663+
"until": {
2664+
"oneOf": [
2665+
{ "type": "integer", "minimum": 0 },
2666+
{ "type": "string" }
2667+
],
2668+
"description": "Alias for end_time."
26272669
},
26282670
"limit": {
26292671
"type": "integer",

src/mcp/tools/handlers/session.rs

Lines changed: 51 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ use crate::sessions::lcm::{
2121
LcmGrepSort, LcmLoadSessionRequest, LcmPreflightRequest, LcmScope, LcmSessionBoundaryRequest,
2222
LcmSummarizerMode, LCM_EXPAND_QUERY_SYNTHESIS_SYSTEM_PROMPT,
2323
};
24-
use crate::sessions::{ProviderScope, SessionSearchScope};
24+
use crate::sessions::{ProviderScope, SessionSearchScope, SessionSearchTimeRange};
25+
use crate::timeutil::SearchTimeBound;
2526
use crate::tracedecay::{current_timestamp, TraceDecay};
2627

2728
const DEFAULT_LCM_CONTENT_LIMIT: usize = 4096;
@@ -1094,26 +1095,32 @@ fn non_negative_i64_arg_alias(args: &Value, primary: &str, alias: &str) -> Resul
10941095
}
10951096
}
10961097

1097-
fn non_negative_timestamp_arg_alias(
1098+
fn non_negative_timestamp_arg_aliases(
10981099
args: &Value,
1099-
primary: &str,
1100-
alias: &str,
1100+
names: &[&str],
1101+
bound: SearchTimeBound,
11011102
) -> Result<Option<i64>> {
1102-
match non_negative_timestamp_arg(args, primary)? {
1103-
Some(value) => Ok(Some(value)),
1104-
None => non_negative_timestamp_arg(args, alias),
1103+
for name in names {
1104+
if args.get(name).is_some() {
1105+
return non_negative_timestamp_arg(args, name, bound);
1106+
}
11051107
}
1108+
Ok(None)
11061109
}
11071110

1108-
fn non_negative_timestamp_arg(args: &Value, name: &str) -> Result<Option<i64>> {
1111+
fn non_negative_timestamp_arg(
1112+
args: &Value,
1113+
name: &str,
1114+
bound: SearchTimeBound,
1115+
) -> Result<Option<i64>> {
11091116
let Some(value) = args.get(name) else {
11101117
return Ok(None);
11111118
};
11121119
let timestamp = match value {
11131120
Value::Number(number) => number
11141121
.as_i64()
11151122
.ok_or_else(|| timestamp_argument_error(name))?,
1116-
Value::String(text) => parse_timestamp_string(text, name)?,
1123+
Value::String(text) => parse_timestamp_string(text, name, bound)?,
11171124
_ => return Err(timestamp_argument_error(name)),
11181125
};
11191126
if timestamp < 0 {
@@ -1122,7 +1129,7 @@ fn non_negative_timestamp_arg(args: &Value, name: &str) -> Result<Option<i64>> {
11221129
Ok(Some(timestamp))
11231130
}
11241131

1125-
fn parse_timestamp_string(value: &str, name: &str) -> Result<i64> {
1132+
fn parse_timestamp_string(value: &str, name: &str, bound: SearchTimeBound) -> Result<i64> {
11261133
let text = value.trim();
11271134
if text.is_empty() {
11281135
return Err(argument_error(format!("{name} must not be empty")));
@@ -1133,12 +1140,29 @@ fn parse_timestamp_string(value: &str, name: &str) -> Result<i64> {
11331140
}
11341141
return Err(argument_error(format!("{name} must be >= 0")));
11351142
}
1136-
crate::timeutil::parse_rfc3339_timestamp(text).ok_or_else(|| timestamp_argument_error(name))
1143+
let now = crate::tracedecay::current_timestamp();
1144+
crate::timeutil::parse_search_time_filter_bound(text, now, bound)
1145+
.ok_or_else(|| timestamp_argument_error(name))
1146+
}
1147+
1148+
fn message_search_time_range(args: &Value) -> Result<SessionSearchTimeRange> {
1149+
Ok(SessionSearchTimeRange {
1150+
start_time: non_negative_timestamp_arg_aliases(
1151+
args,
1152+
&["since", "start_time", "time_from"],
1153+
SearchTimeBound::Start,
1154+
)?,
1155+
end_time: non_negative_timestamp_arg_aliases(
1156+
args,
1157+
&["until", "end_time", "time_to"],
1158+
SearchTimeBound::End,
1159+
)?,
1160+
})
11371161
}
11381162

11391163
fn timestamp_argument_error(name: &str) -> TraceDecayError {
11401164
argument_error(format!(
1141-
"{name} must be a non-negative Unix timestamp or timezone-aware ISO/RFC3339 string"
1165+
"{name} must be a non-negative Unix timestamp, timezone-aware ISO/RFC3339 string, YYYY-MM-DD date, or relative time like 'last hour'"
11421166
))
11431167
}
11441168

@@ -1779,6 +1803,7 @@ pub(super) async fn handle_message_search(
17791803
.and_then(Value::as_u64)
17801804
.unwrap_or(10)
17811805
.clamp(1, 50) as usize;
1806+
let time_range = message_search_time_range(&args)?;
17821807

17831808
let Some((db_path, target_root)) = selected_project_session_db_path(
17841809
cg.project_root(),
@@ -1829,6 +1854,7 @@ pub(super) async fn handle_message_search(
18291854
limit,
18301855
scope,
18311856
parent_session_id,
1857+
time_range,
18321858
)
18331859
.await
18341860
} else {
@@ -1838,6 +1864,7 @@ pub(super) async fn handle_message_search(
18381864
limit,
18391865
scope,
18401866
parent_session_id,
1867+
time_range,
18411868
)
18421869
.await
18431870
};
@@ -1858,6 +1885,8 @@ pub(super) async fn handle_message_search(
18581885
SessionSearchScope::ParentsOnly => "parents_only",
18591886
SessionSearchScope::SubagentsOnly => "subagents_only",
18601887
},
1888+
"since": time_range.start_time,
1889+
"until": time_range.end_time,
18611890
"query": query,
18621891
"count": results.len(),
18631892
"results": results,
@@ -2069,8 +2098,16 @@ pub(super) async fn handle_lcm_grep(
20692098
sort: parse_lcm_grep_sort(&args)?,
20702099
source: string_arg(&args, "source").map(str::to_string),
20712100
role: string_arg(&args, "role").map(str::to_string),
2072-
start_time: non_negative_timestamp_arg_alias(&args, "start_time", "time_from")?,
2073-
end_time: non_negative_timestamp_arg_alias(&args, "end_time", "time_to")?,
2101+
start_time: non_negative_timestamp_arg_aliases(
2102+
&args,
2103+
&["since", "start_time", "time_from"],
2104+
SearchTimeBound::Start,
2105+
)?,
2106+
end_time: non_negative_timestamp_arg_aliases(
2107+
&args,
2108+
&["until", "end_time", "time_to"],
2109+
SearchTimeBound::End,
2110+
)?,
20742111
})
20752112
.await
20762113
.map_err(lcm_error)?;

src/sessions/mod.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,13 @@ pub struct SessionMessageSearchResult {
169169
pub score: f64,
170170
}
171171

172+
/// Inclusive timestamp bounds for session-message full-text search.
173+
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
174+
pub struct SessionSearchTimeRange {
175+
pub start_time: Option<i64>,
176+
pub end_time: Option<i64>,
177+
}
178+
172179
/// Scope filter for session-message full-text search.
173180
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
174181
pub enum SessionSearchScope {

0 commit comments

Comments
 (0)