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);
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,
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,
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;