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 #[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}