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>,
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();
let result = run(UserPromptArgs {
config_path: cfg,
cwd: Some(repo),
prompt_override: Some("之前说过 auth 怎么做的".to_string()),
});
assert!(result.is_ok());
}
}