1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
4pub enum MemoryType {
5 Decision,
6 Discovery,
7 Bugfix,
8 Architecture,
9 Lesson,
10 Preference,
11 Procedure,
12 SessionActivity,
13}
14
15impl MemoryType {
16 pub const ALL: [Self; 8] = [
17 Self::Decision,
18 Self::Discovery,
19 Self::Bugfix,
20 Self::Architecture,
21 Self::Lesson,
22 Self::Preference,
23 Self::Procedure,
24 Self::SessionActivity,
25 ];
26
27 pub const fn as_str(self) -> &'static str {
28 match self {
29 Self::Decision => "decision",
30 Self::Discovery => "discovery",
31 Self::Bugfix => "bugfix",
32 Self::Architecture => "architecture",
33 Self::Lesson => "lesson",
34 Self::Preference => "preference",
35 Self::Procedure => "procedure",
36 Self::SessionActivity => "session_activity",
37 }
38 }
39
40 pub const fn label(self) -> &'static str {
41 match self {
42 Self::Decision => "Decisions",
43 Self::Discovery => "Discoveries",
44 Self::Bugfix => "Bug Fixes",
45 Self::Architecture => "Architecture",
46 Self::Lesson => "Lessons",
47 Self::Preference => "Preferences",
48 Self::Procedure => "Procedures",
49 Self::SessionActivity => "Sessions",
50 }
51 }
52
53 pub const fn index_order(self) -> Option<usize> {
54 match self {
55 Self::Decision => Some(0),
56 Self::Bugfix => Some(1),
57 Self::Architecture => Some(2),
58 Self::Discovery => Some(3),
59 Self::Procedure => Some(4),
60 Self::SessionActivity => Some(5),
61 Self::Lesson | Self::Preference => None,
62 }
63 }
64
65 pub const fn is_indexed(self) -> bool {
66 matches!(
67 self,
68 Self::Decision
69 | Self::Bugfix
70 | Self::Architecture
71 | Self::Discovery
72 | Self::Procedure
73 | Self::SessionActivity
74 )
75 }
76
77 pub const fn is_core(self) -> bool {
78 matches!(
79 self,
80 Self::Bugfix | Self::Architecture | Self::Decision | Self::Discovery
81 )
82 }
83
84 pub const fn weight(self) -> f64 {
85 match self {
86 Self::Bugfix => 3.0,
87 Self::Architecture => 2.6,
88 Self::Decision => 2.2,
89 Self::Discovery => 1.8,
90 Self::Lesson | Self::Preference | Self::Procedure | Self::SessionActivity => 0.0,
91 }
92 }
93
94 pub const fn auto_promote(self) -> bool {
95 matches!(
96 self,
97 Self::Architecture | Self::Bugfix | Self::Decision | Self::Discovery
98 )
99 }
100
101 pub fn parse(value: &str) -> Option<Self> {
102 Self::ALL
103 .iter()
104 .copied()
105 .find(|memory_type| memory_type.as_str() == value)
106 }
107
108 pub fn from_observation_type(observation_type: &str) -> Option<Self> {
119 match observation_type.trim().to_ascii_lowercase().as_str() {
120 "bugfix" => Some(Self::Bugfix),
121 "decision" => Some(Self::Decision),
122 "discovery" | "feature" | "refactor" | "change" => Some(Self::Discovery),
123 _ => None,
124 }
125 }
126
127 pub fn supports_observation_type(self, observation_type: &str) -> bool {
133 match Self::from_observation_type(observation_type) {
134 Some(mapped) => {
135 mapped == self || (self == Self::Architecture && mapped == Self::Discovery)
136 }
137 None => false,
138 }
139 }
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct Memory {
144 pub id: i64,
145 pub session_id: Option<String>,
146 pub project: String,
147 pub topic_key: Option<String>,
148 pub title: String,
149 pub text: String,
150 pub memory_type: String,
151 pub files: Option<String>,
152 pub created_at_epoch: i64,
153 pub updated_at_epoch: i64,
154 pub status: String,
155 #[serde(skip_serializing_if = "Option::is_none")]
156 pub branch: Option<String>,
157 #[serde(default = "default_scope")]
158 pub scope: String,
159}
160
161fn default_scope() -> String {
162 "project".to_string()
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct Event {
167 pub id: i64,
168 pub session_id: String,
169 pub project: String,
170 pub event_type: String,
171 pub summary: String,
172 pub detail: Option<String>,
173 pub files: Option<String>,
174 pub exit_code: Option<i32>,
175 pub created_at_epoch: i64,
176}
177
178pub const MEMORY_TYPES: &[&str] = &[
179 MemoryType::Decision.as_str(),
180 MemoryType::Discovery.as_str(),
181 MemoryType::Bugfix.as_str(),
182 MemoryType::Architecture.as_str(),
183 MemoryType::Lesson.as_str(),
184 MemoryType::Preference.as_str(),
185 MemoryType::Procedure.as_str(),
186 MemoryType::SessionActivity.as_str(),
187];
188
189pub const MEMORY_COLS: &str = "id, session_id, project, topic_key, title, content, memory_type, \
190 files, created_at_epoch, updated_at_epoch, status, branch, scope";
191
192pub fn memory_status_filter_sql(column: &str, include_inactive: bool) -> String {
193 if include_inactive {
194 format!("{column} IN ('active', 'stale', 'archived')")
195 } else {
196 format!("{column} = 'active'")
197 }
198}
199
200pub fn memory_current_filter_sql(
201 status_column: &str,
202 expires_column: &str,
203 include_inactive: bool,
204) -> String {
205 if include_inactive {
206 memory_status_filter_sql(status_column, true)
207 } else {
208 format!(
209 "{status_column} = 'active' AND \
210 ({expires_column} IS NULL OR {expires_column} > CAST(strftime('%s', 'now') AS INTEGER))"
211 )
212 }
213}
214
215pub fn memory_state_key_current_filter_sql(table_alias: &str) -> String {
216 let table_alias = table_alias.trim();
217 let qualifier = if table_alias.is_empty() {
218 String::new()
219 } else {
220 format!("{table_alias}.")
221 };
222 format!(
223 "({qualifier}state_key_id IS NULL OR NOT EXISTS (
224 SELECT 1 FROM memory_state_keys sk
225 WHERE sk.id = {qualifier}state_key_id
226 AND sk.current_memory_id IS NOT NULL
227 AND sk.current_memory_id <> {qualifier}id
228 ))"
229 )
230}
231
232pub fn memory_not_superseded_filter_sql(table_alias: &str) -> String {
233 let table_alias = table_alias.trim();
234 let qualifier = if table_alias.is_empty() {
235 String::new()
236 } else {
237 format!("{table_alias}.")
238 };
239 format!(
240 "NOT EXISTS (
241 SELECT 1 FROM memory_edges supersede_edge
242 WHERE supersede_edge.edge_type = 'supersedes'
243 AND supersede_edge.from_memory_id = {qualifier}id
244 )"
245 )
246}
247
248pub fn map_memory_row_pub(row: &rusqlite::Row) -> rusqlite::Result<Memory> {
249 map_memory_row(row)
250}
251
252pub(super) fn map_memory_row(row: &rusqlite::Row) -> rusqlite::Result<Memory> {
253 Ok(Memory {
254 id: row.get(0)?,
255 session_id: row.get(1)?,
256 project: row.get(2)?,
257 topic_key: row.get(3)?,
258 title: row.get(4)?,
259 text: row.get(5)?,
260 memory_type: row.get(6)?,
261 files: row.get(7)?,
262 created_at_epoch: row.get(8)?,
263 updated_at_epoch: row.get(9)?,
264 status: row.get(10)?,
265 branch: row.get(11)?,
266 scope: row
267 .get::<_, Option<String>>(12)?
268 .unwrap_or_else(|| "project".to_string()),
269 })
270}
271
272pub(super) fn map_event_row(row: &rusqlite::Row) -> rusqlite::Result<Event> {
273 Ok(Event {
274 id: row.get(0)?,
275 session_id: row.get(1)?,
276 project: row.get(2)?,
277 event_type: row.get(3)?,
278 summary: row.get(4)?,
279 detail: row.get(5)?,
280 files: row.get(6)?,
281 exit_code: row.get(7)?,
282 created_at_epoch: row.get(8)?,
283 })
284}
285
286#[cfg(test)]
287mod tests {
288 use super::{MemoryType, MEMORY_TYPES};
289
290 #[test]
291 fn memory_types_are_derived_from_canonical_enum_order() {
292 let canonical = MemoryType::ALL
293 .iter()
294 .copied()
295 .map(MemoryType::as_str)
296 .collect::<Vec<_>>();
297
298 assert_eq!(MEMORY_TYPES, canonical.as_slice());
299 }
300
301 #[test]
302 fn architecture_candidate_accepts_discovery_class_observations() {
303 assert!(MemoryType::Architecture.supports_observation_type("discovery"));
306 assert!(MemoryType::Architecture.supports_observation_type("feature"));
307 assert!(MemoryType::Architecture.supports_observation_type("refactor"));
308 assert!(MemoryType::Architecture.supports_observation_type("change"));
309 assert!(!MemoryType::Architecture.supports_observation_type("bugfix"));
311 assert!(!MemoryType::Architecture.supports_observation_type("decision"));
312 }
313
314 #[test]
315 fn auto_promote_types_match_their_observation_equivalents() {
316 assert!(MemoryType::Bugfix.supports_observation_type("bugfix"));
317 assert!(MemoryType::Decision.supports_observation_type("decision"));
318 assert!(MemoryType::Discovery.supports_observation_type("discovery"));
319 assert!(MemoryType::Discovery.supports_observation_type("feature"));
321 assert!(MemoryType::Discovery.supports_observation_type("refactor"));
322 assert!(MemoryType::Discovery.supports_observation_type("change"));
323 assert!(!MemoryType::Bugfix.supports_observation_type("decision"));
325 assert!(!MemoryType::Decision.supports_observation_type("discovery"));
326 assert!(MemoryType::from_observation_type("architecture").is_none());
328 assert!(MemoryType::from_observation_type("nonsense").is_none());
329 }
330
331 #[test]
332 fn procedure_has_context_metadata() {
333 let memory_type = MemoryType::Procedure;
334
335 assert_eq!(memory_type.as_str(), "procedure");
336 assert_eq!(memory_type.label(), "Procedures");
337 assert_eq!(memory_type.index_order(), Some(4));
338 assert!(memory_type.is_indexed());
339 assert!(!memory_type.is_core());
340 assert_eq!(memory_type.weight(), 0.0);
341 assert!(!memory_type.auto_promote());
342 }
343}
344
345#[cfg(test)]
346pub mod tests_helper {
347 use rusqlite::Connection;
348
349 pub fn setup_memory_schema(conn: &Connection) {
350 conn.execute_batch(
351 "CREATE TABLE memories (
352 id INTEGER PRIMARY KEY,
353 session_id TEXT,
354 project TEXT NOT NULL,
355 topic_key TEXT,
356 title TEXT NOT NULL,
357 content TEXT NOT NULL,
358 memory_type TEXT NOT NULL,
359 files TEXT,
360 search_context TEXT,
361 created_at_epoch INTEGER NOT NULL,
362 updated_at_epoch INTEGER NOT NULL,
363 reference_time_epoch INTEGER,
364 status TEXT NOT NULL DEFAULT 'active',
365 branch TEXT,
366 scope TEXT DEFAULT 'project',
367 last_accessed_epoch INTEGER,
368 access_count INTEGER NOT NULL DEFAULT 0,
369 source_project TEXT,
370 target_project TEXT,
371 owner_scope TEXT,
372 owner_key TEXT,
373 topic_domain TEXT,
374 routing_confidence REAL,
375 routing_reason TEXT,
376 context_class TEXT,
377 expires_at_epoch INTEGER,
378 valid_from_epoch INTEGER,
379 valid_to_epoch INTEGER,
380 state_key_id INTEGER,
381 source_trust_class TEXT NOT NULL DEFAULT 'local_tool_output',
382 acknowledged_pattern_id TEXT,
383 acknowledged_pattern_version INTEGER,
384 acknowledged_at_epoch INTEGER
385 );
386 CREATE TABLE memory_state_keys (
387 id INTEGER PRIMARY KEY,
388 owner_scope TEXT NOT NULL,
389 owner_key TEXT NOT NULL,
390 memory_type TEXT NOT NULL,
391 state_key TEXT NOT NULL,
392 state_label TEXT,
393 state_status TEXT NOT NULL DEFAULT 'active',
394 current_memory_id INTEGER,
395 created_at_epoch INTEGER NOT NULL,
396 updated_at_epoch INTEGER NOT NULL,
397 UNIQUE(owner_scope, owner_key, memory_type, state_key)
398 );
399 CREATE TABLE memory_candidates (
400 id INTEGER PRIMARY KEY
401 );
402 CREATE TABLE memory_embeddings (
403 memory_id INTEGER NOT NULL,
404 embedding BLOB NOT NULL,
405 dimensions INTEGER NOT NULL,
406 model TEXT NOT NULL,
407 content_hash TEXT NOT NULL,
408 updated_at_epoch INTEGER NOT NULL,
409 PRIMARY KEY(memory_id, model, dimensions),
410 FOREIGN KEY(memory_id) REFERENCES memories(id) ON DELETE CASCADE
411 );
412 CREATE INDEX idx_memory_embeddings_model
413 ON memory_embeddings(model, updated_at_epoch);
414 CREATE INDEX idx_memory_embeddings_profile_memory_id
415 ON memory_embeddings(model, dimensions, memory_id);
416 CREATE TABLE context_injection_items (
417 id INTEGER PRIMARY KEY,
418 injection_run_id TEXT NOT NULL,
419 host TEXT NOT NULL,
420 project TEXT NOT NULL,
421 session_id TEXT,
422 injection_key TEXT NOT NULL,
423 hook_source TEXT,
424 context_hash TEXT,
425 output_mode TEXT NOT NULL,
426 decision TEXT NOT NULL,
427 item_kind TEXT NOT NULL,
428 item_id INTEGER,
429 memory_id INTEGER,
430 channel TEXT NOT NULL,
431 score REAL,
432 render_order INTEGER,
433 status TEXT NOT NULL,
434 drop_reason TEXT,
435 title TEXT,
436 provenance TEXT,
437 staleness TEXT,
438 injected_at_epoch INTEGER NOT NULL
439 );
440 CREATE TABLE memory_poisoning_injection_drops (
441 id INTEGER PRIMARY KEY AUTOINCREMENT,
442 memory_id INTEGER NOT NULL,
443 pattern_id TEXT NOT NULL,
444 pattern_version INTEGER NOT NULL,
445 source_trust_class TEXT NOT NULL DEFAULT 'local_tool_output',
446 source_project TEXT,
447 title TEXT,
448 created_at_epoch INTEGER NOT NULL
449 );
450 CREATE INDEX idx_memory_poisoning_drops_created
451 ON memory_poisoning_injection_drops(created_at_epoch DESC, id DESC);
452 CREATE INDEX idx_memory_poisoning_drops_pattern
453 ON memory_poisoning_injection_drops(pattern_id, pattern_version, created_at_epoch DESC);
454 CREATE TABLE memory_citation_events (
455 id INTEGER PRIMARY KEY,
456 host TEXT NOT NULL,
457 project TEXT NOT NULL,
458 session_id TEXT NOT NULL,
459 source TEXT NOT NULL,
460 message_hash TEXT NOT NULL,
461 citation_line_present INTEGER NOT NULL DEFAULT 0,
462 parsed_count INTEGER NOT NULL DEFAULT 0,
463 matched_count INTEGER NOT NULL DEFAULT 0,
464 inserted_count INTEGER NOT NULL DEFAULT 0,
465 status TEXT NOT NULL,
466 created_at_epoch INTEGER NOT NULL,
467 UNIQUE(host, project, session_id, source, message_hash)
468 );
469 CREATE TABLE memory_usage_events (
470 id INTEGER PRIMARY KEY,
471 citation_event_id INTEGER NOT NULL,
472 host TEXT NOT NULL,
473 project TEXT NOT NULL,
474 session_id TEXT NOT NULL,
475 source TEXT NOT NULL,
476 message_hash TEXT NOT NULL,
477 memory_id INTEGER NOT NULL,
478 context_injection_item_id INTEGER,
479 created_at_epoch INTEGER NOT NULL,
480 UNIQUE(host, project, session_id, source, message_hash, memory_id)
481 );
482 CREATE TABLE memory_operation_log (
483 id INTEGER PRIMARY KEY,
484 operation TEXT NOT NULL,
485 planner_version TEXT NOT NULL,
486 actor TEXT NOT NULL,
487 source TEXT NOT NULL,
488 owner_scope TEXT,
489 owner_key TEXT,
490 memory_type TEXT,
491 state_key TEXT,
492 input_topic_key TEXT,
493 source_candidate_id INTEGER,
494 result_memory_id INTEGER,
495 superseded_ids TEXT NOT NULL DEFAULT '[]',
496 conflicting_ids TEXT NOT NULL DEFAULT '[]',
497 noop_reason TEXT,
498 defer_reason TEXT,
499 confidence REAL,
500 reason TEXT,
501 created_at_epoch INTEGER NOT NULL
502 );
503 CREATE INDEX idx_memory_operation_log_state
504 ON memory_operation_log(owner_scope, owner_key, memory_type, state_key, created_at_epoch);
505 CREATE TABLE memory_edges (
506 id INTEGER PRIMARY KEY,
507 edge_type TEXT NOT NULL,
508 from_memory_id INTEGER,
509 to_memory_id INTEGER,
510 state_key_id INTEGER,
511 source_candidate_id INTEGER,
512 evidence_event_ids TEXT,
513 source_operation_id INTEGER,
514 confidence REAL,
515 reason TEXT,
516 created_at_epoch INTEGER NOT NULL,
517 FOREIGN KEY(from_memory_id) REFERENCES memories(id),
518 FOREIGN KEY(to_memory_id) REFERENCES memories(id),
519 FOREIGN KEY(state_key_id) REFERENCES memory_state_keys(id),
520 FOREIGN KEY(source_candidate_id) REFERENCES memory_candidates(id),
521 FOREIGN KEY(source_operation_id) REFERENCES memory_operation_log(id)
522 );
523 CREATE INDEX idx_memory_edges_from
524 ON memory_edges(from_memory_id, edge_type);
525 CREATE INDEX idx_memory_edges_to
526 ON memory_edges(to_memory_id, edge_type);
527 CREATE INDEX idx_memory_edges_state
528 ON memory_edges(state_key_id, edge_type, created_at_epoch);
529 CREATE TABLE dream_cluster_decisions (
530 id INTEGER PRIMARY KEY AUTOINCREMENT,
531 project TEXT NOT NULL,
532 memory_type TEXT NOT NULL,
533 cluster_signature TEXT NOT NULL,
534 decision TEXT NOT NULL CHECK(decision IN ('merged', 'no_merge', 'defer', 'failed')),
535 reason TEXT,
536 member_ids_json TEXT NOT NULL,
537 cluster_size INTEGER NOT NULL,
538 next_review_epoch INTEGER,
539 source_memory_id INTEGER,
540 source_operation_id INTEGER,
541 created_at_epoch INTEGER NOT NULL,
542 updated_at_epoch INTEGER NOT NULL,
543 last_seen_epoch INTEGER NOT NULL,
544 UNIQUE(project, memory_type, cluster_signature),
545 FOREIGN KEY(source_memory_id) REFERENCES memories(id),
546 FOREIGN KEY(source_operation_id) REFERENCES memory_operation_log(id)
547 );
548 CREATE INDEX idx_dream_cluster_decisions_review
549 ON dream_cluster_decisions(project, decision, next_review_epoch);
550 CREATE INDEX idx_dream_cluster_decisions_signature
551 ON dream_cluster_decisions(project, memory_type, cluster_signature);
552 CREATE TABLE events (
553 id INTEGER PRIMARY KEY,
554 session_id TEXT NOT NULL,
555 project TEXT NOT NULL,
556 event_type TEXT NOT NULL,
557 summary TEXT NOT NULL,
558 detail TEXT,
559 files TEXT,
560 exit_code INTEGER,
561 created_at_epoch INTEGER NOT NULL
562 );
563 CREATE TABLE IF NOT EXISTS entities (
564 id INTEGER PRIMARY KEY,
565 canonical_name TEXT NOT NULL COLLATE NOCASE,
566 entity_type TEXT,
567 mention_count INTEGER DEFAULT 1,
568 created_at_epoch INTEGER NOT NULL DEFAULT 0,
569 UNIQUE(canonical_name)
570 );
571 CREATE TABLE IF NOT EXISTS memory_entities (
572 memory_id INTEGER NOT NULL,
573 entity_id INTEGER NOT NULL,
574 PRIMARY KEY(memory_id, entity_id)
575 );
576 CREATE TABLE IF NOT EXISTS memory_lessons (
577 memory_id INTEGER PRIMARY KEY,
578 confidence REAL NOT NULL DEFAULT 0.7,
579 reinforcement_count INTEGER NOT NULL DEFAULT 1,
580 source_evidence TEXT,
581 last_reinforced_at_epoch INTEGER NOT NULL,
582 stale_after_epoch INTEGER,
583 outcome_kind TEXT NOT NULL DEFAULT 'unknown'
584 CHECK (outcome_kind IN ('unknown', 'success', 'failure', 'recovery', 'correction', 'revert')),
585 success_count INTEGER NOT NULL DEFAULT 0 CHECK (success_count >= 0),
586 failure_count INTEGER NOT NULL DEFAULT 0 CHECK (failure_count >= 0),
587 recovery_count INTEGER NOT NULL DEFAULT 0 CHECK (recovery_count >= 0),
588 correction_count INTEGER NOT NULL DEFAULT 0 CHECK (correction_count >= 0),
589 revert_count INTEGER NOT NULL DEFAULT 0 CHECK (revert_count >= 0)
590 );
591 CREATE TABLE IF NOT EXISTS memory_suppressions (
592 id INTEGER PRIMARY KEY,
593 owner_scope TEXT,
594 owner_key TEXT,
595 target_kind TEXT NOT NULL,
596 target_id INTEGER,
597 target_value TEXT,
598 reason TEXT NOT NULL,
599 actor TEXT NOT NULL,
600 status TEXT NOT NULL,
601 created_at_epoch INTEGER NOT NULL,
602 updated_at_epoch INTEGER NOT NULL
603 );
604 CREATE TABLE IF NOT EXISTS memory_feedback (
605 id INTEGER PRIMARY KEY,
606 target_kind TEXT NOT NULL,
607 target_id INTEGER,
608 target_value TEXT,
609 feedback TEXT NOT NULL,
610 source TEXT NOT NULL,
611 context_injection_item_id INTEGER,
612 session_id TEXT,
613 project TEXT,
614 reason TEXT,
615 created_at_epoch INTEGER NOT NULL
616 );",
617 )
618 .unwrap();
619 conn.execute_batch(include_str!("../migrations/v020_memory_fts_all_status.sql"))
620 .unwrap();
621 conn.execute_batch(
622 concat!(
623 "DROP TRIGGER memories_au;\n",
624 "CREATE TRIGGER memories_au\n",
625 "AFTER UPDATE OF title, content, search_context ON memories\n",
626 "BEGIN\n",
627 " INSERT INTO memories_fts(memories_fts, rowid, title, content, search_context)\n",
628 " VALUES ('delete', old.id, old.title, old.content, COALESCE(old.search_context, ''));\n",
629 " INSERT INTO memories_fts(rowid, title, content, search_context)\n",
630 " VALUES (new.id, new.title, new.content, COALESCE(new.search_context, ''));\n",
631 "END;",
632 ),
633 )
634 .unwrap();
635 }
636}