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