spool-memory 0.1.1

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

#[derive(Debug, Clone)]
pub struct PreparedWakeupItem {
    pub item: WakeupMemoryItem,
    pub suppressed: bool,
}

#[derive(Debug, Clone)]
pub struct WakeupPolicyState {
    max_sensitivity_included: Option<String>,
    redactions_applied: bool,
    suppressed_note_count: usize,
}

impl WakeupPolicyState {
    pub fn new() -> Self {
        Self {
            max_sensitivity_included: None,
            redactions_applied: false,
            suppressed_note_count: 0,
        }
    }

    pub fn prepare_item(&mut self, scored: &ScoredNote) -> PreparedWakeupItem {
        let note = &scored.note;
        let memory_type = note.memory_type().map(ToString::to_string);
        let sensitivity = note.sensitivity().map(ToString::to_string);
        let source_of_truth = note.source_of_truth();

        if sensitivity.as_deref() == Some("secret") {
            self.suppressed_note_count += 1;
            return PreparedWakeupItem {
                item: WakeupMemoryItem {
                    title: note.title.clone(),
                    summary: String::new(),
                    memory_type,
                    source: note.relative_path.clone(),
                    sensitivity,
                    source_of_truth,
                    confidence: scored.confidence,
                },
                suppressed: true,
            };
        }

        self.track_max_sensitivity(sensitivity.as_deref());
        let summary = self.summarize_excerpt(&scored.excerpt, sensitivity.as_deref());

        PreparedWakeupItem {
            item: WakeupMemoryItem {
                title: note.title.clone(),
                summary,
                memory_type,
                source: note.relative_path.clone(),
                sensitivity,
                source_of_truth,
                confidence: scored.confidence,
            },
            suppressed: false,
        }
    }

    pub fn build_policy(self) -> WakeupPolicy {
        WakeupPolicy {
            max_sensitivity_included: self.max_sensitivity_included,
            redactions_applied: self.redactions_applied,
            suppressed_note_count: self.suppressed_note_count,
            policy_mode: "conservative_default".to_string(),
        }
    }

    fn summarize_excerpt(&mut self, excerpt: &str, sensitivity: Option<&str>) -> String {
        match sensitivity {
            Some("confidential") => {
                self.redactions_applied = true;
                let summary = excerpt.chars().take(80).collect::<String>();
                if summary.is_empty() {
                    "Confidential content redacted".to_string()
                } else {
                    format!("{} [redacted]", summary)
                }
            }
            _ => excerpt.to_string(),
        }
    }

    fn track_max_sensitivity(&mut self, next: Option<&str>) {
        let Some(next) = next else {
            return;
        };
        let next_rank = sensitivity_rank(next);
        let current_rank = self
            .max_sensitivity_included
            .as_deref()
            .map(sensitivity_rank)
            .unwrap_or(-1);
        if next_rank > current_rank {
            self.max_sensitivity_included = Some(next.to_string());
        }
    }
}

fn sensitivity_rank(value: &str) -> i32 {
    match value {
        "public" => 0,
        "internal" => 1,
        "confidential" => 2,
        "secret" => 3,
        _ => -1,
    }
}

#[cfg(test)]
mod tests {
    use super::WakeupPolicyState;
    use crate::domain::{Note, Section};
    use serde_json::json;
    use std::collections::BTreeMap;
    use std::path::PathBuf;

    fn make_scored_note(
        path: &str,
        title: &str,
        memory_type: &str,
        sensitivity: &str,
        body: &str,
    ) -> crate::domain::ScoredNote {
        let note = Note::new(
            PathBuf::from(path),
            path.to_string(),
            title.to_string(),
            BTreeMap::from([
                ("memory_type".to_string(), json!(memory_type)),
                ("sensitivity".to_string(), json!(sensitivity)),
                ("source_of_truth".to_string(), json!(true)),
            ]),
            vec![Section {
                heading: Some(title.to_string()),
                level: 1,
                content: body.to_string(),
            }],
            Vec::new(),
            body.to_string(),
        );
        note.to_scored(10, vec!["matched task token".to_string()])
    }

    #[test]
    fn policy_should_redact_confidential_excerpt() {
        let mut state = WakeupPolicyState::new();
        let prepared = state.prepare_item(&make_scored_note(
            "confidential.md",
            "Confidential",
            "constraint",
            "confidential",
            "Highly specific confidential implementation details that should not be exposed verbatim.",
        ));

        assert!(!prepared.suppressed);
        assert!(prepared.item.summary.contains("[redacted]"));

        let policy = state.build_policy();
        assert_eq!(
            policy.max_sensitivity_included.as_deref(),
            Some("confidential")
        );
        assert!(policy.redactions_applied);
    }

    #[test]
    fn policy_should_suppress_secret_excerpt() {
        let mut state = WakeupPolicyState::new();
        let prepared = state.prepare_item(&make_scored_note(
            "secret.md",
            "Secret",
            "constraint",
            "secret",
            "top secret details",
        ));

        assert!(prepared.suppressed);

        let policy = state.build_policy();
        assert_eq!(policy.suppressed_note_count, 1);
        assert_eq!(policy.max_sensitivity_included, None);
    }

    #[test]
    fn policy_should_track_highest_visible_sensitivity() {
        let mut state = WakeupPolicyState::new();
        state.prepare_item(&make_scored_note(
            "public.md",
            "Public",
            "project",
            "public",
            "public body",
        ));
        state.prepare_item(&make_scored_note(
            "internal.md",
            "Internal",
            "project",
            "internal",
            "internal body",
        ));

        let policy = state.build_policy();
        assert_eq!(policy.max_sensitivity_included.as_deref(), Some("internal"));
        assert!(!policy.redactions_applied);
        assert_eq!(policy.suppressed_note_count, 0);
    }
}