Skip to main content

remem/memory/
lifecycle.rs

1use anyhow::{anyhow, Result};
2use rusqlite::{params, Connection};
3
4use crate::memory::state_key::StateKeyDecision;
5
6pub const SHORT_CURRENT_TTL_SECONDS: i64 = 24 * 60 * 60;
7pub const BRANCH_SNAPSHOT_TTL_SECONDS: i64 = 7 * 24 * 60 * 60;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum MemoryLifecycleOp {
11    Add,
12    Update,
13    Invalidate,
14    Noop,
15    Defer,
16    Conflict,
17}
18
19impl MemoryLifecycleOp {
20    pub fn as_str(self) -> &'static str {
21        match self {
22            Self::Add => "add",
23            Self::Update => "update",
24            Self::Invalidate => "invalidate",
25            Self::Noop => "noop",
26            Self::Defer => "defer",
27            Self::Conflict => "conflict",
28        }
29    }
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct LifecycleOutcome {
34    pub op: MemoryLifecycleOp,
35    pub memory_id: Option<i64>,
36    pub superseded: usize,
37    pub noop: bool,
38    pub deferred: bool,
39    pub reason: Option<String>,
40}
41
42#[allow(clippy::too_many_arguments)]
43pub fn apply_add(
44    conn: &Connection,
45    session_id: Option<&str>,
46    project: &str,
47    topic_key: Option<&str>,
48    title: &str,
49    content: &str,
50    memory_type: &str,
51    files: Option<&str>,
52    branch: Option<&str>,
53    scope: &str,
54) -> Result<LifecycleOutcome> {
55    let memory_id = crate::memory::insert_memory_full(
56        conn,
57        session_id,
58        project,
59        topic_key,
60        title,
61        content,
62        memory_type,
63        files,
64        branch,
65        scope,
66        None,
67    )?;
68    Ok(LifecycleOutcome {
69        op: MemoryLifecycleOp::Add,
70        memory_id: Some(memory_id),
71        superseded: 0,
72        noop: false,
73        deferred: false,
74        reason: None,
75    })
76}
77
78#[allow(clippy::too_many_arguments)]
79pub fn apply_update(
80    conn: &Connection,
81    session_id: Option<&str>,
82    project: &str,
83    topic_key: &str,
84    title: &str,
85    content: &str,
86    memory_type: &str,
87    files: Option<&str>,
88    branch: Option<&str>,
89    scope: &str,
90    superseded_ids: &[i64],
91) -> Result<LifecycleOutcome> {
92    let tx = rusqlite::Transaction::new_unchecked(conn, rusqlite::TransactionBehavior::Immediate)?;
93    let ownership = lifecycle_ownership(project, scope);
94    let state_key =
95        crate::memory::state_key::derive_state_key(memory_type, Some(topic_key), title, content);
96    let mut superseded_targets = superseded_ids.to_vec();
97    superseded_targets.extend(find_active_same_state_or_topic(
98        &tx,
99        project,
100        branch,
101        scope,
102        &ownership,
103        memory_type,
104        topic_key,
105        state_key.as_ref(),
106    )?);
107    superseded_targets.sort_unstable();
108    superseded_targets.dedup();
109    if let Some(matched) =
110        crate::memory::poisoning::scan_instruction_pattern(&format!("{title}\n{content}"))
111    {
112        anyhow::bail!(
113            "lifecycle update payload matched instruction-pattern {}@{}",
114            matched.pattern_id,
115            matched.pattern_set_version
116        );
117    }
118    let superseded_json = serde_json::to_string(&superseded_targets)?;
119    let payload_sha256 = crate::memory::activation::payload_sha256(&[
120        project,
121        topic_key,
122        title,
123        content,
124        memory_type,
125        files.unwrap_or(""),
126        branch.unwrap_or(""),
127        scope,
128        &superseded_json,
129    ]);
130    let request = crate::memory::activation::ActiveMemoryWriteRequest {
131        activation_id: crate::memory::activation::ephemeral_activation_id(
132            "lifecycle-update",
133            &payload_sha256,
134        ),
135        route_kind: crate::memory::activation::ActivationRouteKind::RustApi,
136        actor_kind: crate::memory::activation::ActivationActorKind::RustApi,
137        source_operation: "memory_lifecycle_update".to_string(),
138        source_trust: crate::memory::poisoning::SourceTrustClass::LocalToolOutput,
139        result_source_trust: crate::memory::poisoning::SourceTrustClass::LocalToolOutput,
140        source_project: ownership.source_project.to_string(),
141        route: crate::memory::activation::ActiveMemoryRoute {
142            project: project.to_string(),
143            branch: branch.map(str::to_string),
144            scope: scope.to_string(),
145            owner_scope: ownership.owner_scope.to_string(),
146            owner_key: ownership.owner_key.to_string(),
147            target_project: ownership.target_project.map(str::to_string),
148        },
149        provenance_kind: crate::memory::activation::ActivationProvenanceKind::RustApi,
150        provenance_ref: "rust-api:lifecycle-update:v1".to_string(),
151        payload_sha256,
152        expected_memory: crate::memory::activation::ExpectedActiveMemory::new(
153            title,
154            content,
155            memory_type,
156        )
157        .with_topic_key(Some(topic_key))
158        .with_files(files),
159        poisoning_verdict: crate::memory::activation::ActivationPoisoningVerdict::Clean,
160        superseded_ids: superseded_targets.clone(),
161    };
162    let mut superseded = None;
163    let activation_result = crate::memory::activation::execute_one(&tx, &request, |permit| {
164        let memory_id = insert_replacement_memory(
165            &tx,
166            permit,
167            session_id,
168            project,
169            topic_key,
170            title,
171            content,
172            memory_type,
173            files,
174            branch,
175            scope,
176            &ownership,
177            state_key.as_ref(),
178        )?;
179        let count = soft_supersede_lifecycle(
180            &tx,
181            project,
182            branch,
183            scope,
184            &ownership,
185            &superseded_targets,
186            Some(memory_id),
187        )?;
188        crate::memory::edge::insert_supersedes_edges(
189            &tx,
190            &superseded_targets,
191            memory_id,
192            crate::memory::edge::MemoryEdgeWriteContext {
193                reason: Some("lifecycle update supersedes old memory"),
194                ..Default::default()
195            },
196        )?;
197        superseded = Some(count);
198        Ok(memory_id)
199    })?;
200    tx.commit()?;
201    Ok(LifecycleOutcome {
202        op: MemoryLifecycleOp::Update,
203        memory_id: Some(activation_result.memory_id),
204        superseded: superseded.unwrap_or(0),
205        noop: false,
206        deferred: false,
207        reason: None,
208    })
209}
210
211pub fn apply_invalidate(
212    conn: &Connection,
213    project: &str,
214    memory_ids: &[i64],
215    reason: Option<&str>,
216) -> Result<LifecycleOutcome> {
217    let tx = conn.unchecked_transaction()?;
218    let superseded = soft_supersede(&tx, project, memory_ids, None)?;
219    tx.commit()?;
220    Ok(LifecycleOutcome {
221        op: MemoryLifecycleOp::Invalidate,
222        memory_id: None,
223        superseded,
224        noop: false,
225        deferred: false,
226        reason: reason.map(str::to_string),
227    })
228}
229
230#[allow(clippy::too_many_arguments)]
231fn insert_replacement_memory(
232    conn: &Connection,
233    _permit: &crate::memory::activation::ActiveMemoryWritePermit,
234    session_id: Option<&str>,
235    project: &str,
236    topic_key: &str,
237    title: &str,
238    content: &str,
239    memory_type: &str,
240    files: Option<&str>,
241    branch: Option<&str>,
242    scope: &str,
243    ownership: &LifecycleOwnership<'_>,
244    state_key: Option<&StateKeyDecision>,
245) -> Result<i64> {
246    let now = chrono::Utc::now().timestamp();
247    let (expires_at_epoch, valid_from_epoch) =
248        ttl_metadata(memory_type, Some(topic_key), content, now);
249    let search_context = crate::memory::search_context::build_search_context(
250        memory_type,
251        Some(topic_key),
252        content,
253        files,
254    );
255    let fallback_source_hash = crate::memory::retrieval_enrichment::enrichment_source_hash(
256        title,
257        content,
258        memory_type,
259        Some(topic_key),
260        files,
261    );
262    conn.execute(
263        "INSERT INTO memories
264         (session_id, project, topic_key, title, content, memory_type, files, search_context,
265          search_context_fallback_source_hash,
266          created_at_epoch, updated_at_epoch, status, branch, scope,
267          source_project, target_project, owner_scope, owner_key, context_class,
268          expires_at_epoch, valid_from_epoch)
269         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?18,
270                 ?9, ?9, 'active', ?10, ?11,
271                 ?12, ?13, ?14, ?15, 'startup_core', ?16, ?17)",
272        params![
273            session_id,
274            project,
275            topic_key,
276            title,
277            content,
278            memory_type,
279            files,
280            search_context,
281            now,
282            branch,
283            scope,
284            ownership.source_project,
285            ownership.target_project,
286            ownership.owner_scope,
287            ownership.owner_key,
288            expires_at_epoch,
289            valid_from_epoch,
290            fallback_source_hash
291        ],
292    )?;
293    let memory_id = conn.last_insert_rowid();
294    if let Some(state_key) = state_key {
295        crate::memory::state_key::attach_current_memory(
296            conn,
297            memory_id,
298            ownership.owner_scope,
299            ownership.owner_key,
300            memory_type,
301            state_key,
302            now,
303        )?;
304    }
305    crate::retrieval::vector::upsert_memory_embedding(
306        conn,
307        memory_id,
308        title,
309        content,
310        memory_type,
311        Some(topic_key),
312        "",
313    )?;
314    Ok(memory_id)
315}
316
317struct LifecycleOwnership<'a> {
318    source_project: &'a str,
319    target_project: Option<&'a str>,
320    owner_scope: &'static str,
321    owner_key: &'a str,
322}
323
324fn lifecycle_ownership<'a>(project: &'a str, scope: &str) -> LifecycleOwnership<'a> {
325    if scope == "global" {
326        LifecycleOwnership {
327            source_project: project,
328            target_project: None,
329            owner_scope: "user",
330            owner_key: "user:default",
331        }
332    } else {
333        LifecycleOwnership {
334            source_project: project,
335            target_project: Some(project),
336            owner_scope: "repo",
337            owner_key: project,
338        }
339    }
340}
341
342fn find_active_same_state_or_topic(
343    conn: &Connection,
344    project: &str,
345    branch: Option<&str>,
346    scope: &str,
347    ownership: &LifecycleOwnership<'_>,
348    memory_type: &str,
349    topic_key: &str,
350    state_key: Option<&StateKeyDecision>,
351) -> Result<Vec<i64>> {
352    let mut ids = Vec::new();
353    if let Some(state_key) = state_key {
354        let candidates = crate::memory::state_key::active_memory_ids(
355            conn,
356            ownership.owner_scope,
357            ownership.owner_key,
358            memory_type,
359            &state_key.state_key,
360            chrono::Utc::now().timestamp(),
361            false,
362        )?;
363        for memory_id in candidates {
364            if matches_lifecycle_route(conn, memory_id, project, branch, scope, ownership)? {
365                ids.push(memory_id);
366            }
367        }
368    }
369    ids.extend(find_active_same_topic_key(
370        conn,
371        project,
372        branch,
373        scope,
374        ownership,
375        memory_type,
376        topic_key,
377    )?);
378    Ok(ids)
379}
380
381fn find_active_same_topic_key(
382    conn: &Connection,
383    project: &str,
384    branch: Option<&str>,
385    scope: &str,
386    ownership: &LifecycleOwnership<'_>,
387    memory_type: &str,
388    topic_key: &str,
389) -> Result<Vec<i64>> {
390    let mut stmt = conn.prepare(
391        "SELECT id FROM memories
392         WHERE memory_type = ?1 AND topic_key = ?2
393           AND (?5 = 'global' OR project = ?3)
394           AND branch IS ?4 AND COALESCE(scope, 'project') = ?5
395           AND COALESCE(owner_scope,
396                CASE WHEN COALESCE(scope, 'project') = 'global' THEN 'user' ELSE 'repo' END) = ?6
397           AND COALESCE(owner_key,
398                CASE WHEN COALESCE(scope, 'project') = 'global' THEN 'user:default' ELSE project END) = ?7
399           AND CASE
400               WHEN COALESCE(owner_scope,
401                   CASE WHEN COALESCE(scope, 'project') = 'global' THEN 'user' ELSE 'repo' END) = 'repo'
402               THEN COALESCE(target_project, project)
403               ELSE target_project
404           END IS ?8
405           AND status = 'active'",
406    )?;
407    let rows = stmt.query_map(
408        params![
409            memory_type,
410            topic_key,
411            project,
412            branch,
413            scope,
414            ownership.owner_scope,
415            ownership.owner_key,
416            ownership.target_project,
417        ],
418        |row| row.get(0),
419    )?;
420    crate::db::query::collect_rows(rows)
421}
422
423fn matches_lifecycle_route(
424    conn: &Connection,
425    memory_id: i64,
426    project: &str,
427    branch: Option<&str>,
428    scope: &str,
429    ownership: &LifecycleOwnership<'_>,
430) -> Result<bool> {
431    conn.query_row(
432        "SELECT EXISTS(
433             SELECT 1 FROM memories
434             WHERE id = ?1 AND status = 'active'
435               AND (?4 = 'global' OR project = ?2)
436               AND branch IS ?3 AND COALESCE(scope, 'project') = ?4
437               AND COALESCE(owner_scope,
438                   CASE WHEN COALESCE(scope, 'project') = 'global'
439                        THEN 'user' ELSE 'repo' END) = ?5
440               AND COALESCE(owner_key,
441                   CASE WHEN COALESCE(scope, 'project') = 'global'
442                        THEN 'user:default' ELSE project END) = ?6
443               AND CASE
444                   WHEN COALESCE(owner_scope,
445                       CASE WHEN COALESCE(scope, 'project') = 'global'
446                            THEN 'user' ELSE 'repo' END) = 'repo'
447                   THEN COALESCE(target_project, project)
448                   ELSE target_project
449               END IS ?7
450         )",
451        params![
452            memory_id,
453            project,
454            branch,
455            scope,
456            ownership.owner_scope,
457            ownership.owner_key,
458            ownership.target_project,
459        ],
460        |row| row.get(0),
461    )
462    .map_err(Into::into)
463}
464
465#[allow(clippy::too_many_arguments)]
466fn soft_supersede_lifecycle(
467    conn: &Connection,
468    project: &str,
469    branch: Option<&str>,
470    scope: &str,
471    ownership: &LifecycleOwnership<'_>,
472    memory_ids: &[i64],
473    replacement_id: Option<i64>,
474) -> Result<usize> {
475    let mut seen = std::collections::HashSet::with_capacity(memory_ids.len());
476    let targets = memory_ids
477        .iter()
478        .copied()
479        .filter(|id| Some(*id) != replacement_id && seen.insert(*id))
480        .collect::<Vec<_>>();
481    for memory_id in &targets {
482        if !matches_lifecycle_route(conn, *memory_id, project, branch, scope, ownership)? {
483            return Err(anyhow!(
484                "failed to mark lifecycle memory stale outside owner route: id={memory_id}"
485            ));
486        }
487    }
488    crate::memory::preference::compilation::enqueue_for_memory_ids(conn, &targets)?;
489
490    let now = chrono::Utc::now().timestamp();
491    for memory_id in &targets {
492        let updated = conn.execute(
493            "UPDATE memories
494             SET status = 'stale',
495                 valid_to_epoch = COALESCE(valid_to_epoch, ?2)
496             WHERE id = ?1 AND status = 'active'",
497            params![memory_id, now],
498        )?;
499        if updated != 1 {
500            return Err(anyhow!(
501                "failed to mark lifecycle memory stale: id={memory_id}"
502            ));
503        }
504    }
505    Ok(targets.len())
506}
507
508pub fn noop(reason: impl Into<String>) -> LifecycleOutcome {
509    LifecycleOutcome {
510        op: MemoryLifecycleOp::Noop,
511        memory_id: None,
512        superseded: 0,
513        noop: true,
514        deferred: false,
515        reason: Some(reason.into()),
516    }
517}
518
519pub fn defer(reason: impl Into<String>) -> LifecycleOutcome {
520    LifecycleOutcome {
521        op: MemoryLifecycleOp::Defer,
522        memory_id: None,
523        superseded: 0,
524        noop: false,
525        deferred: true,
526        reason: Some(reason.into()),
527    }
528}
529
530pub fn default_ttl_seconds(
531    memory_type: &str,
532    topic_key: Option<&str>,
533    content: &str,
534) -> Option<i64> {
535    let topic_key = topic_key.unwrap_or_default().to_ascii_lowercase();
536    let content = content.to_ascii_lowercase();
537
538    if has_any(&topic_key, short_current_needles()) {
539        return Some(SHORT_CURRENT_TTL_SECONDS);
540    }
541
542    if has_any(&topic_key, branch_snapshot_needles()) {
543        return Some(BRANCH_SNAPSHOT_TTL_SECONDS);
544    }
545
546    if durable_type_has_no_content_ttl(memory_type) {
547        return None;
548    }
549
550    if has_any(&content, short_current_needles()) {
551        return Some(SHORT_CURRENT_TTL_SECONDS);
552    }
553
554    if has_any(&content, branch_snapshot_needles()) {
555        return Some(BRANCH_SNAPSHOT_TTL_SECONDS);
556    }
557
558    None
559}
560
561pub fn expires_at_epoch(
562    memory_type: &str,
563    topic_key: Option<&str>,
564    content: &str,
565    now_epoch: i64,
566) -> Option<i64> {
567    default_ttl_seconds(memory_type, topic_key, content).map(|ttl| now_epoch + ttl)
568}
569
570pub fn ttl_metadata(
571    memory_type: &str,
572    topic_key: Option<&str>,
573    content: &str,
574    now_epoch: i64,
575) -> (Option<i64>, Option<i64>) {
576    let expires_at_epoch = expires_at_epoch(memory_type, topic_key, content, now_epoch);
577    let valid_from_epoch = expires_at_epoch.map(|_| now_epoch);
578    (expires_at_epoch, valid_from_epoch)
579}
580
581pub fn expire_active_memories(conn: &Connection, now_epoch: i64) -> Result<usize> {
582    if !conn.is_autocommit() {
583        return expire_active_memories_in_transaction(conn, now_epoch);
584    }
585    let tx = rusqlite::Transaction::new_unchecked(conn, rusqlite::TransactionBehavior::Immediate)?;
586    let changed = expire_active_memories_in_transaction(&tx, now_epoch)?;
587    tx.commit()?;
588    Ok(changed)
589}
590
591fn expire_active_memories_in_transaction(conn: &Connection, now_epoch: i64) -> Result<usize> {
592    let mut stmt = conn.prepare(
593        "SELECT id FROM memories
594         WHERE status = 'active'
595           AND memory_type = 'preference'
596           AND expires_at_epoch IS NOT NULL
597           AND expires_at_epoch <= ?1",
598    )?;
599    let rows = stmt.query_map(params![now_epoch], |row| row.get::<_, i64>(0))?;
600    let expiring_preference_ids = crate::db::query::collect_rows(rows)?;
601    drop(stmt);
602    let changed = conn.execute(
603        "UPDATE memories
604         SET status = 'stale',
605             valid_to_epoch = COALESCE(valid_to_epoch, ?1),
606             updated_at_epoch = ?1
607         WHERE status = 'active'
608           AND expires_at_epoch IS NOT NULL
609           AND expires_at_epoch <= ?1",
610        params![now_epoch],
611    )?;
612    crate::memory::preference::compilation::enqueue_for_memory_ids(conn, &expiring_preference_ids)?;
613    Ok(changed)
614}
615
616pub fn count_expired_active_memories(conn: &Connection, now_epoch: i64) -> Result<usize> {
617    let count: i64 = conn.query_row(
618        "SELECT COUNT(*) FROM memories
619         WHERE status = 'active'
620           AND expires_at_epoch IS NOT NULL
621           AND expires_at_epoch <= ?1",
622        params![now_epoch],
623        |row| row.get(0),
624    )?;
625    Ok(count as usize)
626}
627
628pub fn soft_supersede(
629    conn: &Connection,
630    project: &str,
631    memory_ids: &[i64],
632    replacement_id: Option<i64>,
633) -> Result<usize> {
634    let mut seen = std::collections::HashSet::with_capacity(memory_ids.len());
635    let targets = memory_ids
636        .iter()
637        .copied()
638        .filter(|id| Some(*id) != replacement_id && seen.insert(*id))
639        .collect::<Vec<_>>();
640    for id in &targets {
641        let exists: bool = conn.query_row(
642            "SELECT EXISTS(SELECT 1 FROM memories WHERE id = ?1 AND project = ?2)",
643            params![id, project],
644            |row| row.get(0),
645        )?;
646        if !exists {
647            return Err(anyhow!(
648                "failed to mark superseded memory stale: id={} project={}",
649                id,
650                project
651            ));
652        }
653    }
654
655    crate::memory::preference::compilation::enqueue_for_memory_ids(conn, &targets)?;
656
657    let mut changed = 0usize;
658    let now = chrono::Utc::now().timestamp();
659    for id in targets {
660        let updated = conn.execute(
661            "UPDATE memories
662             SET status = 'stale',
663                 valid_to_epoch = COALESCE(valid_to_epoch, ?3)
664             WHERE id = ?1 AND project = ?2",
665            params![id, project, now],
666        )?;
667        if updated != 1 {
668            return Err(anyhow!(
669                "failed to mark superseded memory stale: id={} project={}",
670                id,
671                project
672            ));
673        }
674        changed += updated;
675    }
676    Ok(changed)
677}
678
679fn has_any(haystack: &str, needles: &[&str]) -> bool {
680    needles.iter().any(|needle| haystack.contains(needle))
681}
682
683fn short_current_needles() -> &'static [&'static str] {
684    &[
685        "dev-server",
686        "dev server",
687        "localhost",
688        "127.0.0.1",
689        "port occupied",
690        "port is occupied",
691        "currently running",
692        "server running",
693        "local url",
694        "url healthy",
695        "healthy at",
696        "mergeability",
697        "mergeable",
698        "review status",
699        "review-status",
700        "ci state",
701        "ci status",
702        "ci-status",
703        "github actions",
704        "pull request",
705        "pull-request",
706        "pr #",
707    ]
708}
709
710fn branch_snapshot_needles() -> &'static [&'static str] {
711    &[
712        "git-divergence",
713        "branch-divergence",
714        "branch divergence",
715        "current branch",
716        "git status",
717        "ahead of",
718        "behind origin",
719        "diverged",
720        "dirty worktree",
721    ]
722}
723
724fn durable_type_has_no_content_ttl(memory_type: &str) -> bool {
725    matches!(
726        memory_type,
727        "architecture" | "bugfix" | "lesson" | "preference" | "procedure"
728    )
729}
730
731#[cfg(test)]
732mod ttl_tests;
733#[cfg(test)]
734mod vector_tests;