spool-memory 0.2.3

Local-first developer memory system — persistent, structured knowledge for AI coding tools
Documentation
use crate::lifecycle_store::lifecycle_root_from_config;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use ts_rs::TS;

const TRACE_FILE_NAME: &str = "prompt-optimize.latest.json";
const TRACE_SCHEMA_VERSION: &str = "prompt-optimize-trace.v1";

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
#[ts(export, export_to = "../frontend/src/lib/types/generated/")]
pub struct PromptOptimizeTrace {
    pub schema_version: String,
    pub recorded_at: String,
    pub source: String,
    pub cwd: String,
    pub task: String,
    pub target: String,
    pub profile: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub provider: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub session_id: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub matched_project_id: Option<String>,
    pub note_count: usize,
    pub used_vault_root: String,
}

impl PromptOptimizeTrace {
    #[allow(clippy::too_many_arguments)]
    pub fn new(
        cwd: impl Into<String>,
        task: impl Into<String>,
        target: impl Into<String>,
        profile: impl Into<String>,
        provider: Option<String>,
        session_id: Option<String>,
        matched_project_id: Option<String>,
        note_count: usize,
        used_vault_root: impl Into<String>,
    ) -> Self {
        Self {
            schema_version: TRACE_SCHEMA_VERSION.to_string(),
            recorded_at: timestamp_string(),
            source: "mcp.prompt_optimize".to_string(),
            cwd: cwd.into(),
            task: task.into(),
            target: target.into(),
            profile: profile.into(),
            provider,
            session_id,
            matched_project_id,
            note_count,
            used_vault_root: used_vault_root.into(),
        }
    }
}

pub fn write_latest_prompt_optimize_trace(
    config_path: &Path,
    trace: &PromptOptimizeTrace,
) -> anyhow::Result<()> {
    let path = trace_file_path(config_path);
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }
    let temp_path = path.with_extension("tmp");
    fs::write(&temp_path, serde_json::to_vec_pretty(trace)?)?;
    fs::rename(temp_path, path)?;
    Ok(())
}

pub fn read_latest_prompt_optimize_trace(
    config_path: &Path,
) -> anyhow::Result<Option<PromptOptimizeTrace>> {
    let path = trace_file_path(config_path);
    if !path.exists() {
        return Ok(None);
    }
    let trace = serde_json::from_slice::<PromptOptimizeTrace>(&fs::read(path)?)?;
    if trace.schema_version != TRACE_SCHEMA_VERSION {
        return Ok(None);
    }
    Ok(Some(trace))
}

fn trace_file_path(config_path: &Path) -> PathBuf {
    let config_dir = config_path.parent().unwrap_or_else(|| Path::new("."));
    lifecycle_root_from_config(config_dir).join(TRACE_FILE_NAME)
}

fn timestamp_string() -> String {
    let seconds = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs();
    format!("unix:{seconds}")
}

#[cfg(test)]
mod tests {
    use super::{
        PromptOptimizeTrace, read_latest_prompt_optimize_trace, write_latest_prompt_optimize_trace,
    };
    use std::fs;
    use tempfile::tempdir;

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

        let trace = PromptOptimizeTrace::new(
            "/tmp/repo",
            "continue task",
            "codex",
            "project",
            Some("codex".to_string()),
            Some("codex:session-1".to_string()),
            Some("spool".to_string()),
            3,
            "/tmp/vault",
        );
        write_latest_prompt_optimize_trace(&config_path, &trace).unwrap();

        let loaded = read_latest_prompt_optimize_trace(&config_path)
            .unwrap()
            .unwrap();
        assert_eq!(loaded.source, "mcp.prompt_optimize");
        assert_eq!(loaded.cwd, "/tmp/repo");
        assert_eq!(loaded.provider.as_deref(), Some("codex"));
        assert_eq!(loaded.session_id.as_deref(), Some("codex:session-1"));
        assert_eq!(loaded.note_count, 3);
    }
}