Skip to main content

spool/domain/
memory_lifecycle.rs

1use serde::{Deserialize, Serialize};
2use ts_rs::TS;
3
4#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, TS)]
5#[serde(rename_all = "snake_case")]
6#[ts(export, export_to = "../frontend/src/lib/types/generated/")]
7pub enum MemoryScope {
8    User,
9    Project,
10    Workspace,
11    Team,
12    Agent,
13}
14
15#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, TS)]
16#[serde(rename_all = "snake_case")]
17#[ts(export, export_to = "../frontend/src/lib/types/generated/")]
18pub enum MemorySourceKind {
19    Manual,
20    AiProposal,
21    SessionCapture,
22    Distilled,
23    Imported,
24}
25
26#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, TS)]
27#[serde(rename_all = "snake_case")]
28#[ts(export, export_to = "../frontend/src/lib/types/generated/")]
29pub enum MemoryLifecycleState {
30    Draft,
31    Candidate,
32    Accepted,
33    Canonical,
34    Archived,
35}
36
37#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, TS)]
38#[serde(rename_all = "snake_case")]
39#[ts(export, export_to = "../frontend/src/lib/types/generated/")]
40pub enum MemoryPromotionAction {
41    SubmitProposal,
42    Accept,
43    PromoteToCanonical,
44    Archive,
45}
46
47#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, TS)]
48#[serde(rename_all = "snake_case")]
49#[ts(export, export_to = "../frontend/src/lib/types/generated/")]
50pub enum MemoryLedgerAction {
51    RecordManual,
52    ProposeAi,
53    SubmitProposal,
54    Accept,
55    PromoteToCanonical,
56    Archive,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
60#[ts(export, export_to = "../frontend/src/lib/types/generated/")]
61pub struct MemoryOrigin {
62    pub source_kind: MemorySourceKind,
63    pub source_ref: String,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
67#[ts(export, export_to = "../frontend/src/lib/types/generated/")]
68pub struct MemoryRecord {
69    pub title: String,
70    pub summary: String,
71    pub memory_type: String,
72    pub scope: MemoryScope,
73    pub state: MemoryLifecycleState,
74    pub origin: MemoryOrigin,
75    pub project_id: Option<String>,
76    pub user_id: Option<String>,
77    pub sensitivity: Option<String>,
78    // Structured retrieval signals
79    #[serde(default, skip_serializing_if = "Vec::is_empty")]
80    pub entities: Vec<String>,
81    #[serde(default, skip_serializing_if = "Vec::is_empty")]
82    pub tags: Vec<String>,
83    #[serde(default, skip_serializing_if = "Vec::is_empty")]
84    pub triggers: Vec<String>,
85    #[serde(default, skip_serializing_if = "Vec::is_empty")]
86    pub related_files: Vec<String>,
87    #[serde(default, skip_serializing_if = "Vec::is_empty")]
88    pub related_records: Vec<String>,
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub supersedes: Option<String>,
91    #[serde(default, skip_serializing_if = "Vec::is_empty")]
92    pub applies_to: Vec<String>,
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub valid_until: Option<String>,
95}
96
97impl MemoryRecord {
98    pub fn new_manual(
99        title: impl Into<String>,
100        summary: impl Into<String>,
101        memory_type: impl Into<String>,
102        scope: MemoryScope,
103        source_ref: impl Into<String>,
104    ) -> Self {
105        Self {
106            title: title.into(),
107            summary: summary.into(),
108            memory_type: memory_type.into(),
109            scope,
110            state: MemoryLifecycleState::Accepted,
111            origin: MemoryOrigin {
112                source_kind: MemorySourceKind::Manual,
113                source_ref: source_ref.into(),
114            },
115            project_id: None,
116            user_id: None,
117            sensitivity: None,
118            entities: Vec::new(),
119            tags: Vec::new(),
120            triggers: Vec::new(),
121            related_files: Vec::new(),
122            related_records: Vec::new(),
123            supersedes: None,
124            applies_to: Vec::new(),
125            valid_until: None,
126        }
127    }
128
129    pub fn new_ai_proposal(
130        title: impl Into<String>,
131        summary: impl Into<String>,
132        memory_type: impl Into<String>,
133        scope: MemoryScope,
134        source_ref: impl Into<String>,
135    ) -> Self {
136        Self {
137            title: title.into(),
138            summary: summary.into(),
139            memory_type: memory_type.into(),
140            scope,
141            state: MemoryLifecycleState::Candidate,
142            origin: MemoryOrigin {
143                source_kind: MemorySourceKind::AiProposal,
144                source_ref: source_ref.into(),
145            },
146            project_id: None,
147            user_id: None,
148            sensitivity: None,
149            entities: Vec::new(),
150            tags: Vec::new(),
151            triggers: Vec::new(),
152            related_files: Vec::new(),
153            related_records: Vec::new(),
154            supersedes: None,
155            applies_to: Vec::new(),
156            valid_until: None,
157        }
158    }
159
160    pub fn with_project_id(mut self, project_id: impl Into<String>) -> Self {
161        self.project_id = Some(project_id.into());
162        self
163    }
164
165    pub fn with_user_id(mut self, user_id: impl Into<String>) -> Self {
166        self.user_id = Some(user_id.into());
167        self
168    }
169
170    pub fn with_sensitivity(mut self, sensitivity: impl Into<String>) -> Self {
171        self.sensitivity = Some(sensitivity.into());
172        self
173    }
174}
175
176pub fn next_state(
177    current: MemoryLifecycleState,
178    action: MemoryPromotionAction,
179) -> MemoryLifecycleState {
180    match (current, action) {
181        (MemoryLifecycleState::Draft, MemoryPromotionAction::SubmitProposal) => {
182            MemoryLifecycleState::Candidate
183        }
184        (MemoryLifecycleState::Candidate, MemoryPromotionAction::Accept) => {
185            MemoryLifecycleState::Accepted
186        }
187        (MemoryLifecycleState::Accepted, MemoryPromotionAction::PromoteToCanonical) => {
188            MemoryLifecycleState::Canonical
189        }
190        (_, MemoryPromotionAction::Archive) => MemoryLifecycleState::Archived,
191        _ => current,
192    }
193}
194
195impl MemoryRecord {
196    pub fn apply_ledger_action(self, action: MemoryLedgerAction) -> Self {
197        let next_state = match action {
198            MemoryLedgerAction::RecordManual => self.state,
199            MemoryLedgerAction::ProposeAi => self.state,
200            MemoryLedgerAction::SubmitProposal => {
201                next_state(self.state, MemoryPromotionAction::SubmitProposal)
202            }
203            MemoryLedgerAction::Accept => next_state(self.state, MemoryPromotionAction::Accept),
204            MemoryLedgerAction::PromoteToCanonical => {
205                next_state(self.state, MemoryPromotionAction::PromoteToCanonical)
206            }
207            MemoryLedgerAction::Archive => next_state(self.state, MemoryPromotionAction::Archive),
208        };
209
210        Self {
211            state: next_state,
212            ..self
213        }
214    }
215
216    pub fn apply(self, action: MemoryPromotionAction) -> Self {
217        let next_state = next_state(self.state, action);
218        Self {
219            state: next_state,
220            ..self
221        }
222    }
223
224    pub fn can_be_returned_in_wakeup(&self) -> bool {
225        matches!(
226            self.state,
227            MemoryLifecycleState::Accepted | MemoryLifecycleState::Canonical
228        )
229    }
230
231    pub fn requires_review(&self) -> bool {
232        matches!(
233            self.origin.source_kind,
234            MemorySourceKind::AiProposal
235                | MemorySourceKind::SessionCapture
236                | MemorySourceKind::Distilled
237        ) && matches!(
238            self.state,
239            MemoryLifecycleState::Draft | MemoryLifecycleState::Candidate
240        )
241    }
242}
243
244pub fn ledger_action_for_source(source_kind: MemorySourceKind) -> MemoryLedgerAction {
245    match source_kind {
246        MemorySourceKind::Manual => MemoryLedgerAction::RecordManual,
247        MemorySourceKind::AiProposal => MemoryLedgerAction::ProposeAi,
248        MemorySourceKind::SessionCapture => MemoryLedgerAction::SubmitProposal,
249        MemorySourceKind::Distilled => MemoryLedgerAction::SubmitProposal,
250        MemorySourceKind::Imported => MemoryLedgerAction::SubmitProposal,
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::{
257        MemoryLedgerAction, MemoryLifecycleState, MemoryPromotionAction, MemoryRecord, MemoryScope,
258        MemorySourceKind, ledger_action_for_source, next_state,
259    };
260
261    #[test]
262    fn manual_memory_should_start_as_accepted() {
263        let record = MemoryRecord::new_manual(
264            "简洁输出",
265            "偏好简短直接的回复",
266            "preference",
267            MemoryScope::User,
268            "manual:cli",
269        )
270        .with_user_id("long");
271
272        assert_eq!(record.state, MemoryLifecycleState::Accepted);
273        assert_eq!(record.origin.source_kind, MemorySourceKind::Manual);
274        assert!(record.can_be_returned_in_wakeup());
275        assert!(!record.requires_review());
276    }
277
278    #[test]
279    fn ai_proposal_should_require_review_before_acceptance() {
280        let candidate = MemoryRecord::new_ai_proposal(
281            "常用测试策略",
282            "偏好先补 CLI smoke 再收口内部测试",
283            "workflow",
284            MemoryScope::User,
285            "session:2026-04-09",
286        );
287
288        assert_eq!(candidate.state, MemoryLifecycleState::Candidate);
289        assert!(candidate.requires_review());
290        assert!(!candidate.can_be_returned_in_wakeup());
291
292        let accepted = candidate.apply(MemoryPromotionAction::Accept);
293        assert_eq!(accepted.state, MemoryLifecycleState::Accepted);
294        assert!(accepted.can_be_returned_in_wakeup());
295    }
296
297    #[test]
298    fn accepted_memory_can_be_promoted_to_canonical_and_archived() {
299        let accepted = MemoryRecord::new_manual(
300            "Obsidian 作为主库",
301            "项目记忆以 Obsidian 为可信知识主库",
302            "project",
303            MemoryScope::Project,
304            "vault:10-Projects/spool.md",
305        )
306        .with_project_id("spool");
307
308        let canonical = accepted.apply(MemoryPromotionAction::PromoteToCanonical);
309        assert_eq!(canonical.state, MemoryLifecycleState::Canonical);
310
311        let archived = canonical.apply(MemoryPromotionAction::Archive);
312        assert_eq!(archived.state, MemoryLifecycleState::Archived);
313        assert!(!archived.can_be_returned_in_wakeup());
314    }
315
316    #[test]
317    fn lifecycle_next_state_should_keep_invalid_transitions_unchanged() {
318        assert_eq!(
319            next_state(
320                MemoryLifecycleState::Accepted,
321                MemoryPromotionAction::Accept,
322            ),
323            MemoryLifecycleState::Accepted
324        );
325        assert_eq!(
326            next_state(
327                MemoryLifecycleState::Candidate,
328                MemoryPromotionAction::PromoteToCanonical,
329            ),
330            MemoryLifecycleState::Candidate
331        );
332        assert_eq!(
333            next_state(
334                MemoryLifecycleState::Canonical,
335                MemoryPromotionAction::SubmitProposal,
336            ),
337            MemoryLifecycleState::Canonical
338        );
339    }
340
341    #[test]
342    fn ledger_action_mapping_should_preserve_source_semantics() {
343        assert_eq!(
344            ledger_action_for_source(MemorySourceKind::Manual),
345            MemoryLedgerAction::RecordManual
346        );
347        assert_eq!(
348            ledger_action_for_source(MemorySourceKind::AiProposal),
349            MemoryLedgerAction::ProposeAi
350        );
351        assert_eq!(
352            ledger_action_for_source(MemorySourceKind::Distilled),
353            MemoryLedgerAction::SubmitProposal
354        );
355    }
356}