use crate::domain::{MemoryLifecycleState, MemoryRecord, MemoryScope, MemorySourceKind};
use crate::lifecycle_store::LedgerEntry;
use anyhow::{Context, Result, bail};
use std::collections::BTreeMap;
use std::fs;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
pub const MEMORY_LEDGER_DIR: &str = "50-Memory-Ledger/Extracted";
pub const MEMORY_LEDGER_COMPILED_DIR: &str = "50-Memory-Ledger/Compiled";
pub const NOTE_VERSION: &str = "memory-note.v1";
pub const BODY_HASH_KEY: &str = "spool_body_hash";
pub const VERSION_KEY: &str = "spool_version";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WriteStatus {
Created,
UpdatedAll,
UpdatedPreserveBody,
Unchanged,
}
#[derive(Debug, Clone)]
pub struct VaultWriteResult {
pub path: PathBuf,
pub status: WriteStatus,
pub body_user_edited: bool,
}
pub fn memory_note_path(vault_root: &Path, record_id: &str) -> PathBuf {
vault_root
.join(MEMORY_LEDGER_DIR)
.join(format!("{record_id}.md"))
}
pub fn memory_note_path_for(vault_root: &Path, record_id: &str, memory_type: &str) -> PathBuf {
let dir = if memory_type == "knowledge" {
MEMORY_LEDGER_COMPILED_DIR
} else {
MEMORY_LEDGER_DIR
};
vault_root.join(dir).join(format!("{record_id}.md"))
}
pub fn write_memory_note(
vault_root: &Path,
record_id: &str,
record: &MemoryRecord,
) -> Result<VaultWriteResult> {
if record_id.is_empty() {
bail!("record_id must not be empty");
}
let path = memory_note_path_for(vault_root, record_id, &record.memory_type);
let existing = read_existing_note(&path)?;
let desired_body = render_body(record);
let (final_body, base_status, body_user_edited) = match &existing {
None => (desired_body, WriteStatus::Created, false),
Some(existing) => {
let current_hash = body_hash(&existing.body);
let user_edited = existing
.stored_body_hash
.as_deref()
.map(|stored| stored != current_hash)
.unwrap_or(false);
if user_edited {
(
existing.body.clone(),
WriteStatus::UpdatedPreserveBody,
true,
)
} else {
(desired_body, WriteStatus::UpdatedAll, false)
}
}
};
let fm = render_frontmatter(record_id, record, &final_body);
let desired_content = format_note(&fm, &final_body)?;
let status = if let Some(existing) = &existing {
if existing.raw_content == desired_content {
WriteStatus::Unchanged
} else {
base_status
}
} else {
base_status
};
if status != WriteStatus::Unchanged {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| {
format!(
"failed to create memory note parent dir {}",
parent.display()
)
})?;
}
fs::write(&path, &desired_content)
.with_context(|| format!("failed to write memory note {}", path.display()))?;
}
Ok(VaultWriteResult {
path,
status,
body_user_edited,
})
}
pub fn archive_memory_note(vault_root: &Path, record_id: &str) -> Result<Option<VaultWriteResult>> {
let extracted = memory_note_path(vault_root, record_id);
let compiled = vault_root
.join(MEMORY_LEDGER_COMPILED_DIR)
.join(format!("{record_id}.md"));
let path = if extracted.exists() {
extracted
} else if compiled.exists() {
compiled
} else {
return Ok(None);
};
let existing = read_existing_note(&path)?.expect("path.exists guarded");
let body = existing.body.clone();
let body_hash_value = body_hash(&body);
let mut fm = existing.frontmatter.clone();
fm.insert("archived".to_string(), serde_yaml::Value::Bool(true));
fm.insert(
"archived_at".to_string(),
serde_yaml::Value::String(current_timestamp()),
);
fm.insert(
"state".to_string(),
serde_yaml::Value::String("archived".to_string()),
);
fm.insert(
"source_of_truth".to_string(),
serde_yaml::Value::Bool(false),
);
fm.insert(
BODY_HASH_KEY.to_string(),
serde_yaml::Value::String(body_hash_value),
);
let content = format_note(&fm, &body)?;
if existing.raw_content == content {
return Ok(Some(VaultWriteResult {
path,
status: WriteStatus::Unchanged,
body_user_edited: false,
}));
}
fs::write(&path, &content)
.with_context(|| format!("failed to archive memory note {}", path.display()))?;
Ok(Some(VaultWriteResult {
path,
status: WriteStatus::UpdatedAll,
body_user_edited: false,
}))
}
struct ExistingNote {
frontmatter: BTreeMap<String, serde_yaml::Value>,
body: String,
stored_body_hash: Option<String>,
raw_content: String,
}
fn read_existing_note(path: &Path) -> Result<Option<ExistingNote>> {
if !path.exists() {
return Ok(None);
}
let raw = fs::read_to_string(path)
.with_context(|| format!("failed to read memory note {}", path.display()))?;
let (fm_text, body) = split_frontmatter_raw(&raw);
let frontmatter: BTreeMap<String, serde_yaml::Value> = match fm_text {
Some(text) if !text.trim().is_empty() => serde_yaml::from_str(text)
.with_context(|| format!("failed to parse frontmatter in {}", path.display()))?,
_ => BTreeMap::new(),
};
let stored_body_hash = frontmatter
.get(BODY_HASH_KEY)
.and_then(|v| v.as_str())
.map(ToString::to_string);
Ok(Some(ExistingNote {
frontmatter,
body,
stored_body_hash,
raw_content: raw,
}))
}
fn split_frontmatter_raw(raw: &str) -> (Option<&str>, String) {
let rest = if let Some(r) = raw.strip_prefix("---\n") {
r
} else if let Some(r) = raw.strip_prefix("---\r\n") {
r
} else {
return (None, raw.to_string());
};
if let Some(end) = rest.find("\n---\n") {
let fm = &rest[..end];
let body_start = end + "\n---\n".len();
let body = strip_one_leading_newline(&rest[body_start..]);
(Some(fm), body)
} else if let Some(end) = rest.find("\n---\r\n") {
let fm = &rest[..end];
let body_start = end + "\n---\r\n".len();
let body = strip_one_leading_newline(&rest[body_start..]);
(Some(fm), body)
} else if let Some(stripped) = rest.strip_suffix("\n---") {
(Some(stripped), String::new())
} else {
(None, raw.to_string())
}
}
fn strip_one_leading_newline(s: &str) -> String {
if let Some(stripped) = s.strip_prefix("\r\n") {
stripped.to_string()
} else if let Some(stripped) = s.strip_prefix('\n') {
stripped.to_string()
} else {
s.to_string()
}
}
fn render_body(record: &MemoryRecord) -> String {
let summary = record.summary.trim();
if record.memory_type == "knowledge" {
format!(
"# {title}\n\n{summary}\n",
title = record.title.trim(),
summary = if summary.is_empty() {
"_no summary_"
} else {
summary
},
)
} else {
format!(
"# {title}\n\n{summary}\n\n## Provenance\n\n- source_kind: {source_kind}\n- source_ref: {source_ref}\n",
title = record.title.trim(),
summary = if summary.is_empty() {
"_no summary_"
} else {
summary
},
source_kind = format_source_kind(record.origin.source_kind),
source_ref = record.origin.source_ref,
)
}
}
fn body_hash(body: &str) -> String {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
body.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
fn render_frontmatter(
record_id: &str,
record: &MemoryRecord,
body: &str,
) -> BTreeMap<String, serde_yaml::Value> {
use serde_yaml::Value;
let mut fm = BTreeMap::new();
fm.insert(
VERSION_KEY.to_string(),
Value::String(NOTE_VERSION.to_string()),
);
fm.insert(
"record_id".to_string(),
Value::String(record_id.to_string()),
);
fm.insert(
"memory_type".to_string(),
Value::String(record.memory_type.clone()),
);
fm.insert(
"scope".to_string(),
Value::String(map_scope(record.scope).to_string()),
);
fm.insert(
"state".to_string(),
Value::String(map_state(record.state).to_string()),
);
fm.insert(
"source_of_truth".to_string(),
Value::Bool(matches!(record.state, MemoryLifecycleState::Canonical)),
);
if let Some(pid) = &record.project_id {
fm.insert("project_id".to_string(), Value::String(pid.clone()));
}
if let Some(uid) = &record.user_id {
fm.insert("user_id".to_string(), Value::String(uid.clone()));
}
if let Some(sens) = &record.sensitivity {
fm.insert("sensitivity".to_string(), Value::String(sens.clone()));
}
fm.insert(
"source_kind".to_string(),
Value::String(format_source_kind(record.origin.source_kind).to_string()),
);
fm.insert(
"source_ref".to_string(),
Value::String(record.origin.source_ref.clone()),
);
if !record.entities.is_empty() {
fm.insert(
"entities".to_string(),
Value::Sequence(
record
.entities
.iter()
.map(|s| Value::String(s.clone()))
.collect(),
),
);
}
if !record.tags.is_empty() {
fm.insert(
"tags".to_string(),
Value::Sequence(
record
.tags
.iter()
.map(|s| Value::String(s.clone()))
.collect(),
),
);
}
if !record.triggers.is_empty() {
fm.insert(
"triggers".to_string(),
Value::Sequence(
record
.triggers
.iter()
.map(|s| Value::String(s.clone()))
.collect(),
),
);
}
if !record.related_files.is_empty() {
fm.insert(
"related_files".to_string(),
Value::Sequence(
record
.related_files
.iter()
.map(|s| Value::String(s.clone()))
.collect(),
),
);
}
if !record.related_records.is_empty() {
fm.insert(
"related_memory".to_string(),
Value::Sequence(
record
.related_records
.iter()
.map(|s| Value::String(format!("[[{s}]]")))
.collect(),
),
);
}
if let Some(supersedes) = &record.supersedes {
fm.insert("supersedes".to_string(), Value::String(supersedes.clone()));
}
fm.insert(BODY_HASH_KEY.to_string(), Value::String(body_hash(body)));
fm
}
fn format_note(fm: &BTreeMap<String, serde_yaml::Value>, body: &str) -> Result<String> {
let yaml = serde_yaml::to_string(fm).context("failed to serialize frontmatter as yaml")?;
let body_trimmed = body.trim_end_matches('\n');
Ok(format!("---\n{yaml}---\n\n{body_trimmed}\n"))
}
fn map_scope(scope: MemoryScope) -> &'static str {
match scope {
MemoryScope::User => "personal",
MemoryScope::Project => "project",
MemoryScope::Workspace => "team",
MemoryScope::Team => "team",
MemoryScope::Agent => "personal",
}
}
fn map_state(state: MemoryLifecycleState) -> &'static str {
match state {
MemoryLifecycleState::Draft => "draft",
MemoryLifecycleState::Candidate => "candidate",
MemoryLifecycleState::Accepted => "accepted",
MemoryLifecycleState::Canonical => "canonical",
MemoryLifecycleState::Archived => "archived",
}
}
fn format_source_kind(kind: MemorySourceKind) -> &'static str {
match kind {
MemorySourceKind::Manual => "manual",
MemorySourceKind::AiProposal => "ai_proposal",
MemorySourceKind::SessionCapture => "session_capture",
MemorySourceKind::Distilled => "distilled",
MemorySourceKind::Imported => "imported",
}
}
fn current_timestamp() -> String {
let seconds = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
format!("unix:{seconds}")
}
pub fn apply_writeback_for_entry(
vault_root: &Path,
entry: &LedgerEntry,
) -> Option<VaultWriteResult> {
match entry.record.state {
MemoryLifecycleState::Archived => match archive_memory_note(vault_root, &entry.record_id) {
Ok(result) => result,
Err(error) => {
log_writeback_error(&entry.record_id, &error);
None
}
},
MemoryLifecycleState::Accepted | MemoryLifecycleState::Canonical => {
match write_memory_note(vault_root, &entry.record_id, &entry.record) {
Ok(result) => Some(result),
Err(error) => {
log_writeback_error(&entry.record_id, &error);
None
}
}
}
MemoryLifecycleState::Draft | MemoryLifecycleState::Candidate => None,
}
}
pub fn writeback_from_config(config_path: &Path, entry: &LedgerEntry) -> Option<VaultWriteResult> {
let vault_root = match resolve_vault_root(config_path) {
Ok(root) => root,
Err(error) => {
log_writeback_error(&entry.record_id, &error);
return None;
}
};
let result = apply_writeback_for_entry(&vault_root, entry);
let _ = crate::wiki_index::refresh_index_from_config(config_path);
let _ = crate::knowledge::auto_compile_from_config(config_path);
result
}
pub fn writeback_from_config_no_compile(
config_path: &Path,
entry: &LedgerEntry,
) -> Option<VaultWriteResult> {
let vault_root = match resolve_vault_root(config_path) {
Ok(root) => root,
Err(error) => {
log_writeback_error(&entry.record_id, &error);
return None;
}
};
let result = apply_writeback_for_entry(&vault_root, entry);
let _ = crate::wiki_index::refresh_index_from_config(config_path);
result
}
fn resolve_vault_root(config_path: &Path) -> Result<PathBuf> {
let config = crate::app::load(config_path)
.with_context(|| format!("failed to load config {}", config_path.display()))?;
crate::app::resolve_override_path(&config.vault.root, config_path)
}
fn log_writeback_error(record_id: &str, error: &anyhow::Error) {
eprintln!("[spool] vault writeback failed for record {record_id}: {error}");
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::{
MemoryLifecycleState, MemoryOrigin, MemoryRecord, MemoryScope, MemorySourceKind,
};
use tempfile::tempdir;
fn sample_record(state: MemoryLifecycleState) -> MemoryRecord {
MemoryRecord {
title: "简洁输出".to_string(),
summary: "偏好简短直接的回复,不要 trailing 总结".to_string(),
memory_type: "preference".to_string(),
scope: MemoryScope::User,
state,
origin: MemoryOrigin {
source_kind: MemorySourceKind::Manual,
source_ref: "manual:cli".to_string(),
},
project_id: None,
user_id: Some("long".to_string()),
sensitivity: Some("internal".to_string()),
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,
}
}
#[test]
fn write_memory_note_should_create_new_file_with_frontmatter_and_body() {
let temp = tempdir().unwrap();
let result = write_memory_note(
temp.path(),
"rec-001",
&sample_record(MemoryLifecycleState::Accepted),
)
.unwrap();
assert_eq!(result.status, WriteStatus::Created);
assert!(!result.body_user_edited);
let content = fs::read_to_string(&result.path).unwrap();
assert!(content.starts_with("---\n"));
assert!(content.contains("record_id: rec-001"));
assert!(content.contains("memory_type: preference"));
assert!(content.contains("scope: personal")); assert!(content.contains("state: accepted"));
assert!(content.contains("source_of_truth: false"));
assert!(content.contains("spool_body_hash:"));
assert!(content.contains("# 简洁输出"));
assert!(content.contains("## Provenance"));
assert!(content.contains("source_kind: manual"));
}
#[test]
fn write_memory_note_should_mark_canonical_as_source_of_truth() {
let temp = tempdir().unwrap();
let result = write_memory_note(
temp.path(),
"rec-002",
&sample_record(MemoryLifecycleState::Canonical),
)
.unwrap();
let content = fs::read_to_string(&result.path).unwrap();
assert!(content.contains("state: canonical"));
assert!(content.contains("source_of_truth: true"));
}
#[test]
fn write_memory_note_should_be_idempotent_on_identical_record() {
let temp = tempdir().unwrap();
let record = sample_record(MemoryLifecycleState::Accepted);
let first = write_memory_note(temp.path(), "rec-003", &record).unwrap();
assert_eq!(first.status, WriteStatus::Created);
let second = write_memory_note(temp.path(), "rec-003", &record).unwrap();
assert_eq!(second.status, WriteStatus::Unchanged);
}
#[test]
fn write_memory_note_should_update_body_when_summary_changes() {
let temp = tempdir().unwrap();
let mut record = sample_record(MemoryLifecycleState::Accepted);
write_memory_note(temp.path(), "rec-004", &record).unwrap();
record.summary = "新的更短摘要".to_string();
let result = write_memory_note(temp.path(), "rec-004", &record).unwrap();
assert_eq!(result.status, WriteStatus::UpdatedAll);
assert!(!result.body_user_edited);
let content = fs::read_to_string(&result.path).unwrap();
assert!(content.contains("新的更短摘要"));
}
#[test]
fn write_memory_note_should_preserve_body_when_user_hand_edited() {
let temp = tempdir().unwrap();
let record = sample_record(MemoryLifecycleState::Accepted);
let first = write_memory_note(temp.path(), "rec-005", &record).unwrap();
let original = fs::read_to_string(&first.path).unwrap();
let user_edited =
original.replace("# 简洁输出", "# 简洁输出\n\n> NOTE: 用户手动补充的上下文");
fs::write(&first.path, user_edited).unwrap();
let result = write_memory_note(temp.path(), "rec-005", &record).unwrap();
assert_eq!(result.status, WriteStatus::UpdatedPreserveBody);
assert!(result.body_user_edited);
let content = fs::read_to_string(&result.path).unwrap();
assert!(content.contains("NOTE: 用户手动补充的上下文"));
}
#[test]
fn archive_memory_note_should_mark_archived_and_keep_body() {
let temp = tempdir().unwrap();
let record = sample_record(MemoryLifecycleState::Accepted);
write_memory_note(temp.path(), "rec-006", &record).unwrap();
let result = archive_memory_note(temp.path(), "rec-006")
.unwrap()
.expect("archive should return result for existing file");
assert_eq!(result.status, WriteStatus::UpdatedAll);
let content = fs::read_to_string(&result.path).unwrap();
assert!(content.contains("archived: true"));
assert!(content.contains("archived_at: unix:"));
assert!(content.contains("state: archived"));
assert!(content.contains("# 简洁输出"));
}
#[test]
fn archive_memory_note_should_return_none_for_missing_file() {
let temp = tempdir().unwrap();
assert!(
archive_memory_note(temp.path(), "missing")
.unwrap()
.is_none()
);
}
#[test]
fn write_memory_note_should_reject_empty_record_id() {
let temp = tempdir().unwrap();
let err = write_memory_note(
temp.path(),
"",
&sample_record(MemoryLifecycleState::Accepted),
)
.unwrap_err();
assert!(err.to_string().contains("record_id"));
}
#[test]
fn memory_note_path_should_use_extracted_dir_and_record_id() {
let path = memory_note_path(Path::new("/vault"), "abc");
assert_eq!(
path,
PathBuf::from("/vault/50-Memory-Ledger/Extracted/abc.md")
);
}
#[test]
fn memory_note_path_for_should_route_knowledge_to_compiled_dir() {
let compiled = memory_note_path_for(Path::new("/vault"), "wiki-1", "knowledge");
assert_eq!(
compiled,
PathBuf::from("/vault/50-Memory-Ledger/Compiled/wiki-1.md")
);
let fragment = memory_note_path_for(Path::new("/vault"), "frag-1", "preference");
assert_eq!(
fragment,
PathBuf::from("/vault/50-Memory-Ledger/Extracted/frag-1.md")
);
}
#[test]
fn write_memory_note_should_place_knowledge_in_compiled_dir() {
let temp = tempdir().unwrap();
let mut record = sample_record(MemoryLifecycleState::Accepted);
record.memory_type = "knowledge".to_string();
let result = write_memory_note(temp.path(), "wiki-x", &record).unwrap();
assert_eq!(result.status, WriteStatus::Created);
assert!(
temp.path()
.join("50-Memory-Ledger/Compiled/wiki-x.md")
.exists()
);
assert!(
!temp
.path()
.join("50-Memory-Ledger/Extracted/wiki-x.md")
.exists()
);
}
fn sample_entry(record_id: &str, state: MemoryLifecycleState) -> LedgerEntry {
LedgerEntry {
schema_version: "memory-ledger.v1".to_string(),
recorded_at: "unix:0".to_string(),
record_id: record_id.to_string(),
scope_key: "user".to_string(),
action: crate::domain::MemoryLedgerAction::RecordManual,
source_kind: MemorySourceKind::Manual,
metadata: Default::default(),
record: MemoryRecord {
state,
..sample_record(state)
},
}
}
#[test]
fn apply_writeback_should_write_for_accepted_and_canonical() {
let temp = tempdir().unwrap();
let entry_a = sample_entry("wb-accept", MemoryLifecycleState::Accepted);
let entry_c = sample_entry("wb-canon", MemoryLifecycleState::Canonical);
assert!(apply_writeback_for_entry(temp.path(), &entry_a).is_some());
assert!(apply_writeback_for_entry(temp.path(), &entry_c).is_some());
assert!(memory_note_path(temp.path(), "wb-accept").exists());
assert!(memory_note_path(temp.path(), "wb-canon").exists());
}
#[test]
fn apply_writeback_should_skip_draft_and_candidate() {
let temp = tempdir().unwrap();
let entry_d = sample_entry("wb-draft", MemoryLifecycleState::Draft);
let entry_p = sample_entry("wb-cand", MemoryLifecycleState::Candidate);
assert!(apply_writeback_for_entry(temp.path(), &entry_d).is_none());
assert!(apply_writeback_for_entry(temp.path(), &entry_p).is_none());
assert!(!memory_note_path(temp.path(), "wb-draft").exists());
assert!(!memory_note_path(temp.path(), "wb-cand").exists());
}
#[test]
fn apply_writeback_should_archive_when_state_archived() {
let temp = tempdir().unwrap();
let entry_a = sample_entry("wb-life", MemoryLifecycleState::Accepted);
apply_writeback_for_entry(temp.path(), &entry_a);
let archived_entry = sample_entry("wb-life", MemoryLifecycleState::Archived);
let result = apply_writeback_for_entry(temp.path(), &archived_entry).unwrap();
let content = fs::read_to_string(&result.path).unwrap();
assert!(content.contains("archived: true"));
}
}