Skip to main content

cortex_mcp/tools/
memory_note.rs

1//! `cortex_memory_note` MCP tool handler.
2//!
3//! Directly admits an operator-written fact as an active memory, bypassing
4//! the reflection pipeline. The claim goes straight into the active memory
5//! store without a candidate stage or an LLM — the operator's act of writing
6//! the note is itself the provenance.
7//!
8//! Gate: [`GateId::SessionWrite`].
9//! Tier: supervised — executes and logs on every call (no confirmation token
10//! required).
11
12use std::sync::{Arc, Mutex};
13
14use chrono::Utc;
15use cortex_core::MemoryId;
16use cortex_store::repo::memories::MemoryCandidate;
17use cortex_store::repo::MemoryRepo;
18use cortex_store::Pool;
19use serde_json::{json, Value};
20
21use crate::tool_handler::{GateId, ToolError, ToolHandler};
22
23/// MCP tool: `cortex_memory_note`.
24///
25/// Schema:
26/// ```text
27/// cortex_memory_note(
28///   claim:       string,    // required, non-empty
29///   domains?:    [string],  // default ["operator_note"]
30///   confidence?: float,     // default 0.9, must be in [0.0, 1.0]
31/// ) -> {
32///   memory_id:  string,
33///   claim:      string,
34///   domains:    [string],
35///   confidence: float,
36///   status:     "active",
37/// }
38/// ```
39///
40/// When `claim` is empty the call returns `ToolError::InvalidParams`.
41/// When `confidence` is outside `[0.0, 1.0]` the call returns
42/// `ToolError::InvalidParams`.
43#[derive(Debug)]
44pub struct CortexMemoryNoteTool {
45    pool: Arc<Mutex<Pool>>,
46}
47
48impl CortexMemoryNoteTool {
49    /// Construct the tool over a shared, mutex-guarded store connection.
50    #[must_use]
51    pub fn new(pool: Arc<Mutex<Pool>>) -> Self {
52        Self { pool }
53    }
54}
55
56impl ToolHandler for CortexMemoryNoteTool {
57    fn name(&self) -> &'static str {
58        "cortex_memory_note"
59    }
60
61    fn gate_set(&self) -> &'static [GateId] {
62        &[GateId::SessionWrite]
63    }
64
65    fn call(&self, params: Value) -> Result<Value, ToolError> {
66        // ── claim (required, non-empty) ─────────────────────────────────────
67        let claim = params["claim"]
68            .as_str()
69            .map(str::trim)
70            .filter(|s| !s.is_empty())
71            .ok_or_else(|| ToolError::InvalidParams("claim is required and must not be empty".into()))?
72            .to_string();
73
74        // ── confidence (optional, default 0.9, range [0.0, 1.0]) ────────────
75        let confidence = if params["confidence"].is_null() || params.get("confidence").is_none() {
76            0.9_f64
77        } else {
78            params["confidence"]
79                .as_f64()
80                .ok_or_else(|| ToolError::InvalidParams("confidence must be a number".into()))?
81        };
82
83        if !(0.0..=1.0).contains(&confidence) {
84            return Err(ToolError::InvalidParams(
85                "confidence must be in [0.0, 1.0]".into(),
86            ));
87        }
88
89        // ── domains (optional, default ["operator_note"]) ────────────────────
90        let domains: Vec<String> = if let Some(arr) = params["domains"].as_array() {
91            if arr.is_empty() {
92                vec!["operator_note".to_string()]
93            } else {
94                arr.iter()
95                    .filter_map(|v| v.as_str().map(str::to_string))
96                    .collect()
97            }
98        } else {
99            vec!["operator_note".to_string()]
100        };
101
102        tracing::info!(
103            claim = %claim,
104            confidence = confidence,
105            domains = ?domains,
106            "cortex_memory_note via MCP"
107        );
108
109        let pool = self
110            .pool
111            .lock()
112            .map_err(|err| ToolError::Internal(format!("pool lock poisoned: {err}")))?;
113        let repo = MemoryRepo::new(&pool);
114        let now = Utc::now();
115        let id = MemoryId::new();
116
117        let candidate = MemoryCandidate {
118            id,
119            memory_type: "operator_note".to_string(),
120            claim: claim.clone(),
121            confidence,
122            authority: "operator".to_string(),
123            domains_json: serde_json::to_value(&domains)
124                .unwrap_or_else(|_| serde_json::json!([])),
125            source_events_json: json!([]),
126            source_episodes_json: json!([]),
127            salience_json: json!({}),
128            applies_when_json: json!(null),
129            does_not_apply_when_json: json!(null),
130            created_at: now,
131            updated_at: now,
132        };
133
134        repo.insert_candidate(&candidate).map_err(|err| {
135            ToolError::Internal(format!("failed to insert operator note: {err}"))
136        })?;
137
138        // Promote directly to active — operator note is self-attesting.
139        repo.set_active(&id, now).map_err(|err| {
140            ToolError::Internal(format!("failed to activate operator note {id}: {err}"))
141        })?;
142
143        let memory_id = id.to_string();
144
145        Ok(json!({
146            "memory_id":  memory_id,
147            "claim":      claim,
148            "domains":    domains,
149            "confidence": confidence,
150            "status":     "active",
151        }))
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use serde_json::json;
159
160    fn make_tool() -> CortexMemoryNoteTool {
161        let pool = rusqlite::Connection::open_in_memory().expect("in-memory sqlite");
162        cortex_store::migrate::apply_pending(&pool).expect("migrations");
163        CortexMemoryNoteTool::new(Arc::new(Mutex::new(pool)))
164    }
165
166    #[test]
167    fn name_and_gate() {
168        let tool = make_tool();
169        assert_eq!(tool.name(), "cortex_memory_note");
170        assert_eq!(tool.gate_set(), &[GateId::SessionWrite]);
171    }
172
173    #[test]
174    fn empty_claim_returns_invalid_params() {
175        let tool = make_tool();
176        let err = tool.call(json!({ "claim": "" })).unwrap_err();
177        assert!(matches!(err, ToolError::InvalidParams(_)));
178    }
179
180    #[test]
181    fn blank_claim_returns_invalid_params() {
182        let tool = make_tool();
183        let err = tool.call(json!({ "claim": "   " })).unwrap_err();
184        assert!(matches!(err, ToolError::InvalidParams(_)));
185    }
186
187    #[test]
188    fn missing_claim_returns_invalid_params() {
189        let tool = make_tool();
190        let err = tool.call(json!({ "confidence": 0.8 })).unwrap_err();
191        assert!(matches!(err, ToolError::InvalidParams(_)));
192    }
193
194    #[test]
195    fn out_of_range_confidence_returns_invalid_params() {
196        let tool = make_tool();
197        let err = tool
198            .call(json!({ "claim": "fact", "confidence": 1.5 }))
199            .unwrap_err();
200        assert!(matches!(err, ToolError::InvalidParams(_)));
201
202        let err2 = tool
203            .call(json!({ "claim": "fact", "confidence": -0.1 }))
204            .unwrap_err();
205        assert!(matches!(err2, ToolError::InvalidParams(_)));
206    }
207
208    #[test]
209    fn valid_claim_creates_active_memory() {
210        let tool = make_tool();
211        let result = tool
212            .call(json!({
213                "claim":      "Cortex uses BLAKE3 for embedding stubs",
214                "domains":    ["cortex", "embeddings"],
215                "confidence": 0.95,
216            }))
217            .unwrap();
218
219        assert_eq!(result["status"], "active");
220        assert_eq!(result["claim"], "Cortex uses BLAKE3 for embedding stubs");
221        assert_eq!(result["confidence"], 0.95);
222        let domains = result["domains"].as_array().unwrap();
223        assert!(domains.iter().any(|d| d == "cortex"));
224        assert!(domains.iter().any(|d| d == "embeddings"));
225        // memory_id must be a non-empty string
226        assert!(!result["memory_id"].as_str().unwrap_or("").is_empty());
227    }
228
229    #[test]
230    fn default_domain_when_none_provided() {
231        let tool = make_tool();
232        let result = tool
233            .call(json!({ "claim": "A bare fact with no domain" }))
234            .unwrap();
235        let domains = result["domains"].as_array().unwrap();
236        assert_eq!(domains.len(), 1);
237        assert_eq!(domains[0], "operator_note");
238    }
239
240    #[test]
241    fn default_confidence_when_none_provided() {
242        let tool = make_tool();
243        let result = tool
244            .call(json!({ "claim": "A bare fact" }))
245            .unwrap();
246        assert!((result["confidence"].as_f64().unwrap() - 0.9).abs() < f64::EPSILON);
247    }
248}