spool-memory 0.2.3

Local-first developer memory system — persistent, structured knowledge for AI coding tools
Documentation
//! `spool hook session-start` — emit a wakeup packet at session start.
//!
//! ## Output contract
//! - stdout: a textual prompt block, prefixed by an HTML comment
//!   marker so consumers can locate / strip the spool region.
//! - exit 0: always (failure is handled by [`run_silent`] in `mod.rs`).
//!
//! ## Behavior modes
//! - **Trellis-aware degraded mode** (D6): when
//!   `<cwd>/.trellis/.developer` exists we emit a single-line note
//!   pointing to `spool memory wakeup` instead of the full packet.
//!   This avoids two assistants both pushing their own wakeup blob into
//!   the system prompt.
//! - **Empty wakeup**: when the project has no wakeup-ready memories,
//!   we still emit a minimal block so Claude Code knows the hook is
//!   wired (vs silently doing nothing — confusing during onboarding).
//!
//! ## What we deliberately do NOT do here
//! - Network calls or sampling. SessionStart must finish in <500ms.
//! - Mutating ledger state. SessionStart is read-only.

use std::io::Write;
use std::path::PathBuf;

use anyhow::Context;

use crate::domain::{OutputFormat, RouteInput, TargetTool, WakeupProfile};
use crate::memory_gateway::{self, wakeup_request};

/// Arguments for the `session-start` hook.
#[derive(Debug, Clone)]
pub struct SessionStartArgs {
    pub config_path: PathBuf,
    /// Project root the user is currently working in. Defaults to the
    /// process's `current_dir` when absent.
    pub cwd: Option<PathBuf>,
    /// Optional task hint forwarded to the wakeup gateway. SessionStart
    /// usually has no concrete task — we route by `cwd` alone — but we
    /// keep the slot to mirror the CLI surface.
    pub task: Option<String>,
    pub profile: WakeupProfile,
}

pub fn run(args: SessionStartArgs) -> anyhow::Result<()> {
    let cwd = match args.cwd {
        Some(p) => p,
        None => std::env::current_dir().context("resolving cwd for session-start hook")?,
    };
    let task = args.task.unwrap_or_else(|| String::from("session start"));

    let mut stdout = std::io::stdout().lock();

    let response = match memory_gateway::execute(
        &args.config_path,
        wakeup_request(
            RouteInput {
                task,
                cwd: cwd.clone(),
                files: Vec::new(),
                target: TargetTool::Claude,
                format: OutputFormat::Prompt,
            },
            args.profile,
        ),
        None,
    ) {
        Ok(r) => r,
        Err(err) => {
            write_error_block(&mut stdout, &err)?;
            return Ok(());
        }
    };

    let packet = match response.wakeup_packet() {
        Some(p) => p,
        None => {
            write_empty_block(&mut stdout)?;
            return Ok(());
        }
    };

    let body = render_memory_injection(packet);
    if !body.is_empty() {
        write_block(&mut stdout, &body)?;
    } else {
        write_empty_block(&mut stdout)?;
    }
    Ok(())
}

const REGION_OPEN: &str = "<spool-memory>";
const REGION_CLOSE: &str = "</spool-memory>";

fn write_block<W: Write>(w: &mut W, body: &str) -> anyhow::Result<()> {
    writeln!(w, "{}", REGION_OPEN)?;
    writeln!(w, "{}", body)?;
    writeln!(w, "{}", REGION_CLOSE)?;
    Ok(())
}

fn write_empty_block<W: Write>(w: &mut W) -> anyhow::Result<()> {
    writeln!(w, "<spool-memory>")?;
    writeln!(
        w,
        "spool: no active memories yet. Say \"记一下\" to capture."
    )?;
    writeln!(w, "</spool-memory>")?;
    Ok(())
}

fn write_error_block<W: Write>(w: &mut W, err: &anyhow::Error) -> anyhow::Result<()> {
    write_block(
        w,
        &format!(
            "Memory unavailable: {}. Run `spool mcp doctor` for diagnosis.",
            short_error(err)
        ),
    )
}

/// Maximum characters for the session-start memory injection.
/// ~2000 chars ≈ 500 tokens — keeps the injection lightweight.
const MAX_INJECTION_CHARS: usize = 2000;
const MAX_SUMMARY_CHARS: usize = 120;

/// Render a compact, spool-branded memory injection from the wakeup packet.
/// Applies token budget: prioritizes constraints > decisions > preferences > incidents.
/// Truncates individual summaries and drops lower-priority items when over budget.
fn render_memory_injection(packet: &crate::domain::WakeupPacket) -> String {
    use crate::domain::WakeupMemoryItem;

    fn has_content(items: &[WakeupMemoryItem]) -> bool {
        !items.is_empty()
    }

    let has_any = has_content(&packet.constraints)
        || has_content(&packet.decisions)
        || has_content(&packet.working_style.items)
        || has_content(&packet.incidents);

    if !has_any {
        return String::new();
    }

    let sections: Vec<(&str, &[WakeupMemoryItem])> = vec![
        ("Constraints", &packet.constraints),
        ("Decisions", &packet.decisions),
        ("Preferences", &packet.working_style.items),
        ("Incidents", &packet.incidents),
    ];

    let mut lines: Vec<String> = Vec::new();
    let mut total_chars: usize = 0;
    let mut dropped: usize = 0;

    for (heading, items) in &sections {
        if items.is_empty() {
            continue;
        }
        let header_line = format!("## {heading}");
        let header_cost = header_line.len() + 1;

        if total_chars + header_cost > MAX_INJECTION_CHARS {
            dropped += items.len();
            continue;
        }

        if !lines.is_empty() {
            lines.push(String::new());
            total_chars += 1;
        }
        lines.push(header_line);
        total_chars += header_cost;

        for item in *items {
            let summary = truncate_summary(&item.summary);
            let line = format!("- {summary}");
            let line_cost = line.len() + 1;

            if total_chars + line_cost > MAX_INJECTION_CHARS {
                dropped += 1;
                continue;
            }
            lines.push(line);
            total_chars += line_cost;
        }
    }

    if dropped > 0 {
        lines.push(String::new());
        lines.push(format!(
            "({dropped} more — use /spool-wakeup for full list)"
        ));
    }

    lines.join("\n")
}

fn truncate_summary(s: &str) -> String {
    let trimmed = s.trim();
    if trimmed.chars().count() <= MAX_SUMMARY_CHARS {
        return trimmed.to_string();
    }
    let mut out: String = trimmed.chars().take(MAX_SUMMARY_CHARS).collect();
    out.push('');
    out
}

fn short_error(err: &anyhow::Error) -> String {
    let raw = format!("{}", err);
    let trimmed: String = raw.chars().take(160).collect();
    if trimmed.len() < raw.len() {
        format!("{}", trimmed)
    } else {
        trimmed
    }
}

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

    fn fixture(temp: &tempfile::TempDir) -> PathBuf {
        let vault = temp.path().join("vault");
        fs::create_dir_all(vault.join("10-Projects")).unwrap();
        fs::write(
            vault.join("10-Projects/proj.md"),
            r#"---
memory_type: decision
project_id: spool
retrieval_priority: high
---
# 决策: 用 cargo install
"#,
        )
        .unwrap();
        let cwd = temp.path().join("repo");
        fs::create_dir_all(&cwd).unwrap();
        let config = format!(
            r#"[vault]
root = "{}"

[output]
default_format = "prompt"
max_chars = 8000
max_notes = 4

[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["10-Projects"]
"#,
            vault.display(),
            cwd.display()
        );
        let cfg = temp.path().join("spool.toml");
        fs::write(&cfg, config).unwrap();
        cfg
    }

    #[test]
    fn write_block_wraps_body_in_markers() {
        let mut buf = Vec::new();
        write_block(&mut buf, "hello world").unwrap();
        let text = String::from_utf8(buf).unwrap();
        assert!(text.starts_with("<spool-memory>\n"));
        assert!(text.contains("hello world"));
        assert!(text.trim_end().ends_with("</spool-memory>"));
    }

    #[test]
    fn write_empty_block_provides_actionable_hint() {
        let mut buf = Vec::new();
        write_empty_block(&mut buf).unwrap();
        let text = String::from_utf8(buf).unwrap();
        assert!(text.contains("记一下"));
    }

    #[test]
    fn short_error_truncates_long_errors() {
        let long = "x".repeat(500);
        let err = anyhow::anyhow!("{}", long);
        let s = short_error(&err);
        assert!(s.ends_with(''));
        assert!(s.len() < long.len());
    }

    #[test]
    fn run_emits_wakeup_block_for_real_project() {
        let temp = tempdir().unwrap();
        let cfg = fixture(&temp);
        let cwd = temp.path().join("repo");

        let response = memory_gateway::execute(
            &cfg,
            wakeup_request(
                RouteInput {
                    task: "session start".into(),
                    cwd: cwd.clone(),
                    files: Vec::new(),
                    target: TargetTool::Claude,
                    format: OutputFormat::Prompt,
                },
                WakeupProfile::Project,
            ),
            None,
        )
        .unwrap();
        assert!(response.wakeup_packet().is_some());
    }

    #[test]
    fn run_works_even_with_trellis_present() {
        let temp = tempdir().unwrap();
        let cfg = fixture(&temp);
        let cwd = temp.path().join("repo");
        let trellis = cwd.join(".trellis");
        fs::create_dir_all(&trellis).unwrap();
        fs::write(trellis.join(".developer"), "name=long").unwrap();

        let result = run(SessionStartArgs {
            config_path: cfg,
            cwd: Some(cwd),
            task: None,
            profile: WakeupProfile::Project,
        });
        assert!(result.is_ok(), "should work regardless of trellis presence");
    }
}