1use super::args::RememberArgs;
4use super::graph_input::{normalize_and_validate_graph_input, GraphInput};
5use crate::chunking;
6use crate::entity_type::EntityType;
7use crate::errors::AppError;
8use crate::i18n::errors_msg;
9use crate::output;
10use crate::paths::AppPaths;
11use crate::storage::chunks as storage_chunks;
12use crate::storage::connection::{ensure_schema, open_rw};
13use crate::storage::entities::{self as entities, NewEntity};
14use crate::storage::memories::{self as memories, NewMemory};
15use crate::storage::versions;
16
17pub fn run(
19 args: RememberArgs,
20 llm_backend: crate::cli::LlmBackendChoice,
21 embedding_backend: crate::cli::EmbeddingBackendChoice,
22) -> Result<(), AppError> {
23 use crate::constants::*;
24
25 let inicio = std::time::Instant::now();
26 let _ = args.format;
27 tracing::debug!(target: "remember", name = %args.name, "persisting memory");
28 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
29
30 let original_name = args.name.clone();
34
35 let normalized_name = {
39 let lower = args.name.to_lowercase().replace(['_', ' '], "-");
40 let trimmed = lower.trim_matches('-').to_string();
41 if trimmed != args.name {
42 tracing::warn!(target: "remember",
43 original = %args.name,
44 normalized = %trimmed,
45 "name auto-normalized to kebab-case"
46 );
47 }
48 trimmed
49 };
50 let name_was_normalized = normalized_name != original_name;
51
52 if args.strict_name && name_was_normalized {
55 return Err(AppError::Validation(
56 crate::i18n::validation::strict_name_not_canonical(&original_name, &normalized_name),
57 ));
58 }
59
60 if normalized_name.is_empty() {
61 return Err(AppError::Validation(
62 crate::i18n::validation::name_empty_after_normalization(),
63 ));
64 }
65 if normalized_name.len() > MAX_MEMORY_NAME_LEN {
66 return Err(AppError::LimitExceeded(
67 crate::i18n::validation::name_length(MAX_MEMORY_NAME_LEN),
68 ));
69 }
70
71 if normalized_name.starts_with("__") {
72 return Err(AppError::Validation(
73 crate::i18n::validation::reserved_name(),
74 ));
75 }
76
77 {
78 let slug_re = crate::constants::name_slug_regex();
79 if !slug_re.is_match(&normalized_name) {
80 return Err(AppError::Validation(crate::i18n::validation::name_kebab(
81 &normalized_name,
82 )));
83 }
84 }
85
86 if let Some(ref desc) = args.description {
87 if desc.len() > MAX_MEMORY_DESCRIPTION_LEN {
88 return Err(AppError::Validation(
89 crate::i18n::validation::description_exceeds(MAX_MEMORY_DESCRIPTION_LEN),
90 ));
91 }
92 }
93
94 let body_explicitly_provided =
98 args.body.is_some() || args.body_file.is_some() || args.body_stdin;
99
100 let mut raw_body = if let Some(ref b) = args.body {
101 b.clone()
102 } else if let Some(ref path) = args.body_file {
103 let file_size = std::fs::metadata(path).map_err(AppError::Io)?.len();
104 if file_size > MAX_MEMORY_BODY_LEN as u64 {
105 return Err(AppError::BodyTooLarge {
106 bytes: file_size,
107 limit: MAX_MEMORY_BODY_LEN as u64,
108 });
109 }
110 match std::fs::read_to_string(path) {
111 Ok(s) => s,
112 Err(e) if e.kind() == std::io::ErrorKind::InvalidData => {
113 let bytes = std::fs::read(path).map_err(AppError::Io)?;
114 tracing::warn!(target: "remember", "body file contains invalid UTF-8; replacing invalid sequences");
115 String::from_utf8_lossy(&bytes).into_owned()
116 }
117 Err(e) => return Err(AppError::Io(e)),
118 }
119 } else if args.body_stdin || args.graph_stdin {
120 crate::stdin_helper::read_stdin_with_timeout(60)?
121 } else {
122 String::new()
123 };
124
125 let mut entities_provided_externally =
126 args.entities_file.is_some() || args.relationships_file.is_some();
127
128 let mut graph = GraphInput::default();
129 if let Some(ref path) = args.entities_file {
130 let file_size = std::fs::metadata(path).map_err(AppError::Io)?.len();
131 if file_size > MAX_MEMORY_BODY_LEN as u64 {
132 return Err(AppError::BodyTooLarge {
133 bytes: file_size,
134 limit: MAX_MEMORY_BODY_LEN as u64,
135 });
136 }
137 let content = std::fs::read_to_string(path).map_err(AppError::Io)?;
138 graph.entities = serde_json::from_str(&content).map_err(|e| {
142 AppError::Validation(crate::i18n::validation::invalid_json_in_flag(
143 "--entities-file",
144 &e,
145 ))
146 })?;
147 }
148 if let Some(ref path) = args.relationships_file {
149 let file_size = std::fs::metadata(path).map_err(AppError::Io)?.len();
150 if file_size > MAX_MEMORY_BODY_LEN as u64 {
151 return Err(AppError::BodyTooLarge {
152 bytes: file_size,
153 limit: MAX_MEMORY_BODY_LEN as u64,
154 });
155 }
156 let content = std::fs::read_to_string(path).map_err(AppError::Io)?;
157 graph.relationships = serde_json::from_str(&content).map_err(|e| {
158 AppError::Validation(crate::i18n::validation::invalid_json_in_flag(
159 "--relationships-file",
160 &e,
161 ))
162 })?;
163 }
164 if args.graph_stdin {
165 graph = serde_json::from_str::<GraphInput>(&raw_body).map_err(|e| {
166 AppError::Validation(crate::i18n::validation::invalid_json_payload_on_flag(
167 "--graph-stdin",
168 &e,
169 ))
170 })?;
171 raw_body = graph.body.take().unwrap_or_default();
172 }
173 if args.graph_stdin && !graph.entities.is_empty() {
174 entities_provided_externally = true;
175 }
176 if let Some(ref path) = args.graph_file {
180 let file_size = std::fs::metadata(path).map_err(AppError::Io)?.len();
181 if file_size > MAX_MEMORY_BODY_LEN as u64 {
182 return Err(AppError::BodyTooLarge {
183 bytes: file_size,
184 limit: MAX_MEMORY_BODY_LEN as u64,
185 });
186 }
187 let content = std::fs::read_to_string(path).map_err(AppError::Io)?;
188 let mut gf = serde_json::from_str::<GraphInput>(&content).map_err(|e| {
189 AppError::Validation(crate::i18n::validation::invalid_json_in_flag(
190 "--graph-file",
191 &e,
192 ))
193 })?;
194 graph.entities = gf.entities;
195 graph.relationships = gf.relationships;
196 if !body_explicitly_provided {
197 raw_body = gf.body.take().unwrap_or_default();
198 }
199 if !graph.entities.is_empty() {
200 entities_provided_externally = true;
201 }
202 }
203
204 if graph.entities.len() > max_entities_per_memory() {
205 return Err(AppError::LimitExceeded(errors_msg::entity_limit_exceeded(
206 max_entities_per_memory(),
207 )));
208 }
209 let mut relationships_updated = false;
210 let rel_cap = max_relationships_per_memory();
211 if graph.relationships.len() > rel_cap {
212 tracing::warn!(target: "remember",
213 count = graph.relationships.len(),
214 cap = rel_cap,
215 "truncating relationships to cap"
216 );
217 graph.relationships.truncate(rel_cap);
218 relationships_updated = true;
219 }
220 normalize_and_validate_graph_input(&mut graph)?;
221
222 crate::memory_guard::check_embedding_input_size(&raw_body)?;
227
228 let body_will_be_preserved = args.force_merge && raw_body.trim().is_empty() && !args.clear_body;
233 if !entities_provided_externally
234 && graph.entities.is_empty()
235 && raw_body.trim().is_empty()
236 && !body_will_be_preserved
237 && !args.clear_body
238 {
239 return Err(AppError::Validation(crate::i18n::validation::empty_body()));
240 }
241
242 let metadata: serde_json::Value = if let Some(ref m) = args.metadata {
243 serde_json::from_str(m)?
244 } else if let Some(ref path) = args.metadata_file {
245 let file_size = std::fs::metadata(path).map_err(AppError::Io)?.len();
246 if file_size > MAX_MEMORY_BODY_LEN as u64 {
247 return Err(AppError::BodyTooLarge {
248 bytes: file_size,
249 limit: MAX_MEMORY_BODY_LEN as u64,
250 });
251 }
252 let content = std::fs::read_to_string(path).map_err(AppError::Io)?;
253 serde_json::from_str(&content)?
254 } else {
255 serde_json::json!({})
256 };
257
258 let mut body_hash = blake3::hash(raw_body.as_bytes()).to_hex().to_string();
259 let mut snippet: String = raw_body.chars().take(200).collect();
260
261 let paths = AppPaths::resolve(args.db.as_deref())?;
262 paths.ensure_dirs()?;
263
264 let mut extraction_method: Option<String> = None;
266 let mut extracted_urls: Vec<crate::extraction::ExtractedUrl> = Vec::with_capacity(4);
267 if args.enable_ner && args.skip_extraction {
268 return Err(AppError::Validation(
269 crate::i18n::validation::enable_ner_skip_extraction_exclusive(),
270 ));
271 }
272 if args.skip_extraction && !args.enable_ner {
273 tracing::warn!(
280 "--skip-extraction is deprecated since v1.0.45 and has no effect (NER is disabled by default); remove this flag to silence the warning"
281 );
282 }
283 if args.enable_ner && graph.entities.is_empty() && !raw_body.trim().is_empty() {
284 match crate::extraction::extract_graph_auto(&raw_body, &paths) {
285 Ok(extracted) => {
286 extraction_method = Some("url-regex".to_string());
290 extracted_urls = extracted.urls;
291 graph.entities = extracted
294 .entities
295 .into_iter()
296 .map(|e| NewEntity {
297 name: e.name,
298 entity_type: crate::entity_type::EntityType::Concept,
299 description: None,
300 })
301 .collect();
302 graph.relationships.clear();
303 relationships_updated = false;
304
305 if graph.entities.len() > max_entities_per_memory() {
306 graph.entities.truncate(max_entities_per_memory());
307 }
308 if graph.relationships.len() > max_relationships_per_memory() {
309 relationships_updated = true;
310 graph.relationships.truncate(max_relationships_per_memory());
311 }
312 normalize_and_validate_graph_input(&mut graph)?;
313 }
314 Err(e) => {
315 tracing::warn!(target: "remember", error = %e, "auto-extraction failed, graceful degradation");
316 extraction_method = Some("none:extraction-failed".to_string());
317 }
318 }
319 }
320
321 let mut conn = open_rw(&paths.db)?;
322 ensure_schema(&mut conn)?;
323
324 if args.dry_run {
326 let existing = memories::find_by_name(&conn, &namespace, &normalized_name)?;
327 let planned_action = if existing.is_some() && args.force_merge {
328 "would_update"
329 } else {
330 "would_create"
331 };
332 output::emit_json(&serde_json::json!({
333 "dry_run": true,
334 "name": normalized_name,
335 "namespace": namespace,
336 "planned_action": planned_action,
337 }))?;
338 return Ok(());
339 }
340
341 {
342 use crate::constants::MAX_NAMESPACES_ACTIVE;
343 let active_count: u32 = conn.query_row(
344 "SELECT COUNT(DISTINCT namespace) FROM memories WHERE deleted_at IS NULL",
345 [],
346 |r| r.get::<_, i64>(0).map(|v| v as u32),
347 )?;
348 let ns_exists: bool = conn.query_row(
349 "SELECT EXISTS(SELECT 1 FROM memories WHERE namespace = ?1 AND deleted_at IS NULL)",
350 rusqlite::params![namespace],
351 |r| r.get::<_, i64>(0).map(|v| v > 0),
352 )?;
353 if !ns_exists && active_count >= MAX_NAMESPACES_ACTIVE {
354 return Err(AppError::NamespaceError(format!(
355 "active namespace limit of {MAX_NAMESPACES_ACTIVE} reached while trying to create '{namespace}'"
356 )));
357 }
358 }
359
360 if let Some((sd_id, true)) =
362 memories::find_by_name_any_state(&conn, &namespace, &normalized_name)?
363 {
364 if args.force_merge {
365 memories::clear_deleted_at(&conn, sd_id)?;
366 } else {
367 return Err(AppError::Duplicate(
368 errors_msg::duplicate_memory_soft_deleted(&normalized_name, &namespace),
369 ));
370 }
371 }
372
373 let existing_memory = memories::find_by_name(&conn, &namespace, &normalized_name)?;
374 if existing_memory.is_some() && !args.force_merge {
375 return Err(AppError::Duplicate(errors_msg::duplicate_memory(
376 &normalized_name,
377 &namespace,
378 )));
379 }
380
381 let (resolved_type, resolved_description) = if existing_memory.is_none() {
385 let t = args.r#type.ok_or_else(|| {
387 AppError::Validation(crate::i18n::validation::type_and_description_required())
388 })?;
389 let d = args.description.clone().ok_or_else(|| {
390 AppError::Validation(crate::i18n::validation::type_and_description_required())
391 })?;
392 (t.as_str().to_string(), d)
393 } else {
394 let existing_row = memories::read_by_name(&conn, &namespace, &normalized_name)?
396 .ok_or_else(|| {
397 AppError::NotFound(format!(
398 "memory '{normalized_name}' not found in namespace '{namespace}'"
399 ))
400 })?;
401 let t = args
402 .r#type
403 .map(|v| v.as_str().to_string())
404 .unwrap_or_else(|| existing_row.memory_type.clone());
405 let d = args
406 .description
407 .clone()
408 .unwrap_or_else(|| existing_row.description.clone());
409 (t, d)
410 };
411
412 if body_will_be_preserved {
417 if let Some(existing_row) = memories::read_by_name(&conn, &namespace, &normalized_name)? {
418 if !existing_row.body.is_empty() {
419 tracing::debug!(target: "remember",
420 name = %normalized_name,
421 "GAP-08: empty body with --force-merge and no --clear-body; preserving existing body"
422 );
423 raw_body = existing_row.body;
424 body_hash = blake3::hash(raw_body.as_bytes()).to_hex().to_string();
425 snippet = raw_body.chars().take(200).collect();
426 }
427 }
428 }
429
430 let duplicate_hash_id = memories::find_by_hash(&conn, &namespace, &body_hash)?;
431
432 output::emit_progress_i18n(
433 &format!(
434 "Remember stage: validated input; available memory {} MB",
435 crate::memory_guard::available_memory_mb()
436 ),
437 &format!(
438 "Stage remember: input validated; available memory {} MB",
439 crate::memory_guard::available_memory_mb()
440 ),
441 );
442
443 let model_max_length = crate::tokenizer::get_model_max_length();
444 let total_passage_tokens = crate::tokenizer::count_passage_tokens(&raw_body)?;
445 let chunks_info = chunking::split_into_chunks_hierarchical(&raw_body);
446 let chunks_created = chunks_info.len();
447 output::emit_progress_i18n(
453 &format!(
454 "Remember stage: tokenizer counted {total_passage_tokens} passage tokens (model max {model_max_length}); chunking produced {} chunks; process RSS {} MB",
455 chunks_created,
456 crate::memory_guard::current_process_memory_mb().unwrap_or(0)
457 ),
458 &format!(
459 "Stage remember: tokenizer counted {total_passage_tokens} passage tokens (model max {model_max_length}); chunking produced {} chunks; process RSS {} MB",
460 chunks_created,
461 crate::memory_guard::current_process_memory_mb().unwrap_or(0)
462 ),
463 );
464
465 if chunks_created > crate::constants::REMEMBER_MAX_SAFE_MULTI_CHUNKS {
466 return Err(AppError::TooManyChunks {
467 chunks: chunks_created,
468 limit: crate::constants::REMEMBER_MAX_SAFE_MULTI_CHUNKS,
469 });
470 }
471
472 let embed_out = super::embed_phase::run_embed_phase(
473 &paths,
474 &raw_body,
475 &chunks_info,
476 &graph,
477 &args,
478 embedding_backend,
479 llm_backend,
480 )?;
481 let embedding = embed_out.embedding;
482 let backend_invoked_passage = embed_out.backend_invoked_passage;
483 let mut chunk_embeddings_cache = embed_out.chunk_embeddings_cache;
484 let graph_entity_embeddings = embed_out.graph_entity_embeddings;
485 let _skip_embed = embed_out.skip_embed;
486
487 let body_for_storage = raw_body;
488 let memory_type = resolved_type.as_str();
489 let new_memory = NewMemory {
490 namespace: namespace.clone(),
491 name: normalized_name.clone(),
492 memory_type: memory_type.to_string(),
493 description: resolved_description.clone(),
494 body: body_for_storage,
495 body_hash: body_hash.clone(),
496 session_id: args.session_id.clone(),
497 source: "agent".to_string(),
498 metadata,
499 };
500
501 let mut warnings = Vec::with_capacity(4);
502 let mut entities_persisted = 0usize;
503 let mut relationships_persisted = 0usize;
504
505 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
506
507 let mut skip_reindex = false;
508 let (memory_id, action, version) = match existing_memory {
509 Some((existing_id, _updated_at, _current_version)) => {
510 if let Some(hash_id) = duplicate_hash_id {
511 if hash_id != existing_id {
512 warnings.push(format!(
513 "identical body already exists as memory id {hash_id}"
514 ));
515 }
516 }
517
518 let (old_fts_name, old_fts_desc, old_fts_body): (String, String, String) = tx
520 .query_row(
521 "SELECT name, description, body FROM memories WHERE id = ?1",
522 rusqlite::params![existing_id],
523 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
524 )?;
525
526 let existing_body_hash: Option<String> = tx
528 .query_row(
529 "SELECT body_hash FROM memories WHERE id = ?1",
530 rusqlite::params![existing_id],
531 |r| r.get(0),
532 )
533 .ok();
534 let body_unchanged = existing_body_hash.as_deref() == Some(&body_hash);
535 skip_reindex = body_unchanged;
536 if !body_unchanged {
537 storage_chunks::delete_chunks(&tx, existing_id)?;
538 }
539
540 let next_v = versions::next_version(&tx, existing_id)?;
541 memories::update(&tx, existing_id, &new_memory, args.expected_updated_at)?;
542
543 memories::sync_fts_after_update(
546 &tx,
547 existing_id,
548 &old_fts_name,
549 &old_fts_desc,
550 &old_fts_body,
551 &normalized_name,
552 &resolved_description,
553 &new_memory.body,
554 )?;
555
556 versions::insert_version(
557 &tx,
558 existing_id,
559 next_v,
560 &normalized_name,
561 memory_type,
562 &resolved_description,
563 &new_memory.body,
564 &serde_json::to_string(&new_memory.metadata)?,
565 None,
566 "edit",
567 )?;
568 if !body_unchanged {
569 if let Some(ref emb) = embedding {
570 memories::upsert_vec(
571 &tx,
572 existing_id,
573 &namespace,
574 memory_type,
575 emb,
576 &normalized_name,
577 &snippet,
578 )?;
579 }
580 }
581 (existing_id, "updated".to_string(), next_v)
582 }
583 None => {
584 if let Some(hash_id) = duplicate_hash_id {
585 warnings.push(format!(
586 "identical body already exists as memory id {hash_id}"
587 ));
588 }
589 let id = memories::insert(&tx, &new_memory)?;
590 versions::insert_version(
591 &tx,
592 id,
593 1,
594 &normalized_name,
595 memory_type,
596 &resolved_description,
597 &new_memory.body,
598 &serde_json::to_string(&new_memory.metadata)?,
599 None,
600 "create",
601 )?;
602 if let Some(ref emb) = embedding {
603 memories::upsert_vec(
604 &tx,
605 id,
606 &namespace,
607 memory_type,
608 emb,
609 &normalized_name,
610 &snippet,
611 )?;
612 }
613 (id, "created".to_string(), 1)
614 }
615 };
616
617 if args.replace_graph && action == "updated" {
623 let (e_removed, r_removed) = entities::clear_memory_graph_bindings(&tx, memory_id)?;
624 if e_removed + r_removed > 0 {
625 warnings.push(format!(
626 "--replace-graph cleared {e_removed} entity binding(s) and {r_removed} relationship binding(s) before re-linking"
627 ));
628 }
629 }
630
631 if chunks_info.len() > 1 && !skip_reindex {
632 storage_chunks::insert_chunk_slices(&tx, memory_id, &new_memory.body, &chunks_info)?;
633
634 if let Some(chunk_embeddings) = chunk_embeddings_cache.take() {
635 for (i, emb) in chunk_embeddings.iter().enumerate() {
636 storage_chunks::upsert_chunk_vec(&tx, i as i64, memory_id, i as i32, emb)?;
637 }
638 }
639 output::emit_progress_i18n(
640 &format!(
641 "Remember stage: persisted chunk vectors; process RSS {} MB",
642 crate::memory_guard::current_process_memory_mb().unwrap_or(0)
643 ),
644 &format!(
645 "Etapa remember: vetores de chunks persistidos; RSS do processo {} MB",
646 crate::memory_guard::current_process_memory_mb().unwrap_or(0)
647 ),
648 );
649 }
650
651 if !graph.entities.is_empty() || !graph.relationships.is_empty() {
652 for entity in &graph.entities {
653 let entity_id = entities::upsert_entity(&tx, &namespace, entity)?;
654 let entity_embedding = &graph_entity_embeddings[entities_persisted];
655 entities::upsert_entity_vec(
656 &tx,
657 entity_id,
658 &namespace,
659 entity.entity_type,
660 entity_embedding,
661 &entity.name,
662 )?;
663 entities::link_memory_entity(&tx, memory_id, entity_id)?;
664 entities_persisted += 1;
665 }
666 let entity_types: std::collections::HashMap<&str, EntityType> = graph
667 .entities
668 .iter()
669 .map(|entity| (entity.name.as_str(), entity.entity_type))
670 .collect();
671
672 let mut affected_entity_ids: std::collections::HashSet<i64> =
673 std::collections::HashSet::new();
674 for entity in &graph.entities {
675 if let Some(eid) = entities::find_entity_id(&tx, &namespace, &entity.name)? {
676 affected_entity_ids.insert(eid);
677 }
678 }
679
680 for rel in &graph.relationships {
681 let source_entity = NewEntity {
682 name: rel.source.clone(),
683 entity_type: entity_types
684 .get(rel.source.as_str())
685 .copied()
686 .unwrap_or(EntityType::Concept),
687 description: None,
688 };
689 let target_entity = NewEntity {
690 name: rel.target.clone(),
691 entity_type: entity_types
692 .get(rel.target.as_str())
693 .copied()
694 .unwrap_or(EntityType::Concept),
695 description: None,
696 };
697 let source_id = entities::upsert_entity(&tx, &namespace, &source_entity)?;
698 let target_id = entities::upsert_entity(&tx, &namespace, &target_entity)?;
699 let rel_id = entities::upsert_relationship(&tx, &namespace, source_id, target_id, rel)?;
700 entities::link_memory_relationship(&tx, memory_id, rel_id)?;
701 affected_entity_ids.insert(source_id);
702 affected_entity_ids.insert(target_id);
703 relationships_persisted += 1;
704 }
705
706 for &eid in &affected_entity_ids {
707 entities::recalculate_degree(&tx, eid)?;
708 }
709 }
710 tx.commit()?;
711
712 super::finish::emit_remember_result(
713 &conn,
714 &paths,
715 &args,
716 &graph,
717 &new_memory,
718 memory_id,
719 action,
720 version,
721 namespace,
722 normalized_name,
723 original_name,
724 name_was_normalized,
725 entities_persisted,
726 relationships_persisted,
727 relationships_updated,
728 chunks_created,
729 warnings,
730 extracted_urls,
731 extraction_method,
732 backend_invoked_passage,
733 inicio,
734 )?;
735
736 Ok(())
737}