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 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 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 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;