Skip to main content

remem/memory/store/
write.rs

1use anyhow::{bail, Context, Result};
2use rusqlite::{params, Connection, OptionalExtension};
3
4use crate::memory::search_context::build_search_context;
5use crate::memory::state_key::{self, StateKeyDecision};
6use crate::memory::{
7    lifecycle::MemoryLifecycleOp,
8    operation::{
9        insert_operation_log, with_operation_savepoint, MemoryOperationInput, MemoryOperationPlan,
10    },
11    preference::consolidation::PreferenceConsolidationKind,
12};
13
14mod activation;
15mod rust_api;
16pub(crate) use activation::insert_memory_replacement_activated;
17pub use rust_api::insert_memory_full_with_reference_time;
18
19pub fn insert_memory(
20    conn: &Connection,
21    session_id: Option<&str>,
22    project: &str,
23    topic_key: Option<&str>,
24    title: &str,
25    content: &str,
26    memory_type: &str,
27    files: Option<&str>,
28) -> Result<i64> {
29    insert_memory_with_branch(
30        conn,
31        session_id,
32        project,
33        topic_key,
34        title,
35        content,
36        memory_type,
37        files,
38        None,
39    )
40}
41
42pub fn insert_memory_with_branch(
43    conn: &Connection,
44    session_id: Option<&str>,
45    project: &str,
46    topic_key: Option<&str>,
47    title: &str,
48    content: &str,
49    memory_type: &str,
50    files: Option<&str>,
51    branch: Option<&str>,
52) -> Result<i64> {
53    insert_memory_full(
54        conn,
55        session_id,
56        project,
57        topic_key,
58        title,
59        content,
60        memory_type,
61        files,
62        branch,
63        "project",
64        None,
65    )
66}
67
68#[allow(clippy::too_many_arguments)]
69pub fn insert_memory_full(
70    conn: &Connection,
71    session_id: Option<&str>,
72    project: &str,
73    topic_key: Option<&str>,
74    title: &str,
75    content: &str,
76    memory_type: &str,
77    files: Option<&str>,
78    branch: Option<&str>,
79    scope: &str,
80    created_at_override: Option<i64>,
81) -> Result<i64> {
82    insert_memory_full_with_reference_time(
83        conn,
84        session_id,
85        project,
86        topic_key,
87        title,
88        content,
89        memory_type,
90        files,
91        branch,
92        scope,
93        created_at_override,
94        created_at_override,
95    )
96}
97
98#[allow(clippy::too_many_arguments)]
99pub(crate) fn insert_memory_full_activated(
100    conn: &Connection,
101    _permit: &crate::memory::activation::ActiveMemoryWritePermit,
102    session_id: Option<&str>,
103    project: &str,
104    topic_key: Option<&str>,
105    title: &str,
106    content: &str,
107    memory_type: &str,
108    files: Option<&str>,
109    branch: Option<&str>,
110    scope: &str,
111    created_at_override: Option<i64>,
112    reference_time_override: Option<i64>,
113) -> Result<i64> {
114    let now = chrono::Utc::now().timestamp();
115    let created_at = created_at_override.unwrap_or(now);
116    let reference_time = reference_time_override
117        .or(created_at_override)
118        .unwrap_or(created_at);
119    let (expires_at_epoch, valid_from_epoch) =
120        crate::memory::lifecycle::ttl_metadata(memory_type, topic_key, content, now);
121    let search_context = build_search_context(memory_type, topic_key, content, files);
122    // Same-statement enrichment identity: binding the fallback source hash to
123    // the exact canonical bytes keeps the convergence trigger from treating
124    // this write as a raw (bypass) canonical update.
125    let fallback_source_hash = crate::memory::retrieval_enrichment::enrichment_source_hash(
126        title,
127        content,
128        memory_type,
129        topic_key,
130        files,
131    );
132    let ownership = default_ownership(project, scope);
133    let state_key = state_key::derive_state_key(memory_type, topic_key, title, content);
134
135    let mut existing_id = None;
136    let mut preference_conflict = false;
137    if let Some(topic_key) = topic_key {
138        if !topic_key.is_empty() {
139            existing_id = conn
140                .query_row(
141                    "SELECT id FROM memories
142                     WHERE (?3 = 'global' OR project = ?1) AND topic_key = ?2 AND scope = ?3
143                       AND memory_type = ?4 AND branch IS ?5
144                       AND COALESCE(owner_scope,
145                           CASE WHEN scope = 'global' THEN 'user' ELSE 'repo' END) = ?6
146                       AND COALESCE(owner_key,
147                           CASE WHEN scope = 'global' THEN 'user:default' ELSE project END) = ?7
148                       AND CASE
149                           WHEN COALESCE(owner_scope,
150                               CASE WHEN scope = 'global' THEN 'user' ELSE 'repo' END) = 'repo'
151                           THEN COALESCE(target_project, project)
152                           ELSE target_project
153                       END IS ?8
154                     ORDER BY CASE status WHEN 'active' THEN 0 ELSE 1 END,
155                              updated_at_epoch DESC,
156                              id DESC
157                     LIMIT 1",
158                    params![
159                        project,
160                        topic_key,
161                        scope,
162                        memory_type,
163                        branch,
164                        ownership.owner_scope,
165                        ownership.owner_key,
166                        ownership.target_project,
167                    ],
168                    |row| row.get(0),
169                )
170                .optional()?;
171        }
172    }
173
174    if existing_id.is_none() {
175        if let Some(decision) = &state_key {
176            if decision.allows_direct_upsert() {
177                let state_memory_id = state_key::current_memory_id(
178                    conn,
179                    ownership.owner_scope,
180                    ownership.owner_key,
181                    memory_type,
182                    &decision.state_key,
183                    now,
184                )?;
185                if let Some(id) = state_memory_id {
186                    let same_branch: bool = conn.query_row(
187                        "SELECT branch IS ?2 FROM memories WHERE id = ?1",
188                        params![id, branch],
189                        |row| row.get(0),
190                    )?;
191                    if same_branch {
192                        existing_id = Some(id);
193                    }
194                }
195            }
196        }
197    }
198    if memory_type == "preference" && existing_id.is_none() {
199        if let Some(preference_match) =
200            crate::memory::preference::consolidation::find_preference_consolidation(
201                conn,
202                ownership.owner_scope,
203                ownership.owner_key,
204                scope,
205                branch,
206                content,
207                now,
208            )?
209        {
210            match preference_match.kind {
211                PreferenceConsolidationKind::SamePreference
212                | PreferenceConsolidationKind::Refinement => {
213                    existing_id = Some(preference_match.memory_id);
214                }
215                PreferenceConsolidationKind::Contradiction => {
216                    preference_conflict = true;
217                }
218            }
219        }
220    }
221
222    if existing_id.is_none() && !preference_conflict {
223        existing_id = crate::memory::semantic_dedup::find_curated_duplicate_id(
224            conn,
225            project,
226            scope,
227            memory_type,
228            title,
229            content,
230            topic_key,
231            branch,
232            now,
233        )?;
234    }
235
236    if let Some(id) = existing_id {
237        return with_memory_savepoint(conn, || {
238            update_existing_memory(
239                conn,
240                id,
241                session_id,
242                topic_key,
243                title,
244                content,
245                memory_type,
246                files,
247                branch,
248                scope,
249                &search_context,
250                &fallback_source_hash,
251                expires_at_epoch,
252                valid_from_epoch,
253                &ownership,
254                state_key.as_ref(),
255                now,
256                reference_time,
257            )?;
258            refresh_memory_entities(conn, id, title, content)?;
259            refresh_memory_embedding(conn, id, title, content, memory_type, topic_key)?;
260            Ok(id)
261        });
262    }
263
264    with_memory_savepoint(conn, || {
265        conn.execute(
266            "INSERT INTO memories \
267             (session_id, project, topic_key, title, content, memory_type, files, search_context, \
268              search_context_fallback_source_hash, \
269              created_at_epoch, updated_at_epoch, reference_time_epoch, status, branch, scope, \
270              source_project, target_project, owner_scope, owner_key, context_class, \
271              expires_at_epoch, valid_from_epoch) \
272             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 'active', ?13, ?14, \
273                     ?15, ?16, ?17, ?18, ?19, ?20, ?21)",
274            params![
275                session_id,
276                project,
277                topic_key,
278                title,
279                content,
280                memory_type,
281                files,
282                search_context,
283                fallback_source_hash,
284                created_at,
285                now,
286                reference_time,
287                branch,
288                scope,
289                ownership.source_project,
290                ownership.target_project,
291                ownership.owner_scope,
292                ownership.owner_key,
293                ownership.context_class,
294                expires_at_epoch,
295                valid_from_epoch
296            ],
297        )?;
298        let id = conn.last_insert_rowid();
299        attach_state_key(conn, id, memory_type, &ownership, state_key.as_ref(), now)?;
300        refresh_memory_entities(conn, id, title, content)?;
301        refresh_memory_embedding(conn, id, title, content, memory_type, topic_key)?;
302        Ok(id)
303    })
304}
305
306#[allow(clippy::too_many_arguments)]
307pub fn insert_memory_full_with_operation_log(
308    conn: &Connection,
309    session_id: Option<&str>,
310    project: &str,
311    topic_key: Option<&str>,
312    title: &str,
313    content: &str,
314    memory_type: &str,
315    files: Option<&str>,
316    branch: Option<&str>,
317    scope: &str,
318    created_at_override: Option<i64>,
319    reference_time_override: Option<i64>,
320    operation_input: &MemoryOperationInput,
321    operation_plan: &MemoryOperationPlan,
322) -> Result<(i64, MemoryLifecycleOp)> {
323    with_operation_savepoint(conn, || {
324        let id = insert_memory_full_with_reference_time(
325            conn,
326            session_id,
327            project,
328            topic_key,
329            title,
330            content,
331            memory_type,
332            files,
333            branch,
334            scope,
335            created_at_override,
336            reference_time_override,
337        )?;
338        let mut logged_plan = operation_plan.clone();
339        logged_plan.target_memory_id = Some(id);
340        let operation_id = insert_operation_log(conn, operation_input, &logged_plan, Some(id))?;
341        crate::memory::edge::insert_supersedes_edges(
342            conn,
343            &logged_plan.superseded_ids,
344            id,
345            crate::memory::edge::MemoryEdgeWriteContext {
346                source_candidate_id: operation_input.source_candidate_id,
347                source_operation_id: Some(operation_id),
348                confidence: operation_input.confidence,
349                reason: Some(logged_plan.reason.as_str()),
350                ..Default::default()
351            },
352        )?;
353        crate::memory::edge::insert_conflicts_edges(
354            conn,
355            &logged_plan.conflicting_ids,
356            id,
357            crate::memory::edge::MemoryEdgeWriteContext {
358                source_candidate_id: operation_input.source_candidate_id,
359                source_operation_id: Some(operation_id),
360                confidence: operation_input.confidence,
361                reason: Some(logged_plan.reason.as_str()),
362                ..Default::default()
363            },
364        )?;
365        Ok((id, logged_plan.op))
366    })
367}
368
369#[allow(clippy::too_many_arguments)]
370pub(crate) fn insert_memory_full_with_operation_log_activated(
371    conn: &Connection,
372    permit: &crate::memory::activation::ActiveMemoryWritePermit,
373    session_id: Option<&str>,
374    project: &str,
375    topic_key: Option<&str>,
376    title: &str,
377    content: &str,
378    memory_type: &str,
379    files: Option<&str>,
380    branch: Option<&str>,
381    scope: &str,
382    created_at_override: Option<i64>,
383    reference_time_override: Option<i64>,
384    operation_input: &MemoryOperationInput,
385    operation_plan: &MemoryOperationPlan,
386) -> Result<(i64, MemoryLifecycleOp)> {
387    let id = insert_memory_full_activated(
388        conn,
389        permit,
390        session_id,
391        project,
392        topic_key,
393        title,
394        content,
395        memory_type,
396        files,
397        branch,
398        scope,
399        created_at_override,
400        reference_time_override,
401    )?;
402    let mut logged_plan = operation_plan.clone();
403    logged_plan.target_memory_id = Some(id);
404    let operation_id = insert_operation_log(conn, operation_input, &logged_plan, Some(id))?;
405    crate::memory::edge::insert_supersedes_edges(
406        conn,
407        &logged_plan.superseded_ids,
408        id,
409        crate::memory::edge::MemoryEdgeWriteContext {
410            source_candidate_id: operation_input.source_candidate_id,
411            source_operation_id: Some(operation_id),
412            confidence: operation_input.confidence,
413            reason: Some(logged_plan.reason.as_str()),
414            ..Default::default()
415        },
416    )?;
417    crate::memory::edge::insert_conflicts_edges(
418        conn,
419        &logged_plan.conflicting_ids,
420        id,
421        crate::memory::edge::MemoryEdgeWriteContext {
422            source_candidate_id: operation_input.source_candidate_id,
423            source_operation_id: Some(operation_id),
424            confidence: operation_input.confidence,
425            reason: Some(logged_plan.reason.as_str()),
426            ..Default::default()
427        },
428    )?;
429    Ok((id, logged_plan.op))
430}
431
432pub(crate) struct DefaultOwnership<'a> {
433    pub(crate) source_project: &'a str,
434    pub(crate) target_project: Option<&'a str>,
435    pub(crate) owner_scope: &'static str,
436    pub(crate) owner_key: &'a str,
437    pub(crate) context_class: &'static str,
438}
439
440pub(crate) fn default_ownership<'a>(project: &'a str, scope: &str) -> DefaultOwnership<'a> {
441    if scope == "global" {
442        DefaultOwnership {
443            source_project: project,
444            target_project: None,
445            owner_scope: "user",
446            owner_key: "user:default",
447            context_class: "startup_core",
448        }
449    } else {
450        DefaultOwnership {
451            source_project: project,
452            target_project: Some(project),
453            owner_scope: "repo",
454            owner_key: project,
455            context_class: "startup_core",
456        }
457    }
458}
459
460#[allow(clippy::too_many_arguments)]
461fn update_existing_memory(
462    conn: &Connection,
463    id: i64,
464    session_id: Option<&str>,
465    topic_key: Option<&str>,
466    title: &str,
467    content: &str,
468    memory_type: &str,
469    files: Option<&str>,
470    branch: Option<&str>,
471    scope: &str,
472    search_context: &str,
473    fallback_source_hash: &str,
474    expires_at_epoch: Option<i64>,
475    valid_from_epoch: Option<i64>,
476    ownership: &DefaultOwnership<'_>,
477    state_key: Option<&StateKeyDecision>,
478    now: i64,
479    reference_time: i64,
480) -> Result<()> {
481    let state_key_id = attach_state_key(conn, id, memory_type, ownership, state_key, now)?;
482    clear_obsolete_state_key_links(conn, id, state_key_id, now)?;
483    // Same statement as the canonical write: reset the enrichment identity to
484    // pending, bind the deterministic fallback to the new source hash, and
485    // clear any stale claim/lease/failure state (GH-850 B-009).
486    conn.execute(
487        "UPDATE memories SET session_id = ?1, topic_key = ?2, title = ?3, content = ?4, \
488         memory_type = ?5, files = ?6, updated_at_epoch = ?7, branch = ?8, \
489         scope = ?9, search_context = ?10, reference_time_epoch = ?11, \
490         status = 'active', valid_to_epoch = NULL, \
491         expires_at_epoch = ?12, valid_from_epoch = ?13, \
492         state_key_id = ?14, \
493         source_project = COALESCE(source_project, ?15), \
494         target_project = COALESCE(target_project, ?16), \
495         owner_scope = COALESCE(owner_scope, ?17), \
496         owner_key = COALESCE(owner_key, ?18), \
497         context_class = COALESCE(context_class, ?19), \
498         search_context_fallback_source_hash = ?21, \
499         search_context_enrichment_state = 'pending', \
500         search_context_enrichment_version = 0, \
501         search_context_security_policy_version = 0, \
502         search_context_source_hash = NULL, \
503         search_context_index_hash = NULL, \
504         search_context_lease_owner = NULL, \
505         search_context_lease_expires_at_epoch = NULL, \
506         search_context_claimed_source_hash = NULL, \
507         search_context_claimed_enrichment_version = NULL, \
508         search_context_claimed_security_policy_version = NULL, \
509         search_context_failure_count = 0, \
510         search_context_next_retry_at_epoch = NULL, \
511         search_context_last_error_code = NULL \
512         WHERE id = ?20",
513        params![
514            session_id,
515            topic_key,
516            title,
517            content,
518            memory_type,
519            files,
520            now,
521            branch,
522            scope,
523            search_context,
524            reference_time,
525            expires_at_epoch,
526            valid_from_epoch,
527            state_key_id,
528            ownership.source_project,
529            ownership.target_project,
530            ownership.owner_scope,
531            ownership.owner_key,
532            ownership.context_class,
533            id,
534            fallback_source_hash
535        ],
536    )?;
537    Ok(())
538}
539
540pub(crate) fn clear_obsolete_state_key_links(
541    conn: &Connection,
542    id: i64,
543    active_state_key_id: Option<i64>,
544    now: i64,
545) -> Result<()> {
546    update_state_key_links(conn, id, active_state_key_id, active_state_key_id, now)
547}
548
549pub(crate) fn update_state_key_links(
550    conn: &Connection,
551    id: i64,
552    row_state_key_id: Option<i64>,
553    current_state_key_id: Option<i64>,
554    now: i64,
555) -> Result<()> {
556    conn.execute(
557        "UPDATE memories SET state_key_id = ?1 WHERE id = ?2",
558        params![row_state_key_id, id],
559    )?;
560    conn.execute(
561        "UPDATE memory_state_keys
562         SET current_memory_id = NULL, updated_at_epoch = ?3
563         WHERE current_memory_id = ?1
564           AND (?2 IS NULL OR id <> ?2)",
565        params![id, current_state_key_id, now],
566    )?;
567    Ok(())
568}
569
570fn attach_state_key(
571    conn: &Connection,
572    id: i64,
573    memory_type: &str,
574    ownership: &DefaultOwnership<'_>,
575    state_key: Option<&StateKeyDecision>,
576    now: i64,
577) -> Result<Option<i64>> {
578    state_key
579        .map(|decision| {
580            state_key::attach_current_memory(
581                conn,
582                id,
583                ownership.owner_scope,
584                ownership.owner_key,
585                memory_type,
586                decision,
587                now,
588            )
589        })
590        .transpose()
591}
592
593fn with_memory_savepoint<T>(conn: &Connection, f: impl FnOnce() -> Result<T>) -> Result<T> {
594    conn.execute_batch("SAVEPOINT remem_memory_state_write")?;
595    match f() {
596        Ok(value) => {
597            conn.execute_batch("RELEASE SAVEPOINT remem_memory_state_write")?;
598            Ok(value)
599        }
600        Err(error) => {
601            let rollback = conn.execute_batch(
602                "ROLLBACK TO SAVEPOINT remem_memory_state_write;
603                 RELEASE SAVEPOINT remem_memory_state_write;",
604            );
605            if let Err(rollback_error) = rollback {
606                return Err(error.context(format!(
607                    "memory state-key rollback also failed: {rollback_error}"
608                )));
609            }
610            Err(error)
611        }
612    }
613}
614
615fn refresh_memory_entities(conn: &Connection, id: i64, title: &str, content: &str) -> Result<()> {
616    let entities = crate::retrieval::entity::extract_entities(title, content);
617    crate::retrieval::entity::refresh_memory_entities(conn, id, &entities)
618        .with_context(|| format!("entity refresh failed for memory id={id}"))
619}
620
621fn refresh_memory_embedding(
622    conn: &Connection,
623    id: i64,
624    title: &str,
625    content: &str,
626    memory_type: &str,
627    topic_key: Option<&str>,
628) -> Result<()> {
629    // Foreground writes always reset enrichment to pending in the same
630    // statement, so the passage is canonical-only here; the idle enrichment
631    // CAS later replaces the vector with the enriched-snapshot passage.
632    crate::retrieval::vector::upsert_memory_embedding(
633        conn,
634        id,
635        title,
636        content,
637        memory_type,
638        topic_key,
639        "",
640    )
641}
642
643#[cfg(test)]
644mod tests;