use async_trait::async_trait;
use serde_json::json;
use super::{Tool, ToolContext, ToolOutput};
use crate::error::Result;
use crate::session_index::SessionIndex;
use crate::storage;
pub struct SessionSearchTool;
#[async_trait]
impl Tool for SessionSearchTool {
fn name(&self) -> &str {
"recall"
}
fn label(&self) -> &str {
"Recall"
}
fn description(&self) -> &str {
"Search past conversations. Use when you need to recall something discussed in a previous session."
}
fn parameters(&self) -> serde_json::Value {
json!({
"type": "object",
"required": ["query"],
"properties": {
"query": {
"type": "string",
"description": "Search query (supports AND, OR, NOT, quoted phrases)"
},
"limit": {
"type": "integer",
"description": "Max results (default: 5)"
}
}
})
}
fn is_readonly(&self) -> bool {
true
}
async fn execute(
&self,
_call_id: &str,
params: serde_json::Value,
_ctx: ToolContext,
) -> Result<ToolOutput> {
let query = params["query"].as_str().unwrap_or("");
if query.is_empty() {
return Ok(ToolOutput::error("Missing required parameter: query"));
}
let limit = params["limit"].as_u64().unwrap_or(5) as usize;
let index_path = index_db_path();
if !index_path.exists() {
return Ok(ToolOutput::text(
"No sessions indexed yet. Session search becomes available \
after your first conversation.",
));
}
let index = match SessionIndex::open(&index_path) {
Ok(idx) => idx,
Err(e) => {
return Ok(ToolOutput::error(format!(
"Failed to open session index: {e}"
)));
}
};
let results = match index.search(query, limit) {
Ok(r) => r,
Err(e) => {
return Ok(ToolOutput::error(format!("Search failed: {e}")));
}
};
if results.is_empty() {
return Ok(ToolOutput::text(format!(
"No past sessions match \"{query}\"."
)));
}
let mut output = format!("Found {} result(s) for \"{}\":\n", results.len(), query);
for (i, hit) in results.iter().enumerate() {
let ts = format_timestamp(hit.created_at);
let first = hit.first_message.as_deref().unwrap_or("(no first message)");
output.push_str(&format!(
"\n[{}] Session from {} ({}, {} messages)\n First: \"{}\"\n {}\n",
i + 1,
ts,
hit.cwd,
hit.message_count,
first,
hit.snippet,
));
}
Ok(ToolOutput::text(output))
}
}
fn index_db_path() -> std::path::PathBuf {
if let Some(path) =
storage::existing_global_file(storage::global_session_index_path, "session_index.db")
{
return path;
}
if let Some(path) = storage::legacy_data_roots()
.into_iter()
.map(|root| root.join("session_index.db"))
.find(|path| path.exists())
{
return path;
}
storage::global_session_index_path()
}
fn format_timestamp(ts: u64) -> String {
if ts == 0 {
return "unknown date".to_string();
}
let secs = ts;
let days_since_epoch = secs / 86400;
let years = 1970 + days_since_epoch / 365;
let day_in_year = days_since_epoch % 365;
let month = day_in_year / 30 + 1;
let day = day_in_year % 30 + 1;
format!("{years}-{month:02}-{day:02}")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::{SessionEntry, SessionManager};
use crate::tools::ToolContext;
use std::sync::Arc;
fn test_ctx() -> ToolContext {
let (tx, _rx) = tokio::sync::mpsc::channel(16);
let (cmd_tx, _cmd_rx) = tokio::sync::mpsc::channel(16);
ToolContext {
cwd: std::env::temp_dir(),
cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
update_tx: tx,
command_tx: cmd_tx,
ui: Arc::new(crate::ui::NullInterface),
file_cache: Arc::new(crate::tools::FileCache::new()),
checkpoint_state: Arc::new(crate::tools::CheckpointState::new()),
file_tracker: Arc::new(std::sync::Mutex::new(crate::tools::FileTracker::new())),
anchor_store: Arc::new(crate::tools::AnchorStore::new()),
lua_tool_loader: None,
mode: crate::config::AgentMode::Full,
read_max_lines: 500,
turn_mana_review: Arc::new(std::sync::Mutex::new(
crate::mana_review::TurnManaReviewAccumulator::default(),
)),
config: Arc::new(crate::config::Config::default()),
}
}
#[allow(dead_code)]
fn seed_index(dir: &std::path::Path) -> std::path::PathBuf {
let db_path = dir.join("index.db");
let index = SessionIndex::open(&db_path).unwrap();
let session_dir = dir.join("sessions");
let cwd = dir.join("project");
let mut session = SessionManager::new(&cwd, &session_dir).unwrap();
session
.append(SessionEntry::Message {
id: "m1".to_string(),
parent_id: None,
message: imp_llm::Message::user("Help me deploy kubernetes"),
})
.unwrap();
session
.append(SessionEntry::Message {
id: "a1".to_string(),
parent_id: None,
message: imp_llm::Message::Assistant(imp_llm::AssistantMessage {
content: vec![imp_llm::ContentBlock::Text {
text: "I'll help with the kubernetes deployment".to_string(),
}],
usage: None,
stop_reason: imp_llm::StopReason::EndTurn,
timestamp: 0,
}),
})
.unwrap();
index.index_session(&session).unwrap();
db_path
}
#[tokio::test]
async fn recall_tool_missing_query() {
let tool = SessionSearchTool;
let r = tool.execute("c1", json!({}), test_ctx()).await.unwrap();
assert!(r.is_error);
}
#[tokio::test]
async fn recall_tool_missing_db() {
let tool = SessionSearchTool;
let r = tool
.execute("c1", json!({"query": "test"}), test_ctx())
.await
.unwrap();
assert!(!r.is_error || r.text_content().unwrap().contains("session"));
}
}