spool-memory 0.2.3

Local-first developer memory system — persistent, structured knowledge for AI coding tools
Documentation
//! `spool hook user-prompt` — invoked when the user submits a prompt.
//!
//! ## Behavior
//! 1. Read the user's prompt from stdin (Claude Code passes it as the
//!    hook payload).
//! 2. Check for recall trigger keywords ("remind me", "之前说过",
//!    "上次决定", etc.).
//! 3. If triggered, run a lightweight context retrieval and emit a
//!    compact memory block to stdout so the AI sees relevant memories.
//! 4. Update `last-prompt.unix` timestamp marker.
//!
//! ## Performance
//! The retrieval path is the same as `spool get` — local vault scan,
//! no network. Must stay under 500ms p95.

use std::io::{IsTerminal, Read as _, Write};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};

use anyhow::Context;

use crate::domain::{OutputFormat, RouteInput, TargetTool};
use crate::memory_gateway;
use crate::output;

use super::project_runtime_dir;

#[derive(Debug, Clone)]
pub struct UserPromptArgs {
    pub config_path: PathBuf,
    pub cwd: Option<PathBuf>,
    /// Override for stdin content (used in tests).
    pub prompt_override: Option<String>,
}

pub fn run(args: UserPromptArgs) -> anyhow::Result<()> {
    let cwd = match args.cwd {
        Some(p) => p,
        None => std::env::current_dir().context("resolving cwd for user-prompt hook")?,
    };
    let runtime_dir = project_runtime_dir(&cwd)?;

    let prompt_text = match args.prompt_override {
        Some(text) => text,
        None => read_stdin_prompt(),
    };

    if !prompt_text.is_empty()
        && has_recall_trigger(&prompt_text)
        && let Ok(block) = build_recall_block(&args.config_path, &cwd, &prompt_text)
        && !block.is_empty()
    {
        let mut stdout = std::io::stdout().lock();
        let _ = writeln!(stdout, "<!-- spool:recall -->");
        let _ = writeln!(stdout, "{}", block.trim());
        let _ = writeln!(stdout, "<!-- /spool:recall -->");
    }

    let stamp = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0);
    let path = runtime_dir.join("last-prompt.unix");
    std::fs::write(&path, stamp.to_string())
        .with_context(|| format!("writing user-prompt timestamp to {}", path.display()))?;

    if !args.config_path.exists() {
        anyhow::bail!("config path does not exist: {}", args.config_path.display());
    }
    Ok(())
}

fn read_stdin_prompt() -> String {
    if std::io::stdin().is_terminal() {
        return String::new();
    }
    let mut buf = String::new();
    let _ = std::io::stdin().read_to_string(&mut buf);
    buf
}

static RECALL_TRIGGERS: &[&str] = &[
    "remind me",
    "as i said before",
    "as we discussed",
    "remember when",
    "what did i say about",
    "what did we decide",
    "之前说过",
    "上次决定",
    "之前决定",
    "提醒我",
    "我说过",
    "我们决定",
    "之前提到",
    "上次提到",
];

fn has_recall_trigger(prompt: &str) -> bool {
    let lower = prompt.to_lowercase();
    RECALL_TRIGGERS
        .iter()
        .any(|trigger| lower.contains(trigger))
}

fn build_recall_block(config_path: &Path, cwd: &Path, prompt: &str) -> anyhow::Result<String> {
    let input = RouteInput {
        task: prompt.to_string(),
        cwd: cwd.to_path_buf(),
        files: Vec::new(),
        target: TargetTool::Claude,
        format: OutputFormat::Prompt,
    };
    let request = memory_gateway::context_request(input);
    let response = memory_gateway::execute(config_path, request, None)?;
    let rendered = output::prompt::render(&response.bundle, 4000);
    Ok(rendered)
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::tempdir;

    #[test]
    fn has_recall_trigger_detects_english() {
        assert!(has_recall_trigger(
            "Can you remind me what we said about auth?"
        ));
        assert!(has_recall_trigger("What did we decide about the API?"));
        assert!(has_recall_trigger("As I said before, use cargo install"));
    }

    #[test]
    fn has_recall_trigger_detects_chinese() {
        assert!(has_recall_trigger("之前说过用 cargo install"));
        assert!(has_recall_trigger("上次决定的方案是什么"));
        assert!(has_recall_trigger("提醒我一下那个约束"));
    }

    #[test]
    fn has_recall_trigger_returns_false_for_normal_prompts() {
        assert!(!has_recall_trigger("fix the bug in auth.rs"));
        assert!(!has_recall_trigger("继续推进"));
        assert!(!has_recall_trigger("add a test for the new feature"));
    }

    #[test]
    fn run_writes_timestamp_without_trigger() {
        let temp = tempdir().unwrap();
        let cfg = temp.path().join("spool.toml");
        fs::write(&cfg, "[vault]\nroot = \"/tmp\"\n").unwrap();

        run(UserPromptArgs {
            config_path: cfg,
            cwd: Some(temp.path().to_path_buf()),
            prompt_override: Some("fix the bug".to_string()),
        })
        .unwrap();

        let stamp =
            fs::read_to_string(temp.path().join(".spool").join("last-prompt.unix")).unwrap();
        assert!(stamp.trim().parse::<u64>().is_ok());
    }

    #[test]
    fn run_errors_when_config_missing() {
        let temp = tempdir().unwrap();
        let err = run(UserPromptArgs {
            config_path: temp.path().join("nope.toml"),
            cwd: Some(temp.path().to_path_buf()),
            prompt_override: Some(String::new()),
        })
        .unwrap_err();
        assert!(err.to_string().contains("config path does not exist"));
    }

    #[test]
    fn run_attempts_recall_on_trigger() {
        let temp = tempdir().unwrap();
        let vault = temp.path().join("vault");
        fs::create_dir_all(vault.join("10-Projects")).unwrap();
        fs::write(
            vault.join("10-Projects/auth.md"),
            "---\nmemory_type: decision\n---\n# Auth Decision\n\nUse JWT tokens.\n",
        )
        .unwrap();
        let repo = temp.path().join("repo");
        fs::create_dir_all(&repo).unwrap();
        let cfg = temp.path().join("spool.toml");
        fs::write(
            &cfg,
            format!(
                "[vault]\nroot = \"{}\"\n\n[output]\ndefault_format = \"prompt\"\nmax_chars = 4000\nmax_notes = 3\n\n[[projects]]\nid = \"test\"\nname = \"test\"\nrepo_paths = [\"{}\"]\nnote_roots = [\"10-Projects\"]\n",
                vault.display(),
                repo.display()
            ),
        )
        .unwrap();

        // Should not panic even with a recall trigger
        let result = run(UserPromptArgs {
            config_path: cfg,
            cwd: Some(repo),
            prompt_override: Some("之前说过 auth 怎么做的".to_string()),
        });
        assert!(result.is_ok());
    }
}