Skip to main content

cortex_mcp/tools/
suggest.rs

1//! `cortex_suggest` — server-initiated memory suggestions.
2//!
3//! Surfaces the memories most worth paying attention to right now:
4//!
5//! - With `query`: run FTS5 full-text search and return the top-N hits,
6//!   filtered to `status = 'active'`.
7//! - Without `query`: return the top-N active memories by confidence score
8//!   (highest first), as a salience-proxy ranking.
9//!
10//! ## Schema
11//!
12//! ```text
13//! cortex_suggest(
14//!   query?:  string,   // optional search query
15//!   limit?:  u32,      // default 3, cap 20
16//! ) -> {
17//!   suggestions: [{ memory_id: string, claim: string, salience: number, score: number }],
18//!   query_used: string | null,
19//! }
20//! ```
21//!
22//! ## Salience and score
23//!
24//! - `salience` is the memory's `confidence` value, the best uniformly
25//!   available proxy for how salient a memory is without a secondary lookup.
26//! - `score` is the FTS5 normalized BM25 score (`exp(rank)` mapped to
27//!   `(0, 1]`) when a query was provided, or equals `salience` when no
28//!   query was provided.
29//!
30//! ## Gate
31//!
32//! [`GateId::FtsRead`] — session tier, no confirmation token required.
33
34use std::sync::{Arc, Mutex};
35
36use cortex_store::repo::MemoryRepo;
37use serde_json::{json, Value};
38
39use crate::tool_handler::{GateId, ToolError, ToolHandler};
40
41/// Default number of suggestions returned when `limit` is absent.
42const DEFAULT_LIMIT: u32 = 3;
43/// Server-side cap on `limit`.
44const MAX_LIMIT: u32 = 20;
45
46/// MCP tool: `cortex_suggest`.
47///
48/// Returns the top-N memories most relevant to `query` (FTS5 ranked), or the
49/// top-N highest-confidence active memories when no query is provided.
50#[derive(Debug)]
51pub struct CortexSuggestTool {
52    pool: Arc<Mutex<cortex_store::Pool>>,
53}
54
55impl CortexSuggestTool {
56    /// Construct the tool over a shared store connection.
57    #[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        // Parse optional query string.
74        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        // Parse optional limit (default 3, cap 20).
92        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            // FTS5 path: search for relevant memories, filter to active status.
119            let limit_usize = usize::try_from(limit).unwrap_or(usize::MAX);
120            // Ask for more hits than needed to allow filtering by status.
121            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                // Skip non-active memories (candidates, quarantined, etc.).
137                if let Some(m) = record {
138                    if m.status == "active" {
139                        // Normalize BM25 rank: exp(rank) maps (-inf, 0] to (0, 1]
140                        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            // No-query path: highest-confidence active memories.
158            let mut memories = repo.list_by_status("active").map_err(|err| {
159                ToolError::Internal(format!("failed to list active memories: {err}"))
160            })?;
161
162            // Sort by confidence descending (best salience proxy available
163            // without a secondary table lookup).
164            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/// Backward-compat type alias for callers that referenced the old stub name.
193///
194/// # Deprecated
195///
196/// Use [`CortexSuggestTool`] instead.
197#[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    /// Insert a source event (idempotent guard on the same pool).
229    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    /// Insert a memory as active with the given confidence.
256    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: empty store returns empty suggestions --------------------------
299
300    #[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: no-query returns highest-salience memories --------------------
328
329    #[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        // First suggestion must be the highest-confidence memory.
368        assert_eq!(
369            suggestions[0]["claim"].as_str().unwrap(),
370            "high confidence memory",
371            "first suggestion must be highest-confidence memory"
372        );
373        // Second suggestion must be the medium-confidence memory.
374        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: query returns relevant memories --------------------------------
410
411    #[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        // Must have found at least the relevant memory.
441        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        // Each suggestion must have the required fields.
457        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        // Insert a memory as candidate (not active) -- the suggest tool must not
468        // return it even if it matches the query.
469        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            // Do NOT accept -- stays as candidate.
492        }
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: tool metadata -------------------------------------------------
506
507    #[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        // An empty/whitespace query is treated as no query -- must not error.
549        let result = tool
550            .call(json!({"query": "   "}))
551            .expect("whitespace-only query must not error");
552        assert!(result["query_used"].is_null());
553    }
554}