use chrono::{DateTime, Utc};
use swink_agent::{ContentBlock, LlmMessage};
use crate::entry::SessionEntry;
#[cfg(feature = "search")]
pub(crate) mod index;
const DEFAULT_MAX_RESULTS: usize = 50;
const SNIPPET_CONTEXT_BYTES: usize = 72;
#[non_exhaustive]
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SessionSearchOptions {
pub session_ids: Option<Vec<String>>,
pub entry_types: Option<Vec<String>>,
pub start_time: Option<DateTime<Utc>>,
pub end_time: Option<DateTime<Utc>>,
pub max_results: Option<usize>,
}
impl SessionSearchOptions {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_session_ids(mut self, session_ids: Vec<String>) -> Self {
self.session_ids = Some(session_ids);
self
}
#[must_use]
pub fn with_entry_types(mut self, entry_types: Vec<String>) -> Self {
self.entry_types = Some(entry_types);
self
}
#[must_use]
pub fn with_start_time(mut self, start_time: DateTime<Utc>) -> Self {
self.start_time = Some(start_time);
self
}
#[must_use]
pub fn with_end_time(mut self, end_time: DateTime<Utc>) -> Self {
self.end_time = Some(end_time);
self
}
#[must_use]
pub fn with_max_results(mut self, max_results: usize) -> Self {
self.max_results = Some(max_results);
self
}
pub(crate) fn limit(&self) -> usize {
self.max_results.unwrap_or(DEFAULT_MAX_RESULTS)
}
}
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct SessionHit {
pub session_id: String,
pub session_title: String,
pub entry: SessionEntry,
pub score: usize,
pub snippet: String,
}
impl SessionHit {
#[must_use]
pub fn new(
session_id: impl Into<String>,
session_title: impl Into<String>,
entry: SessionEntry,
score: usize,
snippet: impl Into<String>,
) -> Self {
Self {
session_id: session_id.into(),
session_title: session_title.into(),
entry,
score,
snippet: snippet.into(),
}
}
}
#[cfg(not(feature = "search"))]
pub(crate) fn query_terms(query: &str) -> Vec<String> {
query
.split_whitespace()
.map(str::trim)
.filter(|term| !term.is_empty())
.map(str::to_lowercase)
.collect()
}
#[cfg(not(feature = "search"))]
pub(crate) fn entry_matches_time_range(
entry: &SessionEntry,
options: &SessionSearchOptions,
) -> bool {
let Some(timestamp) = entry.timestamp() else {
return options.start_time.is_none() && options.end_time.is_none();
};
if let Some(start) = options.start_time
&& timestamp < start.timestamp().cast_unsigned()
{
return false;
}
if let Some(end) = options.end_time
&& timestamp > end.timestamp().cast_unsigned()
{
return false;
}
true
}
#[cfg(not(feature = "search"))]
pub(crate) fn entry_matches_type(entry: &SessionEntry, options: &SessionSearchOptions) -> bool {
options.entry_types.as_ref().is_none_or(|types| {
types
.iter()
.any(|expected| expected == entry.entry_type_name())
})
}
#[cfg(not(feature = "search"))]
pub(crate) fn search_entry(entry: &SessionEntry, terms: &[String]) -> Option<(usize, String)> {
if terms.is_empty() {
return None;
}
let text = searchable_text(entry);
if text.is_empty() {
return None;
}
let haystack = text.to_lowercase();
let mut score = 0;
let mut first_match = None;
for term in terms {
let mut matches = haystack.match_indices(term);
let (idx, _) = matches.next()?;
first_match = Some(first_match.map_or(idx, |current: usize| current.min(idx)));
score += 1 + matches.count();
}
Some((score, snippet(&text, first_match.unwrap_or(0))))
}
#[cfg(feature = "search")]
pub(crate) fn searchable_text_pub(entry: &SessionEntry) -> String {
searchable_text(entry)
}
fn searchable_text(entry: &SessionEntry) -> String {
match entry {
SessionEntry::Message(message) => message_text(message),
SessionEntry::ModelChange {
from,
to,
timestamp: _,
} => format!(
"model change {} {} {} {}",
from.provider, from.model_id, to.provider, to.model_id
),
SessionEntry::ThinkingLevelChange {
from,
to,
timestamp: _,
} => {
format!("thinking level change {from} {to}")
}
SessionEntry::Compaction {
dropped_count,
tokens_before,
tokens_after,
timestamp: _,
} => format!(
"compaction dropped {dropped_count} tokens before {tokens_before} after {tokens_after}"
),
SessionEntry::Label {
text,
message_index,
timestamp: _,
} => format!("label {text} message {message_index}"),
SessionEntry::Custom {
type_name,
data,
timestamp: _,
} => format!("custom {type_name} {data}"),
}
}
fn message_text(message: &LlmMessage) -> String {
match message {
LlmMessage::User(message) => content_text(&message.content),
LlmMessage::Assistant(message) => content_text(&message.content),
LlmMessage::ToolResult(message) => {
let mut text = content_text(&message.content);
if !message.details.is_null() {
if !text.is_empty() {
text.push(' ');
}
text.push_str(&message.details.to_string());
}
text
}
_ => String::new(),
}
}
fn content_text(blocks: &[ContentBlock]) -> String {
let mut text = ContentBlock::extract_text(blocks);
for block in blocks {
match block {
ContentBlock::Thinking {
thinking,
signature: _,
} => push_part(&mut text, thinking),
ContentBlock::ToolCall {
id: _,
name,
arguments,
partial_json,
} => {
push_part(&mut text, name);
push_part(&mut text, &arguments.to_string());
if let Some(partial_json) = partial_json {
push_part(&mut text, partial_json);
}
}
ContentBlock::Extension { type_name, data } => {
push_part(&mut text, type_name);
push_part(&mut text, &data.to_string());
}
_ => {}
}
}
text
}
fn push_part(text: &mut String, part: &str) {
if part.is_empty() {
return;
}
if !text.is_empty() {
text.push(' ');
}
text.push_str(part);
}
#[cfg(feature = "search")]
pub(crate) fn snippet_from_text(text: &str, match_idx: usize) -> String {
snippet(text, match_idx)
}
fn snippet(text: &str, match_idx: usize) -> String {
if text.len() <= SNIPPET_CONTEXT_BYTES * 2 {
return text.to_string();
}
let start = floor_char_boundary(text, match_idx.saturating_sub(SNIPPET_CONTEXT_BYTES));
let end = floor_char_boundary(text, (match_idx + SNIPPET_CONTEXT_BYTES).min(text.len()));
let mut snippet = String::new();
if start > 0 {
snippet.push_str("...");
}
snippet.push_str(text[start..end].trim());
if end < text.len() {
snippet.push_str("...");
}
snippet
}
fn floor_char_boundary(text: &str, mut idx: usize) -> usize {
while idx > 0 && !text.is_char_boundary(idx) {
idx -= 1;
}
idx
}