Skip to main content

imp_core/tools/
session_search.rs

1use 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
110/// Default path for the session index database.
111fn 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
127/// Format a unix timestamp into a human-readable date.
128fn format_timestamp(ts: u64) -> String {
129    // Simple formatting without chrono dependency
130    if ts == 0 {
131        return "unknown date".to_string();
132    }
133    let secs = ts;
134    // Rough formatting: just show the timestamp as-is for now
135    // A full implementation would use chrono, but we avoid the dependency
136    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        // With no index DB, should return a helpful message (not an error)
220        let tool = SessionSearchTool;
221        // We can't easily override the path in this test without refactoring,
222        // but we can verify the tool handles the case gracefully
223        // (The actual path check happens at runtime)
224        let r = tool
225            .execute("c1", json!({"query": "test"}), test_ctx())
226            .await
227            .unwrap();
228        // Either returns "No sessions indexed" or actual results depending on user's state
229        assert!(!r.is_error || r.text_content().unwrap().contains("session"));
230    }
231}