spool-memory 0.2.3

Local-first developer memory system — persistent, structured knowledge for AI coding tools
Documentation
//! Lifecycle ledger store: append-only JSONL + 投影缓存的统一入口。
//!
//! 这个 module 的物理拆分:
//! - `mod.rs`:对外类型与 `LifecycleStore` / `LifecycleProjection` 实现、常量、顶层 helper
//! - `api.rs`:对外 mutation 与查询 API(原 `lifecycle_store.rs` 里的 pub fn)
//! - `projection.rs`:投影缓存 + 快照读写 + fingerprint
//! - `internal.rs`:私有的 ID/scope/timestamp/写入/状态转移辅助
//! - `tests.rs`:单元测试
//!
//! 外部 `use crate::lifecycle_store::xxx` 的路径保持不变。

mod api;
mod internal;
mod projection;

use crate::domain::{
    MemoryLedgerAction, MemoryLifecycleState, MemoryRecord, MemoryScope, MemorySourceKind,
};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs::{self, OpenOptions};
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use ts_rs::TS;

pub(crate) const LEDGER_FILE_NAME: &str = "memory-ledger.jsonl";
pub(crate) const LEDGER_SCHEMA_VERSION: &str = "memory-ledger.v1";

pub use api::{
    accept_memory, accept_memory_with_metadata, archive_memory, archive_memory_with_metadata,
    latest_state_by_scope, latest_state_by_state, latest_state_entries, lifecycle_query_plan,
    pending_review_entries, project_latest_state, promote_memory_to_canonical,
    promote_memory_to_canonical_with_metadata, propose_ai_memory, read_events_for_record,
    record_manual_memory, review_queue_for_scope, review_queue_plan, wakeup_ready_entries,
    wakeup_ready_for_scope,
};
pub use projection::read_projection;

#[cfg(test)]
pub(crate) use projection::{
    clear_projection_cache, read_projection_with_cache_hit, read_projection_with_source,
};

#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, TS)]
#[ts(export, export_to = "../frontend/src/lib/types/generated/")]
pub struct TransitionMetadata {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub actor: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub reason: Option<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub evidence_refs: Vec<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
#[ts(export, export_to = "../frontend/src/lib/types/generated/")]
pub struct LedgerEntry {
    pub schema_version: String,
    pub recorded_at: String,
    pub record_id: String,
    pub scope_key: String,
    pub action: MemoryLedgerAction,
    pub source_kind: MemorySourceKind,
    #[serde(default)]
    pub metadata: TransitionMetadata,
    pub record: MemoryRecord,
}

#[derive(Debug, Clone)]
pub struct LifecycleStore {
    ledger_path: PathBuf,
}

impl LifecycleStore {
    pub fn new(root: &Path) -> Self {
        Self {
            ledger_path: root.join(LEDGER_FILE_NAME),
        }
    }

    pub fn ledger_path(&self) -> &Path {
        &self.ledger_path
    }

    pub fn projection_snapshot_path(&self) -> PathBuf {
        self.ledger_path
            .parent()
            .unwrap_or_else(|| Path::new("."))
            .join(projection::PROJECTION_SNAPSHOT_FILE_NAME)
    }

    pub fn append(&self, entry: &LedgerEntry) -> anyhow::Result<()> {
        if let Some(parent) = self.ledger_path.parent() {
            fs::create_dir_all(parent)?;
        }
        let mut file = OpenOptions::new()
            .create(true)
            .append(true)
            .open(&self.ledger_path)?;
        writeln!(file, "{}", serde_json::to_string(entry)?)?;
        file.sync_data()?;
        projection::invalidate_projection_cache(
            &self.ledger_path,
            self.projection_snapshot_path().as_path(),
        );
        Ok(())
    }

    pub fn read_all(&self) -> anyhow::Result<Vec<LedgerEntry>> {
        if !self.ledger_path.exists() {
            return Ok(Vec::new());
        }

        let file = fs::File::open(&self.ledger_path)?;
        let reader = BufReader::new(file);
        // We deliberately skip-and-warn malformed lines instead of
        // returning Err: a single corrupted line (incomplete write
        // after SIGKILL, accidental hand-edit, git merge marker, …)
        // would otherwise take the entire query surface offline —
        // wakeup, review queue, show, history all go dark together.
        //
        // The trade-off: a malformed entry is silently ignored from
        // the projection. We log it to stderr so `spool mcp doctor`
        // and operators can surface it. Loss-of-truth is preferable
        // to total-blackout because the ledger remains the source
        // of truth — the next valid append re-establishes state.
        let mut entries = Vec::new();
        for (idx, line) in reader.lines().enumerate() {
            let line_no = idx + 1;
            let raw = match line {
                Ok(raw) => raw,
                Err(err) => {
                    eprintln!(
                        "[spool ledger] read error at {}:{line_no}: {err}",
                        self.ledger_path.display()
                    );
                    continue;
                }
            };
            if raw.trim().is_empty() {
                continue;
            }
            match serde_json::from_str::<LedgerEntry>(&raw) {
                Ok(entry) => entries.push(entry),
                Err(err) => {
                    eprintln!(
                        "[spool ledger] malformed entry at {}:{line_no}: {err}",
                        self.ledger_path.display()
                    );
                }
            }
        }
        Ok(entries)
    }
}

#[derive(Debug, Clone)]
pub struct LifecycleProjection {
    latest_entries: Vec<LedgerEntry>,
    latest_index_by_record_id: BTreeMap<String, usize>,
}

impl LifecycleProjection {
    pub fn from_entries(entries: Vec<LedgerEntry>) -> Self {
        let mut latest_entries = Vec::new();
        let mut latest_index_by_record_id = BTreeMap::new();

        for entry in entries {
            if let Some(index) = latest_index_by_record_id.get(&entry.record_id).copied() {
                latest_entries[index] = entry;
            } else {
                latest_index_by_record_id.insert(entry.record_id.clone(), latest_entries.len());
                latest_entries.push(entry);
            }
        }

        Self {
            latest_entries,
            latest_index_by_record_id,
        }
    }

    pub fn latest_entries(&self) -> &[LedgerEntry] {
        &self.latest_entries
    }

    pub fn latest_by_record_id(&self, record_id: &str) -> Option<&LedgerEntry> {
        self.latest_index_by_record_id
            .get(record_id)
            .and_then(|index| self.latest_entries.get(*index))
    }

    pub fn by_scope(&self, scope: MemoryScope, scope_key: &str) -> Vec<LedgerEntry> {
        self.latest_entries
            .iter()
            .filter(|entry| entry.record.scope == scope && entry.scope_key == scope_key)
            .cloned()
            .collect()
    }

    pub fn by_state(&self, state: MemoryLifecycleState) -> Vec<LedgerEntry> {
        self.latest_entries
            .iter()
            .filter(|entry| entry.record.state == state)
            .cloned()
            .collect()
    }

    pub fn pending_review(&self) -> Vec<LedgerEntry> {
        self.latest_entries
            .iter()
            .filter(|entry| entry.record.requires_review())
            .cloned()
            .collect()
    }

    pub fn wakeup_ready(&self) -> Vec<LedgerEntry> {
        self.latest_entries
            .iter()
            .filter(|entry| entry.record.can_be_returned_in_wakeup())
            .cloned()
            .collect()
    }
}

#[derive(Debug, Clone)]
pub struct RecordMemoryRequest {
    pub title: String,
    pub summary: String,
    pub memory_type: String,
    pub scope: MemoryScope,
    pub source_ref: String,
    pub project_id: Option<String>,
    pub user_id: Option<String>,
    pub sensitivity: Option<String>,
    pub metadata: TransitionMetadata,
    // Structured retrieval signals
    pub entities: Vec<String>,
    pub tags: Vec<String>,
    pub triggers: Vec<String>,
    pub related_files: Vec<String>,
    pub related_records: Vec<String>,
    pub supersedes: Option<String>,
    pub applies_to: Vec<String>,
    pub valid_until: Option<String>,
}

#[derive(Debug, Clone)]
pub struct ProposeMemoryRequest {
    pub title: String,
    pub summary: String,
    pub memory_type: String,
    pub scope: MemoryScope,
    pub source_ref: String,
    pub project_id: Option<String>,
    pub user_id: Option<String>,
    pub sensitivity: Option<String>,
    pub metadata: TransitionMetadata,
    // Structured retrieval signals
    pub entities: Vec<String>,
    pub tags: Vec<String>,
    pub triggers: Vec<String>,
    pub related_files: Vec<String>,
    pub related_records: Vec<String>,
    pub supersedes: Option<String>,
    pub applies_to: Vec<String>,
    pub valid_until: Option<String>,
}

pub fn ledger_file_name() -> &'static str {
    LEDGER_FILE_NAME
}

pub fn lifecycle_root_from_config(config_dir: &Path) -> PathBuf {
    if config_dir.file_name().and_then(|n| n.to_str()) == Some(".spool") {
        config_dir.to_path_buf()
    } else {
        config_dir.join(".spool")
    }
}

#[cfg(test)]
mod tests;