1use crate::chunking;
4use crate::cli::MemoryType;
5use crate::entity_type::EntityType;
6use crate::errors::AppError;
7use crate::i18n::errors_msg;
8use crate::output::{self, JsonOutputFormat, RememberResponse};
9use crate::paths::AppPaths;
10use crate::storage::chunks as storage_chunks;
11use crate::storage::connection::{ensure_schema, open_rw};
12use crate::storage::entities::{NewEntity, NewRelationship};
13use crate::storage::memories::NewMemory;
14use crate::storage::{entities, memories, urls as storage_urls, versions};
15use serde::Deserialize;
16
17#[derive(clap::Args)]
18#[command(after_long_help = "EXAMPLES:\n \
19 # Create a memory with inline body\n \
20 sqlite-graphrag remember --name design-auth --type decision \\\n \
21 --description \"auth design\" --body \"JWT for stateless auth\"\n\n \
22 # Create with curated graph via --graph-stdin\n \
23 echo '{\"body\":\"...\",\"entities\":[],\"relationships\":[]}' | \\\n \
24 sqlite-graphrag remember --name my-mem --type note --description \"desc\" --graph-stdin\n\n \
25 # Enable automatic URL extraction with --graph-stdin (URL-regex only since v1.0.79)\n \
26 echo '{\"body\":\"See https://docs.rs ...\",\"entities\":[],\"relationships\":[]}' | \\\n \
27 sqlite-graphrag remember --name url-test --type note --description \"test\" \\\n \
28 --graph-stdin --enable-ner\n\n \
29 # Idempotent upsert with --force-merge\n \
30 sqlite-graphrag remember --name my-mem --type note --description \"updated\" \\\n \
31 --body \"new content\" --force-merge\n\n\
32NOTE:\n \
33 remember does NOT accept positional arguments.\n \
34 Use --body \"text\" for inline content\n \
35 Use --body-file path for file content\n \
36 Use --body-stdin for piped content\n \
37 Use --graph-stdin for JSON with entities and relationships\n\n\
38ENTITY TYPES (for --graph-stdin entities, NOT memory --type):\n \
39 concept, tool, person, file, project, decision, incident,\n \
40 organization, location, date, dashboard, issue_tracker, memory\n \
41 WARNING: reference, skill, document, note, user, feedback are\n \
42 MEMORY types only — NOT valid for entities.\n \
43 Mapping: reference→concept, document→file, user→person")]
44pub struct RememberArgs {
45 #[arg(long)]
48 pub name: String,
49 #[arg(
50 long,
51 value_enum,
52 long_help = "Memory kind stored in `memories.type`. Required when creating a new memory. Optional with --force-merge: if omitted the existing memory type is inherited. This is NOT the graph `entity_type` used in `--entities-file`. Valid values: user, feedback, project, reference, decision, incident, skill, document, note."
53 )]
54 pub r#type: Option<MemoryType>,
55 #[arg(long, allow_hyphen_values = true)]
62 pub description: Option<String>,
63 #[arg(
69 long,
70 allow_hyphen_values = true,
71 help = "Inline body content (max 500 KB / 512000 bytes and 30000 estimated tokens; split dense bodies into multiple memories at ~25000 tokens, or use --body-file)",
72 conflicts_with_all = ["body_file", "body_stdin", "graph_stdin"]
73 )]
74 pub body: Option<String>,
75 #[arg(
76 long,
77 help = "Read body from a file instead of --body",
78 conflicts_with_all = ["body", "body_stdin", "graph_stdin"]
79 )]
80 pub body_file: Option<std::path::PathBuf>,
81 #[arg(
84 long,
85 conflicts_with_all = ["body", "body_file", "graph_stdin"]
86 )]
87 pub body_stdin: bool,
88 #[arg(
89 long,
90 help = "JSON file containing entities to associate with this memory"
91 )]
92 pub entities_file: Option<std::path::PathBuf>,
93 #[arg(
94 long,
95 help = "JSON file containing relationships to associate with this memory"
96 )]
97 pub relationships_file: Option<std::path::PathBuf>,
98 #[arg(
99 long,
100 help = "Read graph JSON (body + entities + relationships) from stdin",
101 conflicts_with_all = [
102 "body",
103 "body_file",
104 "body_stdin",
105 "entities_file",
106 "relationships_file",
107 "graph_file"
108 ]
109 )]
110 pub graph_stdin: bool,
111 #[arg(
118 long,
119 value_name = "PATH",
120 help = "Read graph JSON (body + entities + relationships) from a file (combines with --body/--body-file/--body-stdin)",
121 conflicts_with_all = ["graph_stdin", "entities_file", "relationships_file"]
122 )]
123 pub graph_file: Option<std::path::PathBuf>,
124 #[arg(
125 long,
126 help = "Namespace (flag / XDG namespace.default / global)"
127 )]
128 pub namespace: Option<String>,
129 #[arg(long)]
131 pub metadata: Option<String>,
132 #[arg(long, help = "JSON file containing metadata key-value pairs")]
133 pub metadata_file: Option<std::path::PathBuf>,
134 #[arg(long)]
135 pub force_merge: bool,
136 #[arg(
137 long,
138 value_name = "EPOCH_OR_RFC3339",
139 value_parser = crate::parsers::parse_expected_updated_at,
140 long_help = "Optimistic lock: reject if updated_at does not match. \
141Accepts Unix epoch (e.g. 1700000000) or RFC 3339 (e.g. 2026-04-19T12:00:00Z)."
142 )]
143 pub expected_updated_at: Option<i64>,
144 #[arg(
145 long,
146 value_parser = crate::parsers::parse_bool_flexible,
147 action = clap::ArgAction::Set,
148 num_args = 0..=1,
149 default_missing_value = "true",
150 default_value = "false",
151 help = "Enable automatic URL-regex extraction from body (URL-regex only since v1.0.79)"
152 )]
153 pub enable_ner: bool,
154 #[arg(long, hide = true)]
155 pub skip_extraction: bool,
156 #[arg(
160 long,
161 default_value_t = false,
162 help = "Explicitly clear body content during --force-merge (without this flag, an empty body is ignored and the existing body is kept)"
163 )]
164 pub clear_body: bool,
165 #[arg(
167 long,
168 default_value_t = false,
169 help = "Validate input and report planned actions without persisting"
170 )]
171 pub dry_run: bool,
172 #[arg(
176 long,
177 default_value_t = false,
178 help = "Reject the write if --name would be normalized to kebab-case (preserve-name guard)"
179 )]
180 pub strict_name: bool,
181 #[arg(
186 long,
187 default_value_t = false,
188 help = "With --force-merge, replace (not merge) the memory's graph bindings; empty entities clears them"
189 )]
190 pub replace_graph: bool,
191 #[arg(long)]
193 pub session_id: Option<String>,
194 #[arg(long, value_enum, default_value_t = JsonOutputFormat::Json)]
195 pub format: JsonOutputFormat,
196 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
197 pub json: bool,
198 #[arg(long)]
199 pub db: Option<String>,
200 #[arg(long, default_value_t = crate::constants::DEFAULT_MAX_RSS_MB,
202 help = "Maximum process RSS in MiB; abort if exceeded during embedding (default: 8192)")]
203 pub max_rss_mb: u64,
204 #[arg(long, default_value_t = 4, value_name = "N",
208 value_parser = clap::value_parser!(u64).range(1..=32),
209 help = "Maximum simultaneous LLM embedding subprocesses (default: 4, clamp [1,32])")]
210 pub llm_parallelism: u64,
211 #[arg(long, default_value_t = false)]
215 pub enqueue_enrich: bool,
216}
217
218#[derive(Deserialize, Default)]
219#[serde(deny_unknown_fields)]
220struct GraphInput {
221 #[serde(default)]
222 body: Option<String>,
223 #[serde(default)]
224 entities: Vec<NewEntity>,
225 #[serde(default)]
226 relationships: Vec<NewRelationship>,
227}
228
229fn normalize_and_validate_graph_input(graph: &mut GraphInput) -> Result<(), AppError> {
230 for rel in &mut graph.relationships {
231 rel.relation = crate::parsers::normalize_relation(&rel.relation);
232 if let Err(e) = crate::parsers::validate_relation_format(&rel.relation) {
233 return Err(AppError::Validation(format!(
234 "{e} for relationship '{}' -> '{}'",
235 rel.source, rel.target
236 )));
237 }
238 crate::parsers::warn_if_non_canonical(&rel.relation);
239 if !(0.0..=1.0).contains(&rel.strength) {
240 return Err(AppError::Validation(format!(
241 "invalid strength {} for relationship '{}' -> '{}'; expected value in [0.0, 1.0]",
242 rel.strength, rel.source, rel.target
243 )));
244 }
245 }
246
247 Ok(())
248}
249
250#[tracing::instrument(skip_all, level = "debug", name = "remember")]
251pub fn run(
252 args: RememberArgs,
253 llm_backend: crate::cli::LlmBackendChoice,
254 embedding_backend: crate::cli::EmbeddingBackendChoice,
255) -> Result<(), AppError> {
256 use crate::constants::*;
257
258 let inicio = std::time::Instant::now();
259 let _ = args.format;
260 tracing::debug!(target: "remember", name = %args.name, "persisting memory");
261 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
262
263 let original_name = args.name.clone();
267
268 let normalized_name = {
272 let lower = args.name.to_lowercase().replace(['_', ' '], "-");
273 let trimmed = lower.trim_matches('-').to_string();
274 if trimmed != args.name {
275 tracing::warn!(target: "remember",
276 original = %args.name,
277 normalized = %trimmed,
278 "name auto-normalized to kebab-case"
279 );
280 }
281 trimmed
282 };
283 let name_was_normalized = normalized_name != original_name;
284
285 if args.strict_name && name_was_normalized {
288 return Err(AppError::Validation(format!(
289 "--strict-name is set but '{original_name}' is not canonical kebab-case; \
290 re-run with --name '{normalized_name}' (or drop --strict-name to allow auto-normalization)"
291 )));
292 }
293
294 if normalized_name.is_empty() {
295 return Err(AppError::Validation(
296 "name cannot be empty after normalization (input was blank or contained only hyphens/underscores/spaces)".to_string(),
297 ));
298 }
299 if normalized_name.len() > MAX_MEMORY_NAME_LEN {
300 return Err(AppError::LimitExceeded(
301 crate::i18n::validation::name_length(MAX_MEMORY_NAME_LEN),
302 ));
303 }
304
305 if normalized_name.starts_with("__") {
306 return Err(AppError::Validation(
307 crate::i18n::validation::reserved_name(),
308 ));
309 }
310
311 {
312 let slug_re = crate::constants::name_slug_regex();
313 if !slug_re.is_match(&normalized_name) {
314 return Err(AppError::Validation(crate::i18n::validation::name_kebab(
315 &normalized_name,
316 )));
317 }
318 }
319
320 if let Some(ref desc) = args.description {
321 if desc.len() > MAX_MEMORY_DESCRIPTION_LEN {
322 return Err(AppError::Validation(
323 crate::i18n::validation::description_exceeds(MAX_MEMORY_DESCRIPTION_LEN),
324 ));
325 }
326 }
327
328 let body_explicitly_provided =
332 args.body.is_some() || args.body_file.is_some() || args.body_stdin;
333
334 let mut raw_body = if let Some(b) = args.body {
335 b
336 } else if let Some(ref path) = args.body_file {
337 let file_size = std::fs::metadata(path).map_err(AppError::Io)?.len();
338 if file_size > MAX_MEMORY_BODY_LEN as u64 {
339 return Err(AppError::BodyTooLarge {
340 bytes: file_size,
341 limit: MAX_MEMORY_BODY_LEN as u64,
342 });
343 }
344 match std::fs::read_to_string(path) {
345 Ok(s) => s,
346 Err(e) if e.kind() == std::io::ErrorKind::InvalidData => {
347 let bytes = std::fs::read(path).map_err(AppError::Io)?;
348 tracing::warn!(target: "remember", "body file contains invalid UTF-8; replacing invalid sequences");
349 String::from_utf8_lossy(&bytes).into_owned()
350 }
351 Err(e) => return Err(AppError::Io(e)),
352 }
353 } else if args.body_stdin || args.graph_stdin {
354 crate::stdin_helper::read_stdin_with_timeout(60)?
355 } else {
356 String::new()
357 };
358
359 let mut entities_provided_externally =
360 args.entities_file.is_some() || args.relationships_file.is_some();
361
362 let mut graph = GraphInput::default();
363 if let Some(path) = args.entities_file {
364 let file_size = std::fs::metadata(&path).map_err(AppError::Io)?.len();
365 if file_size > MAX_MEMORY_BODY_LEN as u64 {
366 return Err(AppError::BodyTooLarge {
367 bytes: file_size,
368 limit: MAX_MEMORY_BODY_LEN as u64,
369 });
370 }
371 let content = std::fs::read_to_string(&path).map_err(AppError::Io)?;
372 graph.entities = serde_json::from_str(&content)
376 .map_err(|e| AppError::Validation(format!("invalid JSON in --entities-file: {e}")))?;
377 }
378 if let Some(path) = args.relationships_file {
379 let file_size = std::fs::metadata(&path).map_err(AppError::Io)?.len();
380 if file_size > MAX_MEMORY_BODY_LEN as u64 {
381 return Err(AppError::BodyTooLarge {
382 bytes: file_size,
383 limit: MAX_MEMORY_BODY_LEN as u64,
384 });
385 }
386 let content = std::fs::read_to_string(&path).map_err(AppError::Io)?;
387 graph.relationships = serde_json::from_str(&content).map_err(|e| {
388 AppError::Validation(format!("invalid JSON in --relationships-file: {e}"))
389 })?;
390 }
391 if args.graph_stdin {
392 graph = serde_json::from_str::<GraphInput>(&raw_body).map_err(|e| {
393 AppError::Validation(format!("invalid JSON payload on --graph-stdin: {e}"))
394 })?;
395 raw_body = graph.body.take().unwrap_or_default();
396 }
397 if args.graph_stdin && !graph.entities.is_empty() {
398 entities_provided_externally = true;
399 }
400 if let Some(path) = args.graph_file {
404 let file_size = std::fs::metadata(&path).map_err(AppError::Io)?.len();
405 if file_size > MAX_MEMORY_BODY_LEN as u64 {
406 return Err(AppError::BodyTooLarge {
407 bytes: file_size,
408 limit: MAX_MEMORY_BODY_LEN as u64,
409 });
410 }
411 let content = std::fs::read_to_string(&path).map_err(AppError::Io)?;
412 let mut gf = serde_json::from_str::<GraphInput>(&content)
413 .map_err(|e| AppError::Validation(format!("invalid JSON in --graph-file: {e}")))?;
414 graph.entities = gf.entities;
415 graph.relationships = gf.relationships;
416 if !body_explicitly_provided {
417 raw_body = gf.body.take().unwrap_or_default();
418 }
419 if !graph.entities.is_empty() {
420 entities_provided_externally = true;
421 }
422 }
423
424 if graph.entities.len() > max_entities_per_memory() {
425 return Err(AppError::LimitExceeded(errors_msg::entity_limit_exceeded(
426 max_entities_per_memory(),
427 )));
428 }
429 let mut relationships_truncated = false;
430 let rel_cap = max_relationships_per_memory();
431 if graph.relationships.len() > rel_cap {
432 tracing::warn!(target: "remember",
433 count = graph.relationships.len(),
434 cap = rel_cap,
435 "truncating relationships to cap"
436 );
437 graph.relationships.truncate(rel_cap);
438 relationships_truncated = true;
439 }
440 normalize_and_validate_graph_input(&mut graph)?;
441
442 crate::memory_guard::check_embedding_input_size(&raw_body)?;
447
448 let body_will_be_preserved = args.force_merge && raw_body.trim().is_empty() && !args.clear_body;
453 if !entities_provided_externally
454 && graph.entities.is_empty()
455 && raw_body.trim().is_empty()
456 && !body_will_be_preserved
457 && !args.clear_body
458 {
459 return Err(AppError::Validation(crate::i18n::validation::empty_body()));
460 }
461
462 let metadata: serde_json::Value = if let Some(m) = args.metadata {
463 serde_json::from_str(&m)?
464 } else if let Some(path) = args.metadata_file {
465 let file_size = std::fs::metadata(&path).map_err(AppError::Io)?.len();
466 if file_size > MAX_MEMORY_BODY_LEN as u64 {
467 return Err(AppError::BodyTooLarge {
468 bytes: file_size,
469 limit: MAX_MEMORY_BODY_LEN as u64,
470 });
471 }
472 let content = std::fs::read_to_string(&path).map_err(AppError::Io)?;
473 serde_json::from_str(&content)?
474 } else {
475 serde_json::json!({})
476 };
477
478 let mut body_hash = blake3::hash(raw_body.as_bytes()).to_hex().to_string();
479 let mut snippet: String = raw_body.chars().take(200).collect();
480
481 let paths = AppPaths::resolve(args.db.as_deref())?;
482 paths.ensure_dirs()?;
483
484 let mut extraction_method: Option<String> = None;
486 let mut extracted_urls: Vec<crate::extraction::ExtractedUrl> = Vec::with_capacity(4);
487 if args.enable_ner && args.skip_extraction {
488 return Err(AppError::Validation(
489 "--enable-ner and --skip-extraction are mutually exclusive; remove one".to_string(),
490 ));
491 }
492 if args.skip_extraction && !args.enable_ner {
493 tracing::warn!(
500 "--skip-extraction is deprecated since v1.0.45 and has no effect (NER is disabled by default); remove this flag to silence the warning"
501 );
502 }
503 if args.enable_ner && graph.entities.is_empty() && !raw_body.trim().is_empty() {
504 match crate::extraction::extract_graph_auto(&raw_body, &paths) {
505 Ok(extracted) => {
506 extraction_method = Some("url-regex".to_string());
510 extracted_urls = extracted.urls;
511 graph.entities = extracted
514 .entities
515 .into_iter()
516 .map(|e| NewEntity {
517 name: e.name,
518 entity_type: crate::entity_type::EntityType::Concept,
519 description: None,
520 })
521 .collect();
522 graph.relationships.clear();
523 relationships_truncated = false;
524
525 if graph.entities.len() > max_entities_per_memory() {
526 graph.entities.truncate(max_entities_per_memory());
527 }
528 if graph.relationships.len() > max_relationships_per_memory() {
529 relationships_truncated = true;
530 graph.relationships.truncate(max_relationships_per_memory());
531 }
532 normalize_and_validate_graph_input(&mut graph)?;
533 }
534 Err(e) => {
535 tracing::warn!(target: "remember", error = %e, "auto-extraction failed, graceful degradation");
536 extraction_method = Some("none:extraction-failed".to_string());
537 }
538 }
539 }
540
541 let mut conn = open_rw(&paths.db)?;
542 ensure_schema(&mut conn)?;
543
544 if args.dry_run {
546 let existing = memories::find_by_name(&conn, &namespace, &normalized_name)?;
547 let planned_action = if existing.is_some() && args.force_merge {
548 "would_update"
549 } else {
550 "would_create"
551 };
552 output::emit_json(&serde_json::json!({
553 "dry_run": true,
554 "name": normalized_name,
555 "namespace": namespace,
556 "planned_action": planned_action,
557 }))?;
558 return Ok(());
559 }
560
561 {
562 use crate::constants::MAX_NAMESPACES_ACTIVE;
563 let active_count: u32 = conn.query_row(
564 "SELECT COUNT(DISTINCT namespace) FROM memories WHERE deleted_at IS NULL",
565 [],
566 |r| r.get::<_, i64>(0).map(|v| v as u32),
567 )?;
568 let ns_exists: bool = conn.query_row(
569 "SELECT EXISTS(SELECT 1 FROM memories WHERE namespace = ?1 AND deleted_at IS NULL)",
570 rusqlite::params![namespace],
571 |r| r.get::<_, i64>(0).map(|v| v > 0),
572 )?;
573 if !ns_exists && active_count >= MAX_NAMESPACES_ACTIVE {
574 return Err(AppError::NamespaceError(format!(
575 "active namespace limit of {MAX_NAMESPACES_ACTIVE} reached while trying to create '{namespace}'"
576 )));
577 }
578 }
579
580 if let Some((sd_id, true)) =
582 memories::find_by_name_any_state(&conn, &namespace, &normalized_name)?
583 {
584 if args.force_merge {
585 memories::clear_deleted_at(&conn, sd_id)?;
586 } else {
587 return Err(AppError::Duplicate(
588 errors_msg::duplicate_memory_soft_deleted(&normalized_name, &namespace),
589 ));
590 }
591 }
592
593 let existing_memory = memories::find_by_name(&conn, &namespace, &normalized_name)?;
594 if existing_memory.is_some() && !args.force_merge {
595 return Err(AppError::Duplicate(errors_msg::duplicate_memory(
596 &normalized_name,
597 &namespace,
598 )));
599 }
600
601 let (resolved_type, resolved_description) = if existing_memory.is_none() {
605 let t = args.r#type.ok_or_else(|| {
607 AppError::Validation(
608 "--type and --description are required when creating a new memory".to_string(),
609 )
610 })?;
611 let d = args.description.clone().ok_or_else(|| {
612 AppError::Validation(
613 "--type and --description are required when creating a new memory".to_string(),
614 )
615 })?;
616 (t.as_str().to_string(), d)
617 } else {
618 let existing_row = memories::read_by_name(&conn, &namespace, &normalized_name)?
620 .ok_or_else(|| {
621 AppError::NotFound(format!(
622 "memory '{normalized_name}' not found in namespace '{namespace}'"
623 ))
624 })?;
625 let t = args
626 .r#type
627 .map(|v| v.as_str().to_string())
628 .unwrap_or_else(|| existing_row.memory_type.clone());
629 let d = args
630 .description
631 .clone()
632 .unwrap_or_else(|| existing_row.description.clone());
633 (t, d)
634 };
635
636 if body_will_be_preserved {
641 if let Some(existing_row) = memories::read_by_name(&conn, &namespace, &normalized_name)? {
642 if !existing_row.body.is_empty() {
643 tracing::debug!(target: "remember",
644 name = %normalized_name,
645 "GAP-08: empty body with --force-merge and no --clear-body; preserving existing body"
646 );
647 raw_body = existing_row.body;
648 body_hash = blake3::hash(raw_body.as_bytes()).to_hex().to_string();
649 snippet = raw_body.chars().take(200).collect();
650 }
651 }
652 }
653
654 let duplicate_hash_id = memories::find_by_hash(&conn, &namespace, &body_hash)?;
655
656 output::emit_progress_i18n(
657 &format!(
658 "Remember stage: validated input; available memory {} MB",
659 crate::memory_guard::available_memory_mb()
660 ),
661 &format!(
662 "Stage remember: input validated; available memory {} MB",
663 crate::memory_guard::available_memory_mb()
664 ),
665 );
666
667 let model_max_length = crate::tokenizer::get_model_max_length();
668 let total_passage_tokens = crate::tokenizer::count_passage_tokens(&raw_body)?;
669 let chunks_info = chunking::split_into_chunks_hierarchical(&raw_body);
670 let chunks_created = chunks_info.len();
671 output::emit_progress_i18n(
677 &format!(
678 "Remember stage: tokenizer counted {total_passage_tokens} passage tokens (model max {model_max_length}); chunking produced {} chunks; process RSS {} MB",
679 chunks_created,
680 crate::memory_guard::current_process_memory_mb().unwrap_or(0)
681 ),
682 &format!(
683 "Stage remember: tokenizer counted {total_passage_tokens} passage tokens (model max {model_max_length}); chunking produced {} chunks; process RSS {} MB",
684 chunks_created,
685 crate::memory_guard::current_process_memory_mb().unwrap_or(0)
686 ),
687 );
688
689 if chunks_created > crate::constants::REMEMBER_MAX_SAFE_MULTI_CHUNKS {
690 return Err(AppError::TooManyChunks {
691 chunks: chunks_created,
692 limit: crate::constants::REMEMBER_MAX_SAFE_MULTI_CHUNKS,
693 });
694 }
695
696 output::emit_progress_i18n("Computing embedding...", "Calculando embedding...");
697 let mut chunk_embeddings_cache: Option<Vec<Vec<f32>>> = None;
698
699 let skip_embed = crate::embedder::should_skip_embedding_on_failure();
703 let (embedding, backend_invoked_passage): (Option<Vec<f32>>, Option<&str>) = if chunks_info
704 .len()
705 == 1
706 {
707 match crate::embedder::embed_passage_with_embedding_choice(
708 &paths.models,
709 &raw_body,
710 embedding_backend,
711 llm_backend,
712 ) {
713 Ok((v, k)) if v.is_empty() => (None, Some(k.as_str())),
717 Ok((v, k)) => (Some(v), Some(k.as_str())),
718 Err(
721 e @ (AppError::Validation(_)
722 | AppError::BodyTooLarge { .. }
723 | AppError::TooManyTokens { .. }),
724 ) => return Err(e),
725 Err(e) if skip_embed => {
726 tracing::warn!(error = %e, "embedding failed; --skip-embedding-on-failure active, persisting without embedding");
727 (None, None)
728 }
729 Err(e) => return Err(e),
730 }
731 } else {
732 let chunk_texts: Vec<String> = chunks_info
733 .iter()
734 .map(|c| chunking::chunk_text(&raw_body, c).to_string())
735 .collect();
736 output::emit_progress_i18n(
742 &format!(
743 "Embedding {} chunks in parallel batches (parallelism {})...",
744 chunks_info.len(),
745 args.llm_parallelism
746 ),
747 &format!(
748 "Embedding {} chunks em lotes paralelos (paralelismo {})...",
749 chunks_info.len(),
750 args.llm_parallelism
751 ),
752 );
753 if let Some(rss) = crate::memory_guard::current_process_memory_mb() {
754 if rss > args.max_rss_mb {
755 tracing::error!(target: "remember",
756 rss_mb = rss,
757 max_rss_mb = args.max_rss_mb,
758 "RSS exceeded --max-rss-mb threshold; aborting to prevent system instability"
759 );
760 return Err(AppError::LowMemory {
761 available_mb: crate::memory_guard::available_memory_mb(),
762 required_mb: args.max_rss_mb,
763 });
764 }
765 }
766 match crate::embedder::embed_passages_parallel_with_embedding_choice(
767 &paths.models,
768 &chunk_texts,
769 args.llm_parallelism as usize,
770 crate::embedder::chunk_embed_batch_size(),
771 embedding_backend,
772 llm_backend,
773 ) {
774 Ok(chunk_embeddings) => {
775 output::emit_progress_i18n(
776 &format!(
777 "Remember stage: chunk embeddings complete; process RSS {} MB",
778 crate::memory_guard::current_process_memory_mb().unwrap_or(0)
779 ),
780 &format!(
781 "Stage remember: chunk embeddings completed; process RSS {} MB",
782 crate::memory_guard::current_process_memory_mb().unwrap_or(0)
783 ),
784 );
785 let aggregated = chunking::aggregate_embeddings(&chunk_embeddings);
786 chunk_embeddings_cache = Some(chunk_embeddings);
787 (Some(aggregated), None)
788 }
789 Err(e) if skip_embed => {
790 tracing::warn!(error = %e, "chunk embedding failed; --skip-embedding-on-failure active, persisting without embedding");
791 (None, None)
792 }
793 Err(e) => return Err(e),
794 }
795 };
796 let body_for_storage = raw_body;
797
798 let memory_type = resolved_type.as_str();
799 let new_memory = NewMemory {
800 namespace: namespace.clone(),
801 name: normalized_name.clone(),
802 memory_type: memory_type.to_string(),
803 description: resolved_description.clone(),
804 body: body_for_storage,
805 body_hash: body_hash.clone(),
806 session_id: args.session_id.clone(),
807 source: "agent".to_string(),
808 metadata,
809 };
810
811 let mut warnings = Vec::with_capacity(4);
812 let mut entities_persisted = 0usize;
813 let mut relationships_persisted = 0usize;
814
815 let entity_texts: Vec<String> = graph
820 .entities
821 .iter()
822 .map(|entity| match &entity.description {
823 Some(desc) => format!("{} {}", entity.name, desc),
824 None => entity.name.clone(),
825 })
826 .collect();
827 let (graph_entity_embeddings, embed_cache_stats) =
835 match crate::embedder::embed_entity_texts_cached(
836 &paths.models,
837 &entity_texts,
838 args.llm_parallelism as usize,
839 embedding_backend,
840 llm_backend,
841 ) {
842 Ok(r) => r,
843 Err(e) if skip_embed => {
844 tracing::warn!(error = %e, "entity embedding failed; --skip-embedding-on-failure active");
845 let empty: Vec<Vec<f32>> = entity_texts.iter().map(|_| vec![]).collect();
846 (empty, crate::embedder::EmbedCacheStats::default())
847 }
848 Err(e) => return Err(e),
849 };
850 if embed_cache_stats.hits > 0 {
851 tracing::debug!(
852 hits = embed_cache_stats.hits,
853 misses = embed_cache_stats.misses,
854 requested = embed_cache_stats.requested,
855 "G56: entity embed cache hit (remember)"
856 );
857 }
858
859 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
860
861 let mut skip_reindex = false;
862 let (memory_id, action, version) = match existing_memory {
863 Some((existing_id, _updated_at, _current_version)) => {
864 if let Some(hash_id) = duplicate_hash_id {
865 if hash_id != existing_id {
866 warnings.push(format!(
867 "identical body already exists as memory id {hash_id}"
868 ));
869 }
870 }
871
872 let (old_fts_name, old_fts_desc, old_fts_body): (String, String, String) = tx
874 .query_row(
875 "SELECT name, description, body FROM memories WHERE id = ?1",
876 rusqlite::params![existing_id],
877 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
878 )?;
879
880 let existing_body_hash: Option<String> = tx
882 .query_row(
883 "SELECT body_hash FROM memories WHERE id = ?1",
884 rusqlite::params![existing_id],
885 |r| r.get(0),
886 )
887 .ok();
888 let body_unchanged = existing_body_hash.as_deref() == Some(&body_hash);
889 skip_reindex = body_unchanged;
890 if !body_unchanged {
891 storage_chunks::delete_chunks(&tx, existing_id)?;
892 }
893
894 let next_v = versions::next_version(&tx, existing_id)?;
895 memories::update(&tx, existing_id, &new_memory, args.expected_updated_at)?;
896
897 memories::sync_fts_after_update(
900 &tx,
901 existing_id,
902 &old_fts_name,
903 &old_fts_desc,
904 &old_fts_body,
905 &normalized_name,
906 &resolved_description,
907 &new_memory.body,
908 )?;
909
910 versions::insert_version(
911 &tx,
912 existing_id,
913 next_v,
914 &normalized_name,
915 memory_type,
916 &resolved_description,
917 &new_memory.body,
918 &serde_json::to_string(&new_memory.metadata)?,
919 None,
920 "edit",
921 )?;
922 if !body_unchanged {
923 if let Some(ref emb) = embedding {
924 memories::upsert_vec(
925 &tx,
926 existing_id,
927 &namespace,
928 memory_type,
929 emb,
930 &normalized_name,
931 &snippet,
932 )?;
933 }
934 }
935 (existing_id, "updated".to_string(), next_v)
936 }
937 None => {
938 if let Some(hash_id) = duplicate_hash_id {
939 warnings.push(format!(
940 "identical body already exists as memory id {hash_id}"
941 ));
942 }
943 let id = memories::insert(&tx, &new_memory)?;
944 versions::insert_version(
945 &tx,
946 id,
947 1,
948 &normalized_name,
949 memory_type,
950 &resolved_description,
951 &new_memory.body,
952 &serde_json::to_string(&new_memory.metadata)?,
953 None,
954 "create",
955 )?;
956 if let Some(ref emb) = embedding {
957 memories::upsert_vec(
958 &tx,
959 id,
960 &namespace,
961 memory_type,
962 emb,
963 &normalized_name,
964 &snippet,
965 )?;
966 }
967 (id, "created".to_string(), 1)
968 }
969 };
970
971 if args.replace_graph && action == "updated" {
977 let (e_removed, r_removed) = entities::clear_memory_graph_bindings(&tx, memory_id)?;
978 if e_removed + r_removed > 0 {
979 warnings.push(format!(
980 "--replace-graph cleared {e_removed} entity binding(s) and {r_removed} relationship binding(s) before re-linking"
981 ));
982 }
983 }
984
985 if chunks_info.len() > 1 && !skip_reindex {
986 storage_chunks::insert_chunk_slices(&tx, memory_id, &new_memory.body, &chunks_info)?;
987
988 if let Some(chunk_embeddings) = chunk_embeddings_cache.take() {
989 for (i, emb) in chunk_embeddings.iter().enumerate() {
990 storage_chunks::upsert_chunk_vec(&tx, i as i64, memory_id, i as i32, emb)?;
991 }
992 }
993 output::emit_progress_i18n(
994 &format!(
995 "Remember stage: persisted chunk vectors; process RSS {} MB",
996 crate::memory_guard::current_process_memory_mb().unwrap_or(0)
997 ),
998 &format!(
999 "Etapa remember: vetores de chunks persistidos; RSS do processo {} MB",
1000 crate::memory_guard::current_process_memory_mb().unwrap_or(0)
1001 ),
1002 );
1003 }
1004
1005 if !graph.entities.is_empty() || !graph.relationships.is_empty() {
1006 for entity in &graph.entities {
1007 let entity_id = entities::upsert_entity(&tx, &namespace, entity)?;
1008 let entity_embedding = &graph_entity_embeddings[entities_persisted];
1009 entities::upsert_entity_vec(
1010 &tx,
1011 entity_id,
1012 &namespace,
1013 entity.entity_type,
1014 entity_embedding,
1015 &entity.name,
1016 )?;
1017 entities::link_memory_entity(&tx, memory_id, entity_id)?;
1018 entities_persisted += 1;
1019 }
1020 let entity_types: std::collections::HashMap<&str, EntityType> = graph
1021 .entities
1022 .iter()
1023 .map(|entity| (entity.name.as_str(), entity.entity_type))
1024 .collect();
1025
1026 let mut affected_entity_ids: std::collections::HashSet<i64> =
1027 std::collections::HashSet::new();
1028 for entity in &graph.entities {
1029 if let Some(eid) = entities::find_entity_id(&tx, &namespace, &entity.name)? {
1030 affected_entity_ids.insert(eid);
1031 }
1032 }
1033
1034 for rel in &graph.relationships {
1035 let source_entity = NewEntity {
1036 name: rel.source.clone(),
1037 entity_type: entity_types
1038 .get(rel.source.as_str())
1039 .copied()
1040 .unwrap_or(EntityType::Concept),
1041 description: None,
1042 };
1043 let target_entity = NewEntity {
1044 name: rel.target.clone(),
1045 entity_type: entity_types
1046 .get(rel.target.as_str())
1047 .copied()
1048 .unwrap_or(EntityType::Concept),
1049 description: None,
1050 };
1051 let source_id = entities::upsert_entity(&tx, &namespace, &source_entity)?;
1052 let target_id = entities::upsert_entity(&tx, &namespace, &target_entity)?;
1053 let rel_id = entities::upsert_relationship(&tx, &namespace, source_id, target_id, rel)?;
1054 entities::link_memory_relationship(&tx, memory_id, rel_id)?;
1055 affected_entity_ids.insert(source_id);
1056 affected_entity_ids.insert(target_id);
1057 relationships_persisted += 1;
1058 }
1059
1060 for &eid in &affected_entity_ids {
1061 entities::recalculate_degree(&tx, eid)?;
1062 }
1063 }
1064 tx.commit()?;
1065
1066 let chunks_persisted = storage_chunks::count_for_memory(&conn, memory_id)?;
1070
1071 if !new_memory.body.trim().is_empty() {
1076 let has_vec: bool = conn
1077 .query_row(
1078 "SELECT EXISTS(SELECT 1 FROM memory_embeddings WHERE memory_id = ?1)",
1079 rusqlite::params![memory_id],
1080 |r| r.get::<_, i64>(0).map(|v| v > 0),
1081 )
1082 .unwrap_or(false);
1083 if !has_vec {
1084 tracing::warn!(target: "remember",
1085 memory_id,
1086 name = %normalized_name,
1087 "memory persisted without an embedding vector; recall will be degraded until re-embedded"
1088 );
1089 warnings.push(
1090 "memory persisted without an embedding vector; run `enrich --operation re-embed` to make it searchable"
1091 .to_string(),
1092 );
1093 }
1094 }
1095
1096 if action == "updated" {
1101 crate::commands::enrich::cleanup_queue_entry(&paths.db, memory_id, &normalized_name);
1102 }
1103
1104 let urls_persisted = if !extracted_urls.is_empty() {
1107 let url_entries: Vec<storage_urls::MemoryUrl> = extracted_urls
1108 .into_iter()
1109 .map(|u| storage_urls::MemoryUrl {
1110 url: u.url,
1111 offset: Some(u.start as i64),
1112 })
1113 .collect();
1114 storage_urls::insert_urls(&conn, memory_id, &url_entries)
1115 } else {
1116 0
1117 };
1118
1119 conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
1120
1121 let created_at_epoch = chrono::Utc::now().timestamp();
1122 let created_at_iso = crate::tz::format_iso(chrono::Utc::now());
1123
1124 let entities_created: Vec<String> = graph
1126 .entities
1127 .iter()
1128 .map(|e| e.name.clone())
1129 .collect();
1130
1131 let mut enrich_recommended: Vec<String> = Vec::new();
1134 if entities_persisted > 0 {
1135 enrich_recommended.push("entity-descriptions".to_string());
1136 }
1137
1138 if args.enqueue_enrich && !entities_created.is_empty() {
1140 match crate::commands::enrich::enqueue_priority_entity_descriptions(
1141 &paths,
1142 &namespace,
1143 &entities_created,
1144 ) {
1145 Ok(n) => {
1146 tracing::info!(
1147 target: "remember",
1148 enqueued = n,
1149 "priority entity-descriptions enqueued"
1150 );
1151 }
1152 Err(e) => {
1153 warnings.push(format!(
1154 "enqueue_enrich failed (entities still listed in entities_created): {e}"
1155 ));
1156 }
1157 }
1158 }
1159
1160 output::emit_json(&RememberResponse {
1161 memory_id,
1162 name: normalized_name.clone(),
1166 namespace,
1167 action: action.clone(),
1168 operation: action,
1169 version,
1170 entities_persisted,
1171 relationships_persisted,
1172 relationships_truncated,
1173 chunks_created,
1174 chunks_persisted,
1175 urls_persisted,
1176 extraction_method,
1177 merged_into_memory_id: None,
1178 warnings,
1179 created_at: created_at_epoch,
1180 created_at_iso,
1181 elapsed_ms: inicio.elapsed().as_millis() as u64,
1182 name_was_normalized,
1183 original_name: name_was_normalized.then_some(original_name),
1184 backend_invoked: backend_invoked_passage,
1185 entities_created,
1186 enrich_recommended,
1187 })?;
1188
1189 Ok(())
1190}
1191
1192#[cfg(test)]
1193mod tests {
1194 use crate::output::RememberResponse;
1195
1196 fn strict_name_rejects(strict: bool, name_was_normalized: bool) -> bool {
1199 strict && name_was_normalized
1200 }
1201
1202 #[test]
1203 fn strict_name_rejects_only_when_name_would_change() {
1204 assert!(
1205 strict_name_rejects(true, true),
1206 "strict + changed must reject"
1207 );
1208 assert!(
1209 !strict_name_rejects(true, false),
1210 "strict + canonical passes"
1211 );
1212 assert!(
1213 !strict_name_rejects(false, true),
1214 "non-strict always passes"
1215 );
1216 assert!(!strict_name_rejects(false, false));
1217 }
1218
1219 #[test]
1221 fn remember_parses_strict_name_and_replace_graph_flags() {
1222 use crate::cli::{Cli, Commands};
1223 use clap::Parser;
1224 let cli = Cli::try_parse_from([
1225 "sqlite-graphrag",
1226 "remember",
1227 "--name",
1228 "my-mem",
1229 "--type",
1230 "note",
1231 "--description",
1232 "d",
1233 "--body",
1234 "b",
1235 "--strict-name",
1236 "--replace-graph",
1237 "--force-merge",
1238 ])
1239 .expect("parse");
1240 match cli.command {
1241 Some(Commands::Remember(a)) => {
1242 assert!(a.strict_name);
1243 assert!(a.replace_graph);
1244 assert!(a.force_merge);
1245 }
1246 other => panic!("expected remember, got {other:?}"),
1247 }
1248 }
1249
1250 #[test]
1251 fn remember_response_serializes_required_fields() {
1252 let resp = RememberResponse {
1253 memory_id: 42,
1254 name: "minha-mem".to_string(),
1255 namespace: "global".to_string(),
1256 action: "created".to_string(),
1257 operation: "created".to_string(),
1258 version: 1,
1259 entities_persisted: 0,
1260 relationships_persisted: 0,
1261 relationships_truncated: false,
1262 chunks_created: 1,
1263 chunks_persisted: 0,
1264 urls_persisted: 0,
1265 extraction_method: None,
1266 merged_into_memory_id: None,
1267 warnings: vec![],
1268 created_at: 1_705_320_000,
1269 created_at_iso: "2024-01-15T12:00:00Z".to_string(),
1270 elapsed_ms: 55,
1271 name_was_normalized: false,
1272 original_name: None,
1273 backend_invoked: None,
1274 entities_created: vec![],
1275 enrich_recommended: vec![],
1276 };
1277
1278 let json = serde_json::to_value(&resp).expect("serialization failed");
1279 assert_eq!(json["memory_id"], 42);
1280 assert_eq!(json["action"], "created");
1281 assert_eq!(json["operation"], "created");
1282 assert_eq!(json["version"], 1);
1283 assert_eq!(json["elapsed_ms"], 55u64);
1284 assert!(json["warnings"].is_array());
1285 assert!(json["merged_into_memory_id"].is_null());
1286 }
1287
1288 #[test]
1289 fn remember_response_action_e_operation_sao_aliases() {
1290 let resp = RememberResponse {
1291 memory_id: 1,
1292 name: "mem".to_string(),
1293 namespace: "global".to_string(),
1294 action: "updated".to_string(),
1295 operation: "updated".to_string(),
1296 version: 2,
1297 entities_persisted: 3,
1298 relationships_persisted: 1,
1299 relationships_truncated: false,
1300 extraction_method: None,
1301 chunks_created: 2,
1302 chunks_persisted: 2,
1303 urls_persisted: 0,
1304 merged_into_memory_id: None,
1305 warnings: vec![],
1306 created_at: 0,
1307 created_at_iso: "1970-01-01T00:00:00Z".to_string(),
1308 elapsed_ms: 0,
1309 name_was_normalized: false,
1310 original_name: None,
1311 backend_invoked: None,
1312 entities_created: vec![],
1313 enrich_recommended: vec![],
1314 };
1315
1316 let json = serde_json::to_value(&resp).expect("serialization failed");
1317 assert_eq!(
1318 json["action"], json["operation"],
1319 "action e operation devem ser iguais"
1320 );
1321 assert_eq!(json["entities_persisted"], 3);
1322 assert_eq!(json["relationships_persisted"], 1);
1323 assert_eq!(json["chunks_created"], 2);
1324 }
1325
1326 #[test]
1327 fn remember_response_warnings_lista_mensagens() {
1328 let resp = RememberResponse {
1329 memory_id: 5,
1330 name: "dup-mem".to_string(),
1331 namespace: "global".to_string(),
1332 action: "created".to_string(),
1333 operation: "created".to_string(),
1334 version: 1,
1335 entities_persisted: 0,
1336 extraction_method: None,
1337 relationships_persisted: 0,
1338 relationships_truncated: false,
1339 chunks_created: 1,
1340 chunks_persisted: 0,
1341 urls_persisted: 0,
1342 merged_into_memory_id: None,
1343 warnings: vec!["identical body already exists as memory id 3".to_string()],
1344 created_at: 0,
1345 created_at_iso: "1970-01-01T00:00:00Z".to_string(),
1346 elapsed_ms: 10,
1347 name_was_normalized: false,
1348 original_name: None,
1349 backend_invoked: None,
1350 entities_created: vec![],
1351 enrich_recommended: vec![],
1352 };
1353
1354 let json = serde_json::to_value(&resp).expect("serialization failed");
1355 let warnings = json["warnings"]
1356 .as_array()
1357 .expect("warnings deve ser array");
1358 assert_eq!(warnings.len(), 1);
1359 assert!(warnings[0].as_str().unwrap().contains("identical body"));
1360 }
1361
1362 #[test]
1363 fn invalid_name_reserved_prefix_returns_validation_error() {
1364 use crate::errors::AppError;
1365 let nome = "__reservado";
1367 let resultado: Result<(), AppError> = if nome.starts_with("__") {
1368 Err(AppError::Validation(
1369 crate::i18n::validation::reserved_name(),
1370 ))
1371 } else {
1372 Ok(())
1373 };
1374 assert!(resultado.is_err());
1375 if let Err(AppError::Validation(msg)) = resultado {
1376 assert!(!msg.is_empty());
1377 }
1378 }
1379
1380 #[test]
1381 fn name_too_long_returns_validation_error() {
1382 use crate::errors::AppError;
1383 let nome_longo = "a".repeat(crate::constants::MAX_MEMORY_NAME_LEN + 1);
1384 let resultado: Result<(), AppError> =
1385 if nome_longo.is_empty() || nome_longo.len() > crate::constants::MAX_MEMORY_NAME_LEN {
1386 Err(AppError::Validation(crate::i18n::validation::name_length(
1387 crate::constants::MAX_MEMORY_NAME_LEN,
1388 )))
1389 } else {
1390 Ok(())
1391 };
1392 assert!(resultado.is_err());
1393 }
1394
1395 #[test]
1396 fn remember_response_merged_into_memory_id_some_serializes_integer() {
1397 let resp = RememberResponse {
1398 memory_id: 10,
1399 name: "mem-mergeada".to_string(),
1400 namespace: "global".to_string(),
1401 action: "updated".to_string(),
1402 operation: "updated".to_string(),
1403 version: 3,
1404 extraction_method: None,
1405 entities_persisted: 0,
1406 relationships_persisted: 0,
1407 relationships_truncated: false,
1408 chunks_created: 1,
1409 chunks_persisted: 0,
1410 urls_persisted: 0,
1411 merged_into_memory_id: Some(7),
1412 warnings: vec![],
1413 created_at: 0,
1414 created_at_iso: "1970-01-01T00:00:00Z".to_string(),
1415 elapsed_ms: 0,
1416 name_was_normalized: false,
1417 original_name: None,
1418 backend_invoked: None,
1419 entities_created: vec![],
1420 enrich_recommended: vec![],
1421 };
1422
1423 let json = serde_json::to_value(&resp).expect("serialization failed");
1424 assert_eq!(json["merged_into_memory_id"], 7);
1425 }
1426
1427 #[test]
1428 fn remember_response_urls_persisted_serializes_field() {
1429 let resp = RememberResponse {
1431 memory_id: 3,
1432 name: "mem-com-urls".to_string(),
1433 namespace: "global".to_string(),
1434 action: "created".to_string(),
1435 operation: "created".to_string(),
1436 version: 1,
1437 entities_persisted: 0,
1438 relationships_persisted: 0,
1439 relationships_truncated: false,
1440 chunks_created: 1,
1441 chunks_persisted: 0,
1442 urls_persisted: 3,
1443 extraction_method: Some("regex-only".to_string()),
1444 merged_into_memory_id: None,
1445 warnings: vec![],
1446 created_at: 0,
1447 created_at_iso: "1970-01-01T00:00:00Z".to_string(),
1448 elapsed_ms: 0,
1449 name_was_normalized: false,
1450 original_name: None,
1451 backend_invoked: None,
1452 entities_created: vec![],
1453 enrich_recommended: vec![],
1454 };
1455 let json = serde_json::to_value(&resp).expect("serialization failed");
1456 assert_eq!(json["urls_persisted"], 3);
1457 }
1458
1459 #[test]
1460 fn empty_name_after_normalization_returns_specific_message() {
1461 use crate::errors::AppError;
1464 let normalized = "---".to_lowercase().replace(['_', ' '], "-");
1465 let normalized = normalized.trim_matches('-').to_string();
1466 let resultado: Result<(), AppError> = if normalized.is_empty() {
1467 Err(AppError::Validation(
1468 "name cannot be empty after normalization (input was blank or contained only hyphens/underscores/spaces)".to_string(),
1469 ))
1470 } else {
1471 Ok(())
1472 };
1473 assert!(resultado.is_err());
1474 if let Err(AppError::Validation(msg)) = resultado {
1475 assert!(
1476 msg.contains("empty after normalization"),
1477 "mensagem deve mencionar 'empty after normalization', obteve: {msg}"
1478 );
1479 }
1480 }
1481
1482 #[test]
1483 fn name_only_underscores_after_normalization_returns_specific_message() {
1484 use crate::errors::AppError;
1486 let normalized = "___".to_lowercase().replace(['_', ' '], "-");
1487 let normalized = normalized.trim_matches('-').to_string();
1488 assert!(
1489 normalized.is_empty(),
1490 "underscores devem normalizar para string vazia"
1491 );
1492 let resultado: Result<(), AppError> = if normalized.is_empty() {
1493 Err(AppError::Validation(
1494 "name cannot be empty after normalization (input was blank or contained only hyphens/underscores/spaces)".to_string(),
1495 ))
1496 } else {
1497 Ok(())
1498 };
1499 assert!(resultado.is_err());
1500 if let Err(AppError::Validation(msg)) = resultado {
1501 assert!(
1502 msg.contains("empty after normalization"),
1503 "mensagem deve mencionar 'empty after normalization', obteve: {msg}"
1504 );
1505 }
1506 }
1507
1508 #[test]
1509 fn remember_response_relationships_truncated_serializes_field() {
1510 let resp_false = RememberResponse {
1512 memory_id: 1,
1513 name: "test".to_string(),
1514 namespace: "global".to_string(),
1515 action: "created".to_string(),
1516 operation: "created".to_string(),
1517 version: 1,
1518 entities_persisted: 2,
1519 relationships_persisted: 1,
1520 relationships_truncated: false,
1521 chunks_created: 1,
1522 chunks_persisted: 0,
1523 urls_persisted: 0,
1524 extraction_method: None,
1525 merged_into_memory_id: None,
1526 warnings: vec![],
1527 created_at: 0,
1528 created_at_iso: "1970-01-01T00:00:00Z".to_string(),
1529 elapsed_ms: 0,
1530 name_was_normalized: false,
1531 original_name: None,
1532 backend_invoked: None,
1533 entities_created: vec![],
1534 enrich_recommended: vec![],
1535 };
1536 let json_false = serde_json::to_value(&resp_false).expect("serialization failed");
1537 assert_eq!(json_false["relationships_truncated"], false);
1538
1539 let resp_true = RememberResponse {
1540 relationships_truncated: true,
1541 ..resp_false
1542 };
1543 let json_true = serde_json::to_value(&resp_true).expect("serialization failed");
1544 assert_eq!(json_true["relationships_truncated"], true);
1545 }
1546
1547 fn should_preserve_body(force_merge: bool, raw_body_is_empty: bool, clear_body: bool) -> bool {
1556 force_merge && raw_body_is_empty && !clear_body
1557 }
1558
1559 #[test]
1560 fn gap08_empty_body_force_merge_no_clear_body_preserves() {
1561 assert!(
1564 should_preserve_body(true, true, false),
1565 "empty body + force-merge + no clear-body should trigger preservation"
1566 );
1567 }
1568
1569 #[test]
1570 fn gap08_empty_body_force_merge_with_clear_body_does_not_preserve() {
1571 assert!(
1573 !should_preserve_body(true, true, true),
1574 "--clear-body must bypass preservation"
1575 );
1576 }
1577
1578 #[test]
1579 fn gap08_non_empty_body_force_merge_does_not_preserve() {
1580 assert!(
1582 !should_preserve_body(true, false, false),
1583 "non-empty body must overwrite, not preserve"
1584 );
1585 }
1586
1587 #[test]
1588 fn gap08_empty_body_no_force_merge_does_not_preserve() {
1589 assert!(
1591 !should_preserve_body(false, true, false),
1592 "no --force-merge means no preservation logic applies"
1593 );
1594 }
1595}