car-server-core 0.33.0

Transport-neutral library for the CAR daemon JSON-RPC dispatcher (used by car-server and tokhn-daemon)
//! Durable memory for the assistant: `remember` and `recall`.
//!
//! Backed by CAR's own graph memory ([`car_memgine::MemgineEngine`]) rather than
//! a bespoke store — this is the capability the assistant exists to showcase.
//! Remembered facts are persisted to `~/.car/memory/assistant.json` and
//! re-ingested on startup, so the assistant remembers across sessions and
//! process restarts. Recall uses the memgine's keyword/graph retrieval
//! (`build_context_fast`, no inference required).

use std::path::PathBuf;
use std::sync::Mutex;

use async_trait::async_trait;
use car_engine::ToolExecutor;
use car_memgine::MemgineEngine;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};

#[derive(Clone, Serialize, Deserialize)]
struct Note {
    subject: String,
    body: String,
}

/// `remember` / `recall`, backed by a persistent memgine graph.
pub struct MemoryTools {
    inner: Mutex<Inner>,
    path: PathBuf,
}

struct Inner {
    engine: MemgineEngine,
    notes: Vec<Note>,
}

impl MemoryTools {
    /// Open (or create) the assistant's memory at `path`, re-ingesting any
    /// previously remembered facts so recall works immediately.
    pub fn open(path: PathBuf) -> Self {
        let notes: Vec<Note> = std::fs::read_to_string(&path)
            .ok()
            .and_then(|s| serde_json::from_str(&s).ok())
            .unwrap_or_default();
        let mut engine = MemgineEngine::new(None);
        for (i, n) in notes.iter().enumerate() {
            ingest(&mut engine, i, n);
        }
        Self {
            inner: Mutex::new(Inner { engine, notes }),
            path,
        }
    }

    /// The two model-facing tool schemas.
    pub fn tool_defs() -> Vec<Value> {
        vec![
            json!({
                "name": "remember",
                "description": "Save a durable fact about the user or task so you can recall it in \
                                future sessions (e.g. a preference, a project name, a decision). \
                                Use for things worth persisting, not transient details.",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "subject": { "type": "string", "description": "Short label for the fact (e.g. 'project name')." },
                        "body": { "type": "string", "description": "The fact to remember." }
                    },
                    "required": ["subject", "body"]
                }
            }),
            json!({
                "name": "recall",
                "description": "Retrieve previously remembered facts relevant to a query. Use at the \
                                start of a task to recall what you know about the user or project.",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "query": { "type": "string", "description": "What to recall." }
                    },
                    "required": ["query"]
                }
            }),
        ]
    }

    fn remember(&self, params: &Value) -> Result<Value, String> {
        let subject = params
            .get("subject")
            .and_then(Value::as_str)
            .ok_or("remember requires a 'subject' string")?;
        let body = params
            .get("body")
            .and_then(Value::as_str)
            .ok_or("remember requires a 'body' string")?;
        let mut g = self.inner.lock().map_err(|_| "memory lock poisoned")?;
        let note = Note {
            subject: subject.to_string(),
            body: body.to_string(),
        };
        // Supersede an existing fact with the same subject (case-insensitive) so
        // an updated fact replaces the stale one instead of accumulating.
        match g
            .notes
            .iter_mut()
            .find(|n| n.subject.eq_ignore_ascii_case(subject))
        {
            Some(existing) => existing.body = body.to_string(),
            None => g.notes.push(note),
        }
        // Re-ingest the whole set so the memgine graph mirrors the current notes
        // (cheap for a personal store; correctness over cleverness).
        let mut engine = MemgineEngine::new(None);
        for (i, n) in g.notes.iter().enumerate() {
            ingest(&mut engine, i, n);
        }
        g.engine = engine;
        // Persist the whole set (small; correctness over cleverness).
        if let Some(parent) = self.path.parent() {
            let _ = std::fs::create_dir_all(parent);
        }
        let serialized =
            serde_json::to_string_pretty(&g.notes).map_err(|e| format!("serialize: {e}"))?;
        std::fs::write(&self.path, serialized).map_err(|e| format!("persist memory: {e}"))?;
        Ok(json!({ "remembered": subject, "total_facts": g.notes.len() }))
    }

    fn recall(&self, params: &Value) -> Result<Value, String> {
        let query = params
            .get("query")
            .and_then(Value::as_str)
            .ok_or("recall requires a 'query' string")?;
        let mut g = self.inner.lock().map_err(|_| "memory lock poisoned")?;
        if g.notes.is_empty() {
            return Ok(json!({ "query": query, "context": "", "note": "no facts remembered yet" }));
        }
        // Memgine retrieval (keyword/graph; no inference needed).
        let context = g.engine.build_context_fast(query);
        Ok(json!({ "query": query, "context": context }))
    }
}

/// Ingest one note as a memgine fact. Deterministic id per index so a reload
/// re-creates the same graph.
fn ingest(engine: &mut MemgineEngine, idx: usize, n: &Note) {
    engine.ingest_fact(
        &format!("assistant-note-{idx}"),
        &n.subject,
        &n.body,
        "user",
        "user",
        chrono::Utc::now(),
        "assistant",
        None,
        Vec::new(),
        false,
    );
}

#[async_trait]
impl ToolExecutor for MemoryTools {
    async fn execute(&self, tool: &str, params: &Value) -> Result<Value, String> {
        match tool {
            "remember" => self.remember(params),
            "recall" => self.recall(params),
            other => Err(format!("unknown tool: '{other}'")),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn remembers_across_reopen() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("assistant.json");

        {
            let mem = MemoryTools::open(path.clone());
            mem.execute(
                "remember",
                &json!({ "subject": "project name", "body": "The project is called Zephyr." }),
            )
            .await
            .unwrap();
        }

        // Re-open (simulating a new process/session) and recall.
        let mem = MemoryTools::open(path.clone());
        let out = mem
            .execute("recall", &json!({ "query": "what is my project called?" }))
            .await
            .unwrap();
        let ctx = out["context"].as_str().unwrap();
        assert!(ctx.contains("Zephyr"), "recall should surface the fact: {ctx}");
    }

    #[tokio::test]
    async fn remember_supersedes_same_subject() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("m.json");
        let mem = MemoryTools::open(path.clone());
        mem.execute("remember", &json!({ "subject": "editor", "body": "vim" }))
            .await
            .unwrap();
        let out = mem
            .execute("remember", &json!({ "subject": "Editor", "body": "emacs" }))
            .await
            .unwrap();
        // Same subject (case-insensitive) → replaced, not accumulated.
        assert_eq!(out["total_facts"], 1);
        let notes: Vec<Note> =
            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
        assert_eq!(notes.len(), 1);
        assert_eq!(notes[0].body, "emacs");
    }

    #[tokio::test]
    async fn recall_on_empty_is_graceful() {
        let dir = tempfile::tempdir().unwrap();
        let mem = MemoryTools::open(dir.path().join("m.json"));
        let out = mem
            .execute("recall", &json!({ "query": "anything" }))
            .await
            .unwrap();
        assert_eq!(out["context"], "");
    }
}