Skip to main content

swink_agent_memory/
search.rs

1//! Cross-session search types and helpers.
2//!
3//! The `search` feature gate enables a tantivy-backed full-text index stored
4//! alongside the session JSONL files.  Without the feature the module still
5//! compiles and provides the public types; `JsonlSessionStore::search()` falls
6//! back to the linear scan already implemented in `jsonl.rs`.
7
8use chrono::{DateTime, Utc};
9use swink_agent::{ContentBlock, LlmMessage};
10
11use crate::entry::SessionEntry;
12
13#[cfg(feature = "search")]
14pub(crate) mod index;
15
16const DEFAULT_MAX_RESULTS: usize = 50;
17const SNIPPET_CONTEXT_BYTES: usize = 72;
18
19/// Options for searching persisted sessions.
20#[non_exhaustive]
21#[derive(Debug, Clone, Default, PartialEq, Eq)]
22pub struct SessionSearchOptions {
23    /// Restrict search to these session IDs.
24    pub session_ids: Option<Vec<String>>,
25    /// Restrict search to entry discriminator names such as `"message"` or `"label"`.
26    pub entry_types: Option<Vec<String>>,
27    /// Include entries at or after this timestamp.
28    pub start_time: Option<DateTime<Utc>>,
29    /// Include entries at or before this timestamp.
30    pub end_time: Option<DateTime<Utc>>,
31    /// Maximum number of hits to return. Defaults to 50.
32    pub max_results: Option<usize>,
33}
34
35impl SessionSearchOptions {
36    /// Creates a new `SessionSearchOptions` with no filters applied.
37    #[must_use]
38    pub fn new() -> Self {
39        Self::default()
40    }
41
42    /// Restricts search to the given session IDs.
43    #[must_use]
44    pub fn with_session_ids(mut self, session_ids: Vec<String>) -> Self {
45        self.session_ids = Some(session_ids);
46        self
47    }
48
49    /// Restricts search to entry discriminator names such as `"message"` or `"label"`.
50    #[must_use]
51    pub fn with_entry_types(mut self, entry_types: Vec<String>) -> Self {
52        self.entry_types = Some(entry_types);
53        self
54    }
55
56    /// Includes only entries at or after this timestamp.
57    #[must_use]
58    pub fn with_start_time(mut self, start_time: DateTime<Utc>) -> Self {
59        self.start_time = Some(start_time);
60        self
61    }
62
63    /// Includes only entries at or before this timestamp.
64    #[must_use]
65    pub fn with_end_time(mut self, end_time: DateTime<Utc>) -> Self {
66        self.end_time = Some(end_time);
67        self
68    }
69
70    /// Sets the maximum number of hits to return (defaults to 50).
71    #[must_use]
72    pub fn with_max_results(mut self, max_results: usize) -> Self {
73        self.max_results = Some(max_results);
74        self
75    }
76
77    pub(crate) fn limit(&self) -> usize {
78        self.max_results.unwrap_or(DEFAULT_MAX_RESULTS)
79    }
80}
81
82/// A single cross-session search result.
83#[non_exhaustive]
84#[derive(Debug, Clone)]
85pub struct SessionHit {
86    /// ID of the session containing the match.
87    pub session_id: String,
88    /// Title from the matched session metadata.
89    pub session_title: String,
90    /// Matched entry from the session log.
91    pub entry: SessionEntry,
92    /// Simple term-frequency score for result ordering.
93    pub score: usize,
94    /// Compact preview around the first matched term.
95    pub snippet: String,
96}
97
98impl SessionHit {
99    /// Creates a new search hit.
100    #[must_use]
101    pub fn new(
102        session_id: impl Into<String>,
103        session_title: impl Into<String>,
104        entry: SessionEntry,
105        score: usize,
106        snippet: impl Into<String>,
107    ) -> Self {
108        Self {
109            session_id: session_id.into(),
110            session_title: session_title.into(),
111            entry,
112            score,
113            snippet: snippet.into(),
114        }
115    }
116}
117
118#[cfg(not(feature = "search"))]
119pub(crate) fn query_terms(query: &str) -> Vec<String> {
120    query
121        .split_whitespace()
122        .map(str::trim)
123        .filter(|term| !term.is_empty())
124        .map(str::to_lowercase)
125        .collect()
126}
127
128#[cfg(not(feature = "search"))]
129pub(crate) fn entry_matches_time_range(
130    entry: &SessionEntry,
131    options: &SessionSearchOptions,
132) -> bool {
133    let Some(timestamp) = entry.timestamp() else {
134        return options.start_time.is_none() && options.end_time.is_none();
135    };
136
137    if let Some(start) = options.start_time
138        && timestamp < start.timestamp().cast_unsigned()
139    {
140        return false;
141    }
142    if let Some(end) = options.end_time
143        && timestamp > end.timestamp().cast_unsigned()
144    {
145        return false;
146    }
147    true
148}
149
150#[cfg(not(feature = "search"))]
151pub(crate) fn entry_matches_type(entry: &SessionEntry, options: &SessionSearchOptions) -> bool {
152    options.entry_types.as_ref().is_none_or(|types| {
153        types
154            .iter()
155            .any(|expected| expected == entry.entry_type_name())
156    })
157}
158
159#[cfg(not(feature = "search"))]
160pub(crate) fn search_entry(entry: &SessionEntry, terms: &[String]) -> Option<(usize, String)> {
161    if terms.is_empty() {
162        return None;
163    }
164
165    let text = searchable_text(entry);
166    if text.is_empty() {
167        return None;
168    }
169
170    let haystack = text.to_lowercase();
171    let mut score = 0;
172    let mut first_match = None;
173    for term in terms {
174        let mut matches = haystack.match_indices(term);
175        let (idx, _) = matches.next()?;
176        first_match = Some(first_match.map_or(idx, |current: usize| current.min(idx)));
177        score += 1 + matches.count();
178    }
179
180    Some((score, snippet(&text, first_match.unwrap_or(0))))
181}
182
183/// Public-crate wrapper used by the tantivy index module.
184#[cfg(feature = "search")]
185pub(crate) fn searchable_text_pub(entry: &SessionEntry) -> String {
186    searchable_text(entry)
187}
188
189fn searchable_text(entry: &SessionEntry) -> String {
190    match entry {
191        SessionEntry::Message(message) => message_text(message),
192        SessionEntry::ModelChange {
193            from,
194            to,
195            timestamp: _,
196        } => format!(
197            "model change {} {} {} {}",
198            from.provider, from.model_id, to.provider, to.model_id
199        ),
200        SessionEntry::ThinkingLevelChange {
201            from,
202            to,
203            timestamp: _,
204        } => {
205            format!("thinking level change {from} {to}")
206        }
207        SessionEntry::Compaction {
208            dropped_count,
209            tokens_before,
210            tokens_after,
211            timestamp: _,
212        } => format!(
213            "compaction dropped {dropped_count} tokens before {tokens_before} after {tokens_after}"
214        ),
215        SessionEntry::Label {
216            text,
217            message_index,
218            timestamp: _,
219        } => format!("label {text} message {message_index}"),
220        SessionEntry::Custom {
221            type_name,
222            data,
223            timestamp: _,
224        } => format!("custom {type_name} {data}"),
225    }
226}
227
228fn message_text(message: &LlmMessage) -> String {
229    match message {
230        LlmMessage::User(message) => content_text(&message.content),
231        LlmMessage::Assistant(message) => content_text(&message.content),
232        LlmMessage::ToolResult(message) => {
233            let mut text = content_text(&message.content);
234            if !message.details.is_null() {
235                if !text.is_empty() {
236                    text.push(' ');
237                }
238                text.push_str(&message.details.to_string());
239            }
240            text
241        }
242        // `LlmMessage` is `#[non_exhaustive]`: a variant this build doesn't
243        // recognise contributes no searchable text rather than guessing at
244        // a shape it doesn't understand.
245        _ => String::new(),
246    }
247}
248
249fn content_text(blocks: &[ContentBlock]) -> String {
250    let mut text = ContentBlock::extract_text(blocks);
251    for block in blocks {
252        match block {
253            ContentBlock::Thinking {
254                thinking,
255                signature: _,
256            } => push_part(&mut text, thinking),
257            ContentBlock::ToolCall {
258                id: _,
259                name,
260                arguments,
261                partial_json,
262            } => {
263                push_part(&mut text, name);
264                push_part(&mut text, &arguments.to_string());
265                if let Some(partial_json) = partial_json {
266                    push_part(&mut text, partial_json);
267                }
268            }
269            ContentBlock::Extension { type_name, data } => {
270                push_part(&mut text, type_name);
271                push_part(&mut text, &data.to_string());
272            }
273            _ => {}
274        }
275    }
276    text
277}
278
279fn push_part(text: &mut String, part: &str) {
280    if part.is_empty() {
281        return;
282    }
283    if !text.is_empty() {
284        text.push(' ');
285    }
286    text.push_str(part);
287}
288
289/// Public-crate wrapper used by the tantivy index module.
290#[cfg(feature = "search")]
291pub(crate) fn snippet_from_text(text: &str, match_idx: usize) -> String {
292    snippet(text, match_idx)
293}
294
295fn snippet(text: &str, match_idx: usize) -> String {
296    if text.len() <= SNIPPET_CONTEXT_BYTES * 2 {
297        return text.to_string();
298    }
299
300    let start = floor_char_boundary(text, match_idx.saturating_sub(SNIPPET_CONTEXT_BYTES));
301    let end = floor_char_boundary(text, (match_idx + SNIPPET_CONTEXT_BYTES).min(text.len()));
302
303    let mut snippet = String::new();
304    if start > 0 {
305        snippet.push_str("...");
306    }
307    snippet.push_str(text[start..end].trim());
308    if end < text.len() {
309        snippet.push_str("...");
310    }
311    snippet
312}
313
314fn floor_char_boundary(text: &str, mut idx: usize) -> usize {
315    while idx > 0 && !text.is_char_boundary(idx) {
316        idx -= 1;
317    }
318    idx
319}