1use 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#[non_exhaustive]
21#[derive(Debug, Clone, Default, PartialEq, Eq)]
22pub struct SessionSearchOptions {
23 pub session_ids: Option<Vec<String>>,
25 pub entry_types: Option<Vec<String>>,
27 pub start_time: Option<DateTime<Utc>>,
29 pub end_time: Option<DateTime<Utc>>,
31 pub max_results: Option<usize>,
33}
34
35impl SessionSearchOptions {
36 #[must_use]
38 pub fn new() -> Self {
39 Self::default()
40 }
41
42 #[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 #[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 #[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 #[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 #[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#[non_exhaustive]
84#[derive(Debug, Clone)]
85pub struct SessionHit {
86 pub session_id: String,
88 pub session_title: String,
90 pub entry: SessionEntry,
92 pub score: usize,
94 pub snippet: String,
96}
97
98impl SessionHit {
99 #[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#[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 _ => 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#[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}