1use std::sync::{Arc, Mutex};
35
36use cortex_store::repo::MemoryRepo;
37use serde_json::{json, Value};
38
39use crate::tool_handler::{GateId, ToolError, ToolHandler};
40
41const DEFAULT_LIMIT: u32 = 3;
43const MAX_LIMIT: u32 = 20;
45
46#[derive(Debug)]
51pub struct CortexSuggestTool {
52 pool: Arc<Mutex<cortex_store::Pool>>,
53}
54
55impl CortexSuggestTool {
56 #[must_use]
58 pub fn new(pool: Arc<Mutex<cortex_store::Pool>>) -> Self {
59 Self { pool }
60 }
61}
62
63impl ToolHandler for CortexSuggestTool {
64 fn name(&self) -> &'static str {
65 "cortex_suggest"
66 }
67
68 fn gate_set(&self) -> &'static [GateId] {
69 &[GateId::FtsRead]
70 }
71
72 fn call(&self, params: Value) -> Result<Value, ToolError> {
73 let query: Option<String> = match params.get("query") {
75 None | Some(Value::Null) => None,
76 Some(Value::String(s)) => {
77 let trimmed = s.trim();
78 if trimmed.is_empty() {
79 None
80 } else {
81 Some(trimmed.to_owned())
82 }
83 }
84 Some(other) => {
85 return Err(ToolError::InvalidParams(format!(
86 "query must be a string, got {other}"
87 )));
88 }
89 };
90
91 let limit: u32 = match params.get("limit") {
93 None | Some(Value::Null) => DEFAULT_LIMIT,
94 Some(v) => {
95 let n = v.as_u64().ok_or_else(|| {
96 ToolError::InvalidParams("limit must be a non-negative integer".into())
97 })?;
98 let n = u32::try_from(n).unwrap_or(MAX_LIMIT);
99 n.min(MAX_LIMIT)
100 }
101 };
102
103 if limit == 0 {
104 return Ok(json!({
105 "suggestions": [],
106 "query_used": query,
107 }));
108 }
109
110 let pool = self
111 .pool
112 .lock()
113 .map_err(|err| ToolError::Internal(format!("failed to acquire store lock: {err}")))?;
114
115 let repo = MemoryRepo::new(&pool);
116
117 let suggestions: Vec<Value> = if let Some(ref q) = query {
118 let limit_usize = usize::try_from(limit).unwrap_or(usize::MAX);
120 let fetch_limit = limit_usize.saturating_mul(4).max(limit_usize + 10);
122
123 let hits = repo
124 .fts5_search(q, fetch_limit)
125 .map_err(|err| ToolError::Internal(format!("fts5 search failed: {err}")))?;
126
127 let mut results = Vec::with_capacity(limit_usize);
128 for (memory_id, raw_rank) in hits {
129 if results.len() >= limit_usize {
130 break;
131 }
132 let record = repo.get_by_id(&memory_id).map_err(|err| {
133 ToolError::Internal(format!("failed to fetch memory {memory_id}: {err}"))
134 })?;
135
136 if let Some(m) = record {
138 if m.status == "active" {
139 let score = if raw_rank.is_finite() {
141 raw_rank.exp().clamp(0.0, 1.0)
142 } else {
143 0.0_f32
144 };
145 let salience = m.confidence as f32;
146 results.push(json!({
147 "memory_id": m.id.to_string(),
148 "claim": m.claim,
149 "salience": salience,
150 "score": score,
151 }));
152 }
153 }
154 }
155 results
156 } else {
157 let mut memories = repo.list_by_status("active").map_err(|err| {
159 ToolError::Internal(format!("failed to list active memories: {err}"))
160 })?;
161
162 memories.sort_by(|a, b| {
165 b.confidence
166 .partial_cmp(&a.confidence)
167 .unwrap_or(std::cmp::Ordering::Equal)
168 });
169
170 memories
171 .into_iter()
172 .take(limit as usize)
173 .map(|m| {
174 let salience = m.confidence as f32;
175 json!({
176 "memory_id": m.id.to_string(),
177 "claim": m.claim,
178 "salience": salience,
179 "score": salience,
180 })
181 })
182 .collect()
183 };
184
185 Ok(json!({
186 "suggestions": suggestions,
187 "query_used": query,
188 }))
189 }
190}
191
192#[deprecated(since = "0.1.0", note = "use CortexSuggestTool instead")]
198pub type CortexSuggestStub = CortexSuggestTool;
199
200#[cfg(test)]
201mod tests {
202 use std::sync::{Arc, Mutex};
203
204 use chrono::{TimeZone, Utc};
205 use cortex_core::{AuditRecordId, Event, EventSource, EventType, SCHEMA_VERSION};
206 use rusqlite::Connection;
207 use serde_json::json;
208
209 use super::*;
210 use cortex_store::migrate::apply_pending;
211 use cortex_store::repo::memories::accept_candidate_policy_decision_test_allow;
212 use cortex_store::repo::{EventRepo, MemoryAcceptanceAudit, MemoryCandidate, MemoryRepo};
213
214 fn make_pool() -> Arc<Mutex<Connection>> {
215 let conn = Connection::open_in_memory().expect("in-memory sqlite");
216 apply_pending(&conn).expect("apply_pending");
217 Arc::new(Mutex::new(conn))
218 }
219
220 fn make_tool(pool: Arc<Mutex<Connection>>) -> CortexSuggestTool {
221 CortexSuggestTool::new(pool)
222 }
223
224 fn ts(second: u32) -> chrono::DateTime<Utc> {
225 Utc.with_ymd_and_hms(2026, 1, 1, 12, 0, second).unwrap()
226 }
227
228 fn ensure_event(pool: &Connection, event_id: &str, second: u32) {
230 let parsed_id = event_id.parse().unwrap();
231 let repo = EventRepo::new(pool);
232 if repo.get_by_id(&parsed_id).expect("query event").is_some() {
233 return;
234 }
235 repo.append(&Event {
236 id: parsed_id,
237 schema_version: SCHEMA_VERSION,
238 observed_at: ts(second),
239 recorded_at: ts(second),
240 source: EventSource::Tool {
241 name: "suggest-test".into(),
242 },
243 event_type: EventType::ToolResult,
244 trace_id: None,
245 session_id: Some("suggest-test".into()),
246 domain_tags: vec!["test".into()],
247 payload: json!({}),
248 payload_hash: format!("hash-{second}"),
249 prev_event_hash: None,
250 event_hash: format!("ehash-{second}"),
251 })
252 .expect("append event");
253 }
254
255 fn insert_active(
257 pool: &Connection,
258 memory_id: &str,
259 claim: &str,
260 confidence: f64,
261 event_id: &str,
262 second: u32,
263 ) {
264 ensure_event(pool, event_id, second);
265 let repo = MemoryRepo::new(pool);
266 let candidate = MemoryCandidate {
267 id: memory_id.parse().unwrap(),
268 memory_type: "semantic".into(),
269 claim: claim.into(),
270 source_episodes_json: json!([]),
271 source_events_json: json!([event_id]),
272 domains_json: json!(["test"]),
273 salience_json: json!({"score": confidence}),
274 confidence,
275 authority: "user".into(),
276 applies_when_json: json!([]),
277 does_not_apply_when_json: json!([]),
278 created_at: ts(second),
279 updated_at: ts(second),
280 };
281 repo.insert_candidate(&candidate).expect("insert candidate");
282 let audit = MemoryAcceptanceAudit {
283 id: AuditRecordId::new(),
284 actor_json: json!({"kind": "test"}),
285 reason: "suggest test".into(),
286 source_refs_json: json!([memory_id]),
287 created_at: ts(second + 1),
288 };
289 repo.accept_candidate(
290 &memory_id.parse().unwrap(),
291 ts(second + 2),
292 &audit,
293 &accept_candidate_policy_decision_test_allow(),
294 )
295 .expect("accept candidate");
296 }
297
298 #[test]
301 fn empty_store_returns_empty_suggestions() {
302 let pool = make_pool();
303 let tool = make_tool(pool);
304 let result = tool.call(json!({})).expect("call must not error");
305 assert_eq!(
306 result["suggestions"],
307 json!([]),
308 "empty store must produce empty suggestions"
309 );
310 assert!(
311 result["query_used"].is_null(),
312 "query_used must be null when no query was supplied"
313 );
314 }
315
316 #[test]
317 fn empty_store_with_query_returns_empty_suggestions() {
318 let pool = make_pool();
319 let tool = make_tool(pool);
320 let result = tool
321 .call(json!({"query": "architecture"}))
322 .expect("call must not error");
323 assert_eq!(result["suggestions"], json!([]));
324 assert_eq!(result["query_used"], json!("architecture"));
325 }
326
327 #[test]
330 fn no_query_returns_highest_confidence_memories() {
331 let pool = make_pool();
332 {
333 let conn = pool.lock().unwrap();
334 insert_active(
335 &conn,
336 "mem_01ARZ3NDEKTSV4RRFFQ69G5FAV",
337 "low confidence memory",
338 0.3,
339 "evt_01ARZ3NDEKTSV4RRFFQ69G5FA1",
340 0,
341 );
342 insert_active(
343 &conn,
344 "mem_01ARZ3NDEKTSV4RRFFQ69G5FB1",
345 "high confidence memory",
346 0.95,
347 "evt_01ARZ3NDEKTSV4RRFFQ69G5FA2",
348 10,
349 );
350 insert_active(
351 &conn,
352 "mem_01ARZ3NDEKTSV4RRFFQ69G5FC1",
353 "medium confidence memory",
354 0.6,
355 "evt_01ARZ3NDEKTSV4RRFFQ69G5FA3",
356 20,
357 );
358 }
359
360 let tool = make_tool(pool);
361 let result = tool
362 .call(json!({"limit": 2}))
363 .expect("call must not error");
364 let suggestions = result["suggestions"].as_array().expect("suggestions array");
365 assert_eq!(suggestions.len(), 2, "limit=2 must return 2 suggestions");
366
367 assert_eq!(
369 suggestions[0]["claim"].as_str().unwrap(),
370 "high confidence memory",
371 "first suggestion must be highest-confidence memory"
372 );
373 assert_eq!(
375 suggestions[1]["claim"].as_str().unwrap(),
376 "medium confidence memory",
377 "second suggestion must be medium-confidence memory"
378 );
379
380 assert!(
381 result["query_used"].is_null(),
382 "query_used must be null for no-query path"
383 );
384 }
385
386 #[test]
387 fn no_query_default_limit_is_three() {
388 let pool = make_pool();
389 {
390 let conn = pool.lock().unwrap();
391 for (i, conf) in [0.9_f64, 0.8, 0.7, 0.6, 0.5].iter().enumerate() {
392 let i = i as u32;
393 let mem_id = format!("mem_01ARZ3NDEKTSV4RRFFQ69G5F{:02}", i + 10);
394 let evt_id = format!("evt_01ARZ3NDEKTSV4RRFFQ69G5F{:02}", i + 10);
395 insert_active(&conn, &mem_id, &format!("memory {i}"), *conf, &evt_id, i * 10);
396 }
397 }
398
399 let tool = make_tool(pool);
400 let result = tool.call(json!({})).expect("call must not error");
401 let suggestions = result["suggestions"].as_array().expect("suggestions array");
402 assert_eq!(
403 suggestions.len(),
404 3,
405 "default limit must return 3 suggestions"
406 );
407 }
408
409 #[test]
412 fn query_returns_relevant_memories() {
413 let pool = make_pool();
414 {
415 let conn = pool.lock().unwrap();
416 insert_active(
417 &conn,
418 "mem_01ARZ3NDEKTSV4RRFFQ69G5FAV",
419 "trust boundary architecture decisions should be documented",
420 0.8,
421 "evt_01ARZ3NDEKTSV4RRFFQ69G5FA1",
422 0,
423 );
424 insert_active(
425 &conn,
426 "mem_01ARZ3NDEKTSV4RRFFQ69G5FB1",
427 "database connection pooling improves throughput",
428 0.7,
429 "evt_01ARZ3NDEKTSV4RRFFQ69G5FA2",
430 10,
431 );
432 }
433
434 let tool = make_tool(pool);
435 let result = tool
436 .call(json!({"query": "trust architecture", "limit": 3}))
437 .expect("call must not error");
438 let suggestions = result["suggestions"].as_array().expect("suggestions array");
439
440 assert!(
442 !suggestions.is_empty(),
443 "FTS5 query must return at least one match"
444 );
445 assert!(
446 suggestions
447 .iter()
448 .any(|s| s["claim"].as_str().unwrap_or("").contains("trust boundary")),
449 "FTS5 query must surface the trust-boundary memory"
450 );
451 assert_eq!(
452 result["query_used"].as_str().unwrap(),
453 "trust architecture"
454 );
455
456 for s in suggestions {
458 assert!(s.get("memory_id").is_some(), "suggestion must have memory_id");
459 assert!(s.get("claim").is_some(), "suggestion must have claim");
460 assert!(s.get("salience").is_some(), "suggestion must have salience");
461 assert!(s.get("score").is_some(), "suggestion must have score");
462 }
463 }
464
465 #[test]
466 fn query_only_surfaces_active_memories() {
467 let pool = make_pool();
470 {
471 let conn = pool.lock().unwrap();
472 let repo = MemoryRepo::new(&conn);
473 ensure_event(&conn, "evt_01ARZ3NDEKTSV4RRFFQ69G5FA1", 0);
474 let candidate = MemoryCandidate {
475 id: "mem_01ARZ3NDEKTSV4RRFFQ69G5FAV".parse().unwrap(),
476 memory_type: "semantic".into(),
477 claim: "candidate trust architecture memory".into(),
478 source_episodes_json: json!([]),
479 source_events_json: json!(["evt_01ARZ3NDEKTSV4RRFFQ69G5FA1"]),
480 domains_json: json!(["test"]),
481 salience_json: json!({}),
482 confidence: 0.9,
483 authority: "user".into(),
484 applies_when_json: json!([]),
485 does_not_apply_when_json: json!([]),
486 created_at: ts(0),
487 updated_at: ts(0),
488 };
489 repo.insert_candidate(&candidate)
490 .expect("insert candidate");
491 }
493
494 let tool = make_tool(pool);
495 let result = tool
496 .call(json!({"query": "trust architecture"}))
497 .expect("call must not error");
498 let suggestions = result["suggestions"].as_array().expect("suggestions array");
499 assert!(
500 suggestions.is_empty(),
501 "candidate memories must not appear in suggestions"
502 );
503 }
504
505 #[test]
508 fn name_is_cortex_suggest() {
509 let pool = make_pool();
510 let tool = make_tool(pool);
511 assert_eq!(tool.name(), "cortex_suggest");
512 }
513
514 #[test]
515 fn gate_set_contains_fts_read() {
516 let pool = make_pool();
517 let tool = make_tool(pool);
518 assert!(
519 tool.gate_set().contains(&GateId::FtsRead),
520 "cortex_suggest must declare GateId::FtsRead"
521 );
522 }
523
524 #[test]
525 fn zero_limit_returns_empty_immediately() {
526 let pool = make_pool();
527 let tool = make_tool(pool);
528 let result = tool
529 .call(json!({"limit": 0}))
530 .expect("zero limit must not error");
531 assert_eq!(result["suggestions"], json!([]));
532 }
533
534 #[test]
535 fn invalid_query_type_returns_error() {
536 let pool = make_pool();
537 let tool = make_tool(pool);
538 let err = tool
539 .call(json!({"query": 42}))
540 .expect_err("non-string query must return an error");
541 assert!(matches!(err, ToolError::InvalidParams(_)));
542 }
543
544 #[test]
545 fn empty_string_query_treated_as_no_query() {
546 let pool = make_pool();
547 let tool = make_tool(pool);
548 let result = tool
550 .call(json!({"query": " "}))
551 .expect("whitespace-only query must not error");
552 assert!(result["query_used"].is_null());
553 }
554}