imp_core/tools/
session_search.rs1use async_trait::async_trait;
2use serde_json::json;
3
4use super::{Tool, ToolContext, ToolOutput};
5use crate::error::Result;
6use crate::session_index::SessionIndex;
7use crate::storage;
8
9pub struct SessionSearchTool;
10
11#[async_trait]
12impl Tool for SessionSearchTool {
13 fn name(&self) -> &str {
14 "recall"
15 }
16
17 fn label(&self) -> &str {
18 "Recall"
19 }
20
21 fn description(&self) -> &str {
22 "Search past conversations. Use when you need to recall something discussed in a previous session."
23 }
24
25 fn parameters(&self) -> serde_json::Value {
26 json!({
27 "type": "object",
28 "required": ["query"],
29 "properties": {
30 "query": {
31 "type": "string",
32 "description": "Search query (supports AND, OR, NOT, quoted phrases)"
33 },
34 "limit": {
35 "type": "integer",
36 "description": "Max results (default: 5)"
37 }
38 }
39 })
40 }
41
42 fn is_readonly(&self) -> bool {
43 true
44 }
45
46 async fn execute(
47 &self,
48 _call_id: &str,
49 params: serde_json::Value,
50 _ctx: ToolContext,
51 ) -> Result<ToolOutput> {
52 let query = params["query"].as_str().unwrap_or("");
53 if query.is_empty() {
54 return Ok(ToolOutput::error("Missing required parameter: query"));
55 }
56
57 let limit = params["limit"].as_u64().unwrap_or(5) as usize;
58
59 let index_path = index_db_path();
60 if !index_path.exists() {
61 return Ok(ToolOutput::text(
62 "No sessions indexed yet. Session search becomes available \
63 after your first conversation.",
64 ));
65 }
66
67 let index = match SessionIndex::open(&index_path) {
68 Ok(idx) => idx,
69 Err(e) => {
70 return Ok(ToolOutput::error(format!(
71 "Failed to open session index: {e}"
72 )));
73 }
74 };
75
76 let results = match index.search(query, limit) {
77 Ok(r) => r,
78 Err(e) => {
79 return Ok(ToolOutput::error(format!("Search failed: {e}")));
80 }
81 };
82
83 if results.is_empty() {
84 return Ok(ToolOutput::text(format!(
85 "No past sessions match \"{query}\"."
86 )));
87 }
88
89 let mut output = format!("Found {} result(s) for \"{}\":\n", results.len(), query);
90
91 for (i, hit) in results.iter().enumerate() {
92 let ts = format_timestamp(hit.created_at);
93 let first = hit.first_message.as_deref().unwrap_or("(no first message)");
94
95 output.push_str(&format!(
96 "\n[{}] Session from {} ({}, {} messages)\n First: \"{}\"\n {}\n",
97 i + 1,
98 ts,
99 hit.cwd,
100 hit.message_count,
101 first,
102 hit.snippet,
103 ));
104 }
105
106 Ok(ToolOutput::text(output))
107 }
108}
109
110fn index_db_path() -> std::path::PathBuf {
112 if let Some(path) =
113 storage::existing_global_file(storage::global_session_index_path, "session_index.db")
114 {
115 return path;
116 }
117 if let Some(path) = storage::legacy_data_roots()
118 .into_iter()
119 .map(|root| root.join("session_index.db"))
120 .find(|path| path.exists())
121 {
122 return path;
123 }
124 storage::global_session_index_path()
125}
126
127fn format_timestamp(ts: u64) -> String {
129 if ts == 0 {
131 return "unknown date".to_string();
132 }
133 let secs = ts;
134 let days_since_epoch = secs / 86400;
137 let years = 1970 + days_since_epoch / 365;
138 let day_in_year = days_since_epoch % 365;
139 let month = day_in_year / 30 + 1;
140 let day = day_in_year % 30 + 1;
141 format!("{years}-{month:02}-{day:02}")
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147 use crate::session::{SessionEntry, SessionManager};
148 use crate::tools::ToolContext;
149 use std::sync::Arc;
150
151 fn test_ctx() -> ToolContext {
152 let (tx, _rx) = tokio::sync::mpsc::channel(16);
153 let (cmd_tx, _cmd_rx) = tokio::sync::mpsc::channel(16);
154 ToolContext {
155 cwd: std::env::temp_dir(),
156 cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
157 update_tx: tx,
158 command_tx: cmd_tx,
159 ui: Arc::new(crate::ui::NullInterface),
160 file_cache: Arc::new(crate::tools::FileCache::new()),
161 checkpoint_state: Arc::new(crate::tools::CheckpointState::new()),
162 file_tracker: Arc::new(std::sync::Mutex::new(crate::tools::FileTracker::new())),
163 anchor_store: Arc::new(crate::tools::AnchorStore::new()),
164 lua_tool_loader: None,
165 mode: crate::config::AgentMode::Full,
166 read_max_lines: 500,
167 turn_mana_review: Arc::new(std::sync::Mutex::new(
168 crate::mana_review::TurnManaReviewAccumulator::default(),
169 )),
170 config: Arc::new(crate::config::Config::default()),
171 run_policy: Default::default(),
172 supporting_provenance: Vec::new(),
173 }
174 }
175
176 #[allow(dead_code)]
177 fn seed_index(dir: &std::path::Path) -> std::path::PathBuf {
178 let db_path = dir.join("index.db");
179 let index = SessionIndex::open(&db_path).unwrap();
180
181 let session_dir = dir.join("sessions");
182 let cwd = dir.join("project");
183 let mut session = SessionManager::new(&cwd, &session_dir).unwrap();
184 session
185 .append(SessionEntry::Message {
186 id: "m1".to_string(),
187 parent_id: None,
188 message: imp_llm::Message::user("Help me deploy kubernetes"),
189 })
190 .unwrap();
191 session
192 .append(SessionEntry::Message {
193 id: "a1".to_string(),
194 parent_id: None,
195 message: imp_llm::Message::Assistant(imp_llm::AssistantMessage {
196 content: vec![imp_llm::ContentBlock::Text {
197 text: "I'll help with the kubernetes deployment".to_string(),
198 }],
199 usage: None,
200 stop_reason: imp_llm::StopReason::EndTurn,
201 timestamp: 0,
202 }),
203 })
204 .unwrap();
205 index.index_session(&session).unwrap();
206
207 db_path
208 }
209
210 #[tokio::test]
211 async fn recall_tool_missing_query() {
212 let tool = SessionSearchTool;
213 let r = tool.execute("c1", json!({}), test_ctx()).await.unwrap();
214 assert!(r.is_error);
215 }
216
217 #[tokio::test]
218 async fn recall_tool_missing_db() {
219 let tool = SessionSearchTool;
221 let r = tool
225 .execute("c1", json!({"query": "test"}), test_ctx())
226 .await
227 .unwrap();
228 assert!(!r.is_error || r.text_content().unwrap().contains("session"));
230 }
231}