Skip to main content

remem/memory/
types.rs

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    /// Map a raw observation_type (the legal observation vocabulary lives in
109    /// `crate::db::models::OBSERVATION_TYPES`: bugfix/feature/refactor/discovery/
110    /// decision/change) onto the candidate `MemoryType` it can support.
111    ///
112    /// The candidate vocabulary and the observation vocabulary are different
113    /// word lists, so a raw string-equality comparison between them is wrong:
114    /// `architecture` is a valid candidate type but never a valid observation
115    /// type, so an architecture candidate could never be matched and could
116    /// never auto-promote. `feature`/`refactor`/`change` observations all
117    /// describe project discoveries, so they collapse onto `Discovery`.
118    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    /// Whether an observation of `observation_type` can serve as supporting
128    /// evidence for a candidate of `self`. Auto-promotable candidate types are
129    /// matched to their observation equivalent; `Architecture` candidates have
130    /// no observation equivalent, so they accept `Discovery`-class evidence
131    /// (the closest project-knowledge observation class).
132    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        // architecture is a valid candidate type but never a valid observation
304        // type; it must accept discovery-class observation evidence.
305        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        // but not unrelated classes
310        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        // feature/refactor/change all collapse onto discovery
320        assert!(MemoryType::Discovery.supports_observation_type("feature"));
321        assert!(MemoryType::Discovery.supports_observation_type("refactor"));
322        assert!(MemoryType::Discovery.supports_observation_type("change"));
323        // mismatches stay false
324        assert!(!MemoryType::Bugfix.supports_observation_type("decision"));
325        assert!(!MemoryType::Decision.supports_observation_type("discovery"));
326        // unknown observation type maps to nothing
327        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}