spool-memory 0.2.3

Local-first developer memory system — persistent, structured knowledge for AI coding tools
Documentation
use serde::{Deserialize, Serialize};
use ts_rs::TS;

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "../frontend/src/lib/types/generated/")]
pub enum MemoryScope {
    User,
    Project,
    Workspace,
    Team,
    Agent,
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "../frontend/src/lib/types/generated/")]
pub enum MemorySourceKind {
    Manual,
    AiProposal,
    SessionCapture,
    Distilled,
    Imported,
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "../frontend/src/lib/types/generated/")]
pub enum MemoryLifecycleState {
    Draft,
    Candidate,
    Accepted,
    Canonical,
    Archived,
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "../frontend/src/lib/types/generated/")]
pub enum MemoryPromotionAction {
    SubmitProposal,
    Accept,
    PromoteToCanonical,
    Archive,
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "../frontend/src/lib/types/generated/")]
pub enum MemoryLedgerAction {
    RecordManual,
    ProposeAi,
    SubmitProposal,
    Accept,
    PromoteToCanonical,
    Archive,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
#[ts(export, export_to = "../frontend/src/lib/types/generated/")]
pub struct MemoryOrigin {
    pub source_kind: MemorySourceKind,
    pub source_ref: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
#[ts(export, export_to = "../frontend/src/lib/types/generated/")]
pub struct MemoryRecord {
    pub title: String,
    pub summary: String,
    pub memory_type: String,
    pub scope: MemoryScope,
    pub state: MemoryLifecycleState,
    pub origin: MemoryOrigin,
    pub project_id: Option<String>,
    pub user_id: Option<String>,
    pub sensitivity: Option<String>,
    // Structured retrieval signals
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub entities: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub tags: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub triggers: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub related_files: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub related_records: Vec<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub supersedes: Option<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub applies_to: Vec<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub valid_until: Option<String>,
}

impl MemoryRecord {
    pub fn new_manual(
        title: impl Into<String>,
        summary: impl Into<String>,
        memory_type: impl Into<String>,
        scope: MemoryScope,
        source_ref: impl Into<String>,
    ) -> Self {
        Self {
            title: title.into(),
            summary: summary.into(),
            memory_type: memory_type.into(),
            scope,
            state: MemoryLifecycleState::Accepted,
            origin: MemoryOrigin {
                source_kind: MemorySourceKind::Manual,
                source_ref: source_ref.into(),
            },
            project_id: None,
            user_id: None,
            sensitivity: None,
            entities: Vec::new(),
            tags: Vec::new(),
            triggers: Vec::new(),
            related_files: Vec::new(),
            related_records: Vec::new(),
            supersedes: None,
            applies_to: Vec::new(),
            valid_until: None,
        }
    }

    pub fn new_ai_proposal(
        title: impl Into<String>,
        summary: impl Into<String>,
        memory_type: impl Into<String>,
        scope: MemoryScope,
        source_ref: impl Into<String>,
    ) -> Self {
        Self {
            title: title.into(),
            summary: summary.into(),
            memory_type: memory_type.into(),
            scope,
            state: MemoryLifecycleState::Candidate,
            origin: MemoryOrigin {
                source_kind: MemorySourceKind::AiProposal,
                source_ref: source_ref.into(),
            },
            project_id: None,
            user_id: None,
            sensitivity: None,
            entities: Vec::new(),
            tags: Vec::new(),
            triggers: Vec::new(),
            related_files: Vec::new(),
            related_records: Vec::new(),
            supersedes: None,
            applies_to: Vec::new(),
            valid_until: None,
        }
    }

    pub fn with_project_id(mut self, project_id: impl Into<String>) -> Self {
        self.project_id = Some(project_id.into());
        self
    }

    pub fn with_user_id(mut self, user_id: impl Into<String>) -> Self {
        self.user_id = Some(user_id.into());
        self
    }

    pub fn with_sensitivity(mut self, sensitivity: impl Into<String>) -> Self {
        self.sensitivity = Some(sensitivity.into());
        self
    }
}

pub fn next_state(
    current: MemoryLifecycleState,
    action: MemoryPromotionAction,
) -> MemoryLifecycleState {
    match (current, action) {
        (MemoryLifecycleState::Draft, MemoryPromotionAction::SubmitProposal) => {
            MemoryLifecycleState::Candidate
        }
        (MemoryLifecycleState::Candidate, MemoryPromotionAction::Accept) => {
            MemoryLifecycleState::Accepted
        }
        (MemoryLifecycleState::Accepted, MemoryPromotionAction::PromoteToCanonical) => {
            MemoryLifecycleState::Canonical
        }
        (_, MemoryPromotionAction::Archive) => MemoryLifecycleState::Archived,
        _ => current,
    }
}

impl MemoryRecord {
    pub fn apply_ledger_action(self, action: MemoryLedgerAction) -> Self {
        let next_state = match action {
            MemoryLedgerAction::RecordManual => self.state,
            MemoryLedgerAction::ProposeAi => self.state,
            MemoryLedgerAction::SubmitProposal => {
                next_state(self.state, MemoryPromotionAction::SubmitProposal)
            }
            MemoryLedgerAction::Accept => next_state(self.state, MemoryPromotionAction::Accept),
            MemoryLedgerAction::PromoteToCanonical => {
                next_state(self.state, MemoryPromotionAction::PromoteToCanonical)
            }
            MemoryLedgerAction::Archive => next_state(self.state, MemoryPromotionAction::Archive),
        };

        Self {
            state: next_state,
            ..self
        }
    }

    pub fn apply(self, action: MemoryPromotionAction) -> Self {
        let next_state = next_state(self.state, action);
        Self {
            state: next_state,
            ..self
        }
    }

    pub fn can_be_returned_in_wakeup(&self) -> bool {
        matches!(
            self.state,
            MemoryLifecycleState::Accepted | MemoryLifecycleState::Canonical
        )
    }

    pub fn requires_review(&self) -> bool {
        matches!(
            self.origin.source_kind,
            MemorySourceKind::AiProposal
                | MemorySourceKind::SessionCapture
                | MemorySourceKind::Distilled
        ) && matches!(
            self.state,
            MemoryLifecycleState::Draft | MemoryLifecycleState::Candidate
        )
    }
}

pub fn ledger_action_for_source(source_kind: MemorySourceKind) -> MemoryLedgerAction {
    match source_kind {
        MemorySourceKind::Manual => MemoryLedgerAction::RecordManual,
        MemorySourceKind::AiProposal => MemoryLedgerAction::ProposeAi,
        MemorySourceKind::SessionCapture => MemoryLedgerAction::SubmitProposal,
        MemorySourceKind::Distilled => MemoryLedgerAction::SubmitProposal,
        MemorySourceKind::Imported => MemoryLedgerAction::SubmitProposal,
    }
}

#[cfg(test)]
mod tests {
    use super::{
        MemoryLedgerAction, MemoryLifecycleState, MemoryPromotionAction, MemoryRecord, MemoryScope,
        MemorySourceKind, ledger_action_for_source, next_state,
    };

    #[test]
    fn manual_memory_should_start_as_accepted() {
        let record = MemoryRecord::new_manual(
            "简洁输出",
            "偏好简短直接的回复",
            "preference",
            MemoryScope::User,
            "manual:cli",
        )
        .with_user_id("long");

        assert_eq!(record.state, MemoryLifecycleState::Accepted);
        assert_eq!(record.origin.source_kind, MemorySourceKind::Manual);
        assert!(record.can_be_returned_in_wakeup());
        assert!(!record.requires_review());
    }

    #[test]
    fn ai_proposal_should_require_review_before_acceptance() {
        let candidate = MemoryRecord::new_ai_proposal(
            "常用测试策略",
            "偏好先补 CLI smoke 再收口内部测试",
            "workflow",
            MemoryScope::User,
            "session:2026-04-09",
        );

        assert_eq!(candidate.state, MemoryLifecycleState::Candidate);
        assert!(candidate.requires_review());
        assert!(!candidate.can_be_returned_in_wakeup());

        let accepted = candidate.apply(MemoryPromotionAction::Accept);
        assert_eq!(accepted.state, MemoryLifecycleState::Accepted);
        assert!(accepted.can_be_returned_in_wakeup());
    }

    #[test]
    fn accepted_memory_can_be_promoted_to_canonical_and_archived() {
        let accepted = MemoryRecord::new_manual(
            "Obsidian 作为主库",
            "项目记忆以 Obsidian 为可信知识主库",
            "project",
            MemoryScope::Project,
            "vault:10-Projects/spool.md",
        )
        .with_project_id("spool");

        let canonical = accepted.apply(MemoryPromotionAction::PromoteToCanonical);
        assert_eq!(canonical.state, MemoryLifecycleState::Canonical);

        let archived = canonical.apply(MemoryPromotionAction::Archive);
        assert_eq!(archived.state, MemoryLifecycleState::Archived);
        assert!(!archived.can_be_returned_in_wakeup());
    }

    #[test]
    fn lifecycle_next_state_should_keep_invalid_transitions_unchanged() {
        assert_eq!(
            next_state(
                MemoryLifecycleState::Accepted,
                MemoryPromotionAction::Accept,
            ),
            MemoryLifecycleState::Accepted
        );
        assert_eq!(
            next_state(
                MemoryLifecycleState::Candidate,
                MemoryPromotionAction::PromoteToCanonical,
            ),
            MemoryLifecycleState::Candidate
        );
        assert_eq!(
            next_state(
                MemoryLifecycleState::Canonical,
                MemoryPromotionAction::SubmitProposal,
            ),
            MemoryLifecycleState::Canonical
        );
    }

    #[test]
    fn ledger_action_mapping_should_preserve_source_semantics() {
        assert_eq!(
            ledger_action_for_source(MemorySourceKind::Manual),
            MemoryLedgerAction::RecordManual
        );
        assert_eq!(
            ledger_action_for_source(MemorySourceKind::AiProposal),
            MemoryLedgerAction::ProposeAi
        );
        assert_eq!(
            ledger_action_for_source(MemorySourceKind::Distilled),
            MemoryLedgerAction::SubmitProposal
        );
    }
}