cortex_mcp/tools/
memory_note.rs1use 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#[derive(Debug)]
44pub struct CortexMemoryNoteTool {
45 pool: Arc<Mutex<Pool>>,
46}
47
48impl CortexMemoryNoteTool {
49 #[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 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 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 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 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 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}