use std::sync::{Arc, Mutex};
use chrono::Utc;
use cortex_core::MemoryId;
use cortex_store::repo::memories::MemoryCandidate;
use cortex_store::repo::MemoryRepo;
use cortex_store::Pool;
use serde_json::{json, Value};
use crate::tool_handler::{GateId, ToolError, ToolHandler};
#[derive(Debug)]
pub struct CortexMemoryNoteTool {
pool: Arc<Mutex<Pool>>,
}
impl CortexMemoryNoteTool {
#[must_use]
pub fn new(pool: Arc<Mutex<Pool>>) -> Self {
Self { pool }
}
}
impl ToolHandler for CortexMemoryNoteTool {
fn name(&self) -> &'static str {
"cortex_memory_note"
}
fn gate_set(&self) -> &'static [GateId] {
&[GateId::SessionWrite]
}
fn call(&self, params: Value) -> Result<Value, ToolError> {
let claim = params["claim"]
.as_str()
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| ToolError::InvalidParams("claim is required and must not be empty".into()))?
.to_string();
let confidence = if params["confidence"].is_null() || params.get("confidence").is_none() {
0.9_f64
} else {
params["confidence"]
.as_f64()
.ok_or_else(|| ToolError::InvalidParams("confidence must be a number".into()))?
};
if !(0.0..=1.0).contains(&confidence) {
return Err(ToolError::InvalidParams(
"confidence must be in [0.0, 1.0]".into(),
));
}
let domains: Vec<String> = if let Some(arr) = params["domains"].as_array() {
if arr.is_empty() {
vec!["operator_note".to_string()]
} else {
arr.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
}
} else {
vec!["operator_note".to_string()]
};
tracing::info!(
claim = %claim,
confidence = confidence,
domains = ?domains,
"cortex_memory_note via MCP"
);
let pool = self
.pool
.lock()
.map_err(|err| ToolError::Internal(format!("pool lock poisoned: {err}")))?;
let repo = MemoryRepo::new(&pool);
let now = Utc::now();
let id = MemoryId::new();
let candidate = MemoryCandidate {
id,
memory_type: "operator_note".to_string(),
claim: claim.clone(),
confidence,
authority: "operator".to_string(),
domains_json: serde_json::to_value(&domains)
.unwrap_or_else(|_| serde_json::json!([])),
source_events_json: json!([]),
source_episodes_json: json!([]),
salience_json: json!({}),
applies_when_json: json!(null),
does_not_apply_when_json: json!(null),
created_at: now,
updated_at: now,
};
repo.insert_candidate(&candidate).map_err(|err| {
ToolError::Internal(format!("failed to insert operator note: {err}"))
})?;
repo.set_active(&id, now).map_err(|err| {
ToolError::Internal(format!("failed to activate operator note {id}: {err}"))
})?;
let memory_id = id.to_string();
Ok(json!({
"memory_id": memory_id,
"claim": claim,
"domains": domains,
"confidence": confidence,
"status": "active",
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn make_tool() -> CortexMemoryNoteTool {
let pool = rusqlite::Connection::open_in_memory().expect("in-memory sqlite");
cortex_store::migrate::apply_pending(&pool).expect("migrations");
CortexMemoryNoteTool::new(Arc::new(Mutex::new(pool)))
}
#[test]
fn name_and_gate() {
let tool = make_tool();
assert_eq!(tool.name(), "cortex_memory_note");
assert_eq!(tool.gate_set(), &[GateId::SessionWrite]);
}
#[test]
fn empty_claim_returns_invalid_params() {
let tool = make_tool();
let err = tool.call(json!({ "claim": "" })).unwrap_err();
assert!(matches!(err, ToolError::InvalidParams(_)));
}
#[test]
fn blank_claim_returns_invalid_params() {
let tool = make_tool();
let err = tool.call(json!({ "claim": " " })).unwrap_err();
assert!(matches!(err, ToolError::InvalidParams(_)));
}
#[test]
fn missing_claim_returns_invalid_params() {
let tool = make_tool();
let err = tool.call(json!({ "confidence": 0.8 })).unwrap_err();
assert!(matches!(err, ToolError::InvalidParams(_)));
}
#[test]
fn out_of_range_confidence_returns_invalid_params() {
let tool = make_tool();
let err = tool
.call(json!({ "claim": "fact", "confidence": 1.5 }))
.unwrap_err();
assert!(matches!(err, ToolError::InvalidParams(_)));
let err2 = tool
.call(json!({ "claim": "fact", "confidence": -0.1 }))
.unwrap_err();
assert!(matches!(err2, ToolError::InvalidParams(_)));
}
#[test]
fn valid_claim_creates_active_memory() {
let tool = make_tool();
let result = tool
.call(json!({
"claim": "Cortex uses BLAKE3 for embedding stubs",
"domains": ["cortex", "embeddings"],
"confidence": 0.95,
}))
.unwrap();
assert_eq!(result["status"], "active");
assert_eq!(result["claim"], "Cortex uses BLAKE3 for embedding stubs");
assert_eq!(result["confidence"], 0.95);
let domains = result["domains"].as_array().unwrap();
assert!(domains.iter().any(|d| d == "cortex"));
assert!(domains.iter().any(|d| d == "embeddings"));
assert!(!result["memory_id"].as_str().unwrap_or("").is_empty());
}
#[test]
fn default_domain_when_none_provided() {
let tool = make_tool();
let result = tool
.call(json!({ "claim": "A bare fact with no domain" }))
.unwrap();
let domains = result["domains"].as_array().unwrap();
assert_eq!(domains.len(), 1);
assert_eq!(domains[0], "operator_note");
}
#[test]
fn default_confidence_when_none_provided() {
let tool = make_tool();
let result = tool
.call(json!({ "claim": "A bare fact" }))
.unwrap();
assert!((result["confidence"].as_f64().unwrap() - 0.9).abs() < f64::EPSILON);
}
}