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>,
#[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
);
}
}