spool-memory 0.2.3

Local-first developer memory system — persistent, structured knowledge for AI coding tools
Documentation
use crate::domain::{ContextBundle, TargetTool};

pub fn render(bundle: &ContextBundle, max_chars: usize) -> String {
    let mut output = String::new();
    output.push_str("# spool context\n\n");
    output.push_str(&format!("- task: {}\n", bundle.input.task));
    output.push_str(&format!("- cwd: {}\n", bundle.input.cwd.display()));
    if let Some(project) = &bundle.route.project {
        output.push_str(&format!("- project: {} ({})\n", project.name, project.id));
    }
    output.push('\n');
    if !bundle.route.lifecycle_candidates.is_empty() {
        output.push_str("## 记忆(accepted / canonical)\n\n");
        for candidate in &bundle.route.lifecycle_candidates {
            let summary = truncate_chars(&candidate.summary, 120);
            output.push_str(&format!(
                "- [{}] [{}] **{}** — {}\n",
                candidate.score, candidate.memory_type, candidate.title, summary
            ));
        }
        output.push('\n');
    }
    for candidate in &bundle.route.candidates {
        output.push_str(&format!("## {}\n\n", candidate.relative_path));
        output.push_str(&format!("- score: {}\n", candidate.score));
        output.push_str(&format!("- reasons: {}\n\n", candidate.reasons.join("; ")));
        output.push_str(&candidate.excerpt);
        output.push_str("\n\n");
    }
    if output.chars().count() > max_chars {
        output.chars().take(max_chars).collect()
    } else {
        output
    }
}

fn truncate_chars(value: &str, max_chars: usize) -> String {
    let chars: Vec<char> = value.chars().collect();
    if chars.len() <= max_chars {
        return value.to_string();
    }
    let mut out: String = chars.iter().take(max_chars).collect();
    out.push('');
    out
}

pub fn render_explain(bundle: &ContextBundle) -> String {
    let mut output = String::new();
    output.push_str("# route explain\n\n");
    output.push_str(&format!(
        "- target: {}\n- cwd: {}\n- matched_project_id: {}\n- note_roots: {}\n- scan_roots: {}\n- limits: max_files={}, max_file_bytes={}, max_total_bytes={}, max_depth={}\n- note_count: {}\n\n",
        match bundle.input.target {
            TargetTool::Claude => "claude",
            TargetTool::Codex => "codex",
            TargetTool::Opencode => "opencode",
        },
        bundle.input.cwd.display(),
        bundle.route.debug.matched_project_id.as_deref().unwrap_or("none"),
        if bundle.route.debug.note_roots.is_empty() { "none".to_string() } else { bundle.route.debug.note_roots.join(", ") },
        if bundle.route.debug.scan_roots.is_empty() { "none".to_string() } else { bundle.route.debug.scan_roots.join(", ") },
        bundle.route.debug.limits.max_files,
        bundle.route.debug.limits.max_file_bytes,
        bundle.route.debug.limits.max_total_bytes,
        bundle.route.debug.limits.max_depth,
        bundle.route.debug.note_count,
    ));
    match &bundle.route.project {
        Some(project) => {
            output.push_str(&format!(
                "## project\n- id: {}\n- reason: {}\n\n",
                project.id, project.reason
            ));
        }
        None => output.push_str("## project\n- none\n\n"),
    }
    output.push_str("## modules\n");
    if bundle.route.modules.is_empty() {
        output.push_str("- none\n");
    } else {
        for module in &bundle.route.modules {
            output.push_str(&format!("- {}: {}\n", module.id, module.reasons.join("; ")));
        }
    }
    output.push_str("\n## scenes\n");
    if bundle.route.scenes.is_empty() {
        output.push_str("- none\n");
    } else {
        for scene in &bundle.route.scenes {
            let preferred_notes = if scene.preferred_notes.is_empty() {
                "none".to_string()
            } else {
                scene.preferred_notes.join(", ")
            };
            output.push_str(&format!(
                "- {}: {} | preferred_notes: {}\n",
                scene.id,
                scene.reasons.join("; "),
                preferred_notes
            ));
        }
    }
    output.push_str("\n## candidates\n");
    if bundle.route.candidates.is_empty() {
        output.push_str("- none\n");
    } else {
        for candidate in &bundle.route.candidates {
            output.push_str(&format!(
                "- {} [{}] (confidence={}): {}\n",
                candidate.relative_path,
                candidate.score,
                match candidate.confidence {
                    crate::domain::ConfidenceTier::High => "high",
                    crate::domain::ConfidenceTier::Medium => "medium",
                    crate::domain::ConfidenceTier::Low => "low",
                },
                candidate.reasons.join("; ")
            ));
            if !candidate.score_breakdown.is_empty() {
                for contrib in &candidate.score_breakdown {
                    output.push_str(&format!(
                        "  - {:?} {}/{} {:+}\n",
                        contrib.source, contrib.field, contrib.term, contrib.weight
                    ));
                }
            }
        }
    }
    output.push_str("\n## lifecycle_candidates\n");
    if bundle.route.lifecycle_candidates.is_empty() {
        output.push_str("- none\n");
    } else {
        for candidate in &bundle.route.lifecycle_candidates {
            output.push_str(&format!(
                "- {} [{}] ({}): {}\n",
                candidate.title,
                candidate.score,
                candidate.memory_type,
                candidate.reasons.join("; ")
            ));
        }
    }
    output
}