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
17fn compute_chunks_persisted(chunks_created: usize) -> usize {
26 if chunks_created > 1 {
27 chunks_created
28 } else {
29 0
30 }
31}
32
33#[derive(clap::Args)]
34#[command(after_long_help = "EXAMPLES:\n \
35 # Create a memory with inline body\n \
36 sqlite-graphrag remember --name design-auth --type decision \\\n \
37 --description \"auth design\" --body \"JWT for stateless auth\"\n\n \
38 # Create with curated graph via --graph-stdin\n \
39 echo '{\"body\":\"...\",\"entities\":[],\"relationships\":[]}' | \\\n \
40 sqlite-graphrag remember --name my-mem --type note --description \"desc\" --graph-stdin\n\n \
41 # Enable GLiNER NER extraction with --graph-stdin\n \
42 echo '{\"body\":\"Alice from Microsoft...\",\"entities\":[],\"relationships\":[]}' | \\\n \
43 sqlite-graphrag remember --name ner-test --type note --description \"test\" \\\n \
44 --graph-stdin --enable-ner --gliner-variant int8\n\n \
45 # Idempotent upsert with --force-merge\n \
46 sqlite-graphrag remember --name my-mem --type note --description \"updated\" \\\n \
47 --body \"new content\" --force-merge")]
48pub struct RememberArgs {
49 #[arg(long)]
52 pub name: String,
53 #[arg(
54 long,
55 value_enum,
56 long_help = "Memory kind stored in `memories.type`. This is NOT the graph `entity_type` used in `--entities-file`. Valid values: user, feedback, project, reference, decision, incident, skill, document, note."
57 )]
58 pub r#type: MemoryType,
59 #[arg(long)]
61 pub description: String,
62 #[arg(
65 long,
66 help = "Inline body content (max 500 KB / 512000 bytes; for larger inputs split into multiple memories or use --body-file)",
67 conflicts_with_all = ["body_file", "body_stdin", "graph_stdin"]
68 )]
69 pub body: Option<String>,
70 #[arg(
71 long,
72 help = "Read body from a file instead of --body",
73 conflicts_with_all = ["body", "body_stdin", "graph_stdin"]
74 )]
75 pub body_file: Option<std::path::PathBuf>,
76 #[arg(
79 long,
80 conflicts_with_all = ["body", "body_file", "graph_stdin"]
81 )]
82 pub body_stdin: bool,
83 #[arg(
84 long,
85 help = "JSON file containing entities to associate with this memory"
86 )]
87 pub entities_file: Option<std::path::PathBuf>,
88 #[arg(
89 long,
90 help = "JSON file containing relationships to associate with this memory"
91 )]
92 pub relationships_file: Option<std::path::PathBuf>,
93 #[arg(
94 long,
95 help = "Read graph JSON (body + entities + relationships) from stdin",
96 conflicts_with_all = [
97 "body",
98 "body_file",
99 "body_stdin",
100 "entities_file",
101 "relationships_file"
102 ]
103 )]
104 pub graph_stdin: bool,
105 #[arg(
106 long,
107 help = "Namespace (env: SQLITE_GRAPHRAG_NAMESPACE, default: global)"
108 )]
109 pub namespace: Option<String>,
110 #[arg(long)]
112 pub metadata: Option<String>,
113 #[arg(long, help = "JSON file containing metadata key-value pairs")]
114 pub metadata_file: Option<std::path::PathBuf>,
115 #[arg(long)]
116 pub force_merge: bool,
117 #[arg(
118 long,
119 value_name = "EPOCH_OR_RFC3339",
120 value_parser = crate::parsers::parse_expected_updated_at,
121 long_help = "Optimistic lock: reject if updated_at does not match. \
122Accepts Unix epoch (e.g. 1700000000) or RFC 3339 (e.g. 2026-04-19T12:00:00Z)."
123 )]
124 pub expected_updated_at: Option<i64>,
125 #[arg(
126 long,
127 env = "SQLITE_GRAPHRAG_ENABLE_NER",
128 value_parser = crate::parsers::parse_bool_flexible,
129 action = clap::ArgAction::Set,
130 num_args = 0..=1,
131 default_missing_value = "true",
132 default_value = "false",
133 help = "Enable automatic GLiNER NER entity/relationship extraction from body"
134 )]
135 pub enable_ner: bool,
136 #[arg(
137 long,
138 env = "SQLITE_GRAPHRAG_GLINER_VARIANT",
139 default_value = "fp32",
140 help = "GLiNER model variant: fp32 (1.1GB, best quality), fp16 (580MB), int8 (349MB, fastest but may miss entities on short texts), q4, q4f16"
141 )]
142 pub gliner_variant: String,
143 #[arg(long, hide = true)]
144 pub skip_extraction: bool,
145 #[arg(long)]
147 pub session_id: Option<String>,
148 #[arg(long, value_enum, default_value_t = JsonOutputFormat::Json)]
149 pub format: JsonOutputFormat,
150 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
151 pub json: bool,
152 #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
153 pub db: Option<String>,
154 #[arg(long, default_value_t = crate::constants::DEFAULT_MAX_RSS_MB,
156 help = "Maximum process RSS in MiB; abort if exceeded during embedding (default: 8192)")]
157 pub max_rss_mb: u64,
158}
159
160#[derive(Deserialize, Default)]
161#[serde(deny_unknown_fields)]
162struct GraphInput {
163 #[serde(default)]
164 body: Option<String>,
165 #[serde(default)]
166 entities: Vec<NewEntity>,
167 #[serde(default)]
168 relationships: Vec<NewRelationship>,
169}
170
171fn normalize_and_validate_graph_input(graph: &mut GraphInput) -> Result<(), AppError> {
172 for rel in &mut graph.relationships {
173 rel.relation = crate::parsers::normalize_relation(&rel.relation);
174 if let Err(e) = crate::parsers::validate_relation_format(&rel.relation) {
175 return Err(AppError::Validation(format!(
176 "{e} for relationship '{}' -> '{}'",
177 rel.source, rel.target
178 )));
179 }
180 crate::parsers::warn_if_non_canonical(&rel.relation);
181 if !(0.0..=1.0).contains(&rel.strength) {
182 return Err(AppError::Validation(format!(
183 "invalid strength {} for relationship '{}' -> '{}'; expected value in [0.0, 1.0]",
184 rel.strength, rel.source, rel.target
185 )));
186 }
187 }
188
189 Ok(())
190}
191
192pub fn run(args: RememberArgs) -> Result<(), AppError> {
193 use crate::constants::*;
194
195 let inicio = std::time::Instant::now();
196 let _ = args.format;
197 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
198
199 let original_name = args.name.clone();
203
204 let normalized_name = {
208 let lower = args.name.to_lowercase().replace(['_', ' '], "-");
209 let trimmed = lower.trim_matches('-').to_string();
210 if trimmed != args.name {
211 tracing::warn!(
212 original = %args.name,
213 normalized = %trimmed,
214 "name auto-normalized to kebab-case"
215 );
216 }
217 trimmed
218 };
219 let name_was_normalized = normalized_name != original_name;
220
221 if normalized_name.is_empty() {
222 return Err(AppError::Validation(
223 "name cannot be empty after normalization (input was blank or contained only hyphens/underscores/spaces)".to_string(),
224 ));
225 }
226 if normalized_name.len() > MAX_MEMORY_NAME_LEN {
227 return Err(AppError::LimitExceeded(
228 crate::i18n::validation::name_length(MAX_MEMORY_NAME_LEN),
229 ));
230 }
231
232 if normalized_name.starts_with("__") {
233 return Err(AppError::Validation(
234 crate::i18n::validation::reserved_name(),
235 ));
236 }
237
238 {
239 let slug_re = regex::Regex::new(crate::constants::NAME_SLUG_REGEX)
240 .map_err(|e| AppError::Internal(anyhow::anyhow!("regex: {e}")))?;
241 if !slug_re.is_match(&normalized_name) {
242 return Err(AppError::Validation(crate::i18n::validation::name_kebab(
243 &normalized_name,
244 )));
245 }
246 }
247
248 if args.description.len() > MAX_MEMORY_DESCRIPTION_LEN {
249 return Err(AppError::Validation(
250 crate::i18n::validation::description_exceeds(MAX_MEMORY_DESCRIPTION_LEN),
251 ));
252 }
253
254 let mut raw_body = if let Some(b) = args.body {
255 b
256 } else if let Some(path) = args.body_file {
257 std::fs::read_to_string(&path).map_err(AppError::Io)?
258 } else if args.body_stdin || args.graph_stdin {
259 crate::stdin_helper::read_stdin_with_timeout(60)?
260 } else {
261 String::new()
262 };
263
264 let entities_provided_externally =
265 args.entities_file.is_some() || args.relationships_file.is_some() || args.graph_stdin;
266
267 let mut graph = GraphInput::default();
268 if let Some(path) = args.entities_file {
269 let content = std::fs::read_to_string(&path).map_err(AppError::Io)?;
270 graph.entities = serde_json::from_str(&content)?;
271 }
272 if let Some(path) = args.relationships_file {
273 let content = std::fs::read_to_string(&path).map_err(AppError::Io)?;
274 graph.relationships = serde_json::from_str(&content)?;
275 }
276 if args.graph_stdin {
277 graph = serde_json::from_str::<GraphInput>(&raw_body).map_err(|e| {
278 AppError::Validation(format!("invalid JSON payload on --graph-stdin: {e}"))
279 })?;
280 raw_body = graph.body.take().unwrap_or_default();
281 }
282
283 if graph.entities.len() > max_entities_per_memory() {
284 return Err(AppError::LimitExceeded(errors_msg::entity_limit_exceeded(
285 max_entities_per_memory(),
286 )));
287 }
288 if graph.relationships.len() > MAX_RELATIONSHIPS_PER_MEMORY {
289 return Err(AppError::LimitExceeded(
290 errors_msg::relationship_limit_exceeded(MAX_RELATIONSHIPS_PER_MEMORY),
291 ));
292 }
293 normalize_and_validate_graph_input(&mut graph)?;
294
295 if raw_body.len() > MAX_MEMORY_BODY_LEN {
296 return Err(AppError::LimitExceeded(
297 crate::i18n::validation::body_exceeds(MAX_MEMORY_BODY_LEN),
298 ));
299 }
300
301 if !entities_provided_externally && graph.entities.is_empty() && raw_body.trim().is_empty() {
304 return Err(AppError::Validation(crate::i18n::validation::empty_body()));
305 }
306
307 let metadata: serde_json::Value = if let Some(m) = args.metadata {
308 serde_json::from_str(&m)?
309 } else if let Some(path) = args.metadata_file {
310 let content = std::fs::read_to_string(&path).map_err(AppError::Io)?;
311 serde_json::from_str(&content)?
312 } else {
313 serde_json::json!({})
314 };
315
316 let body_hash = blake3::hash(raw_body.as_bytes()).to_hex().to_string();
317 let snippet: String = raw_body.chars().take(200).collect();
318
319 let paths = AppPaths::resolve(args.db.as_deref())?;
320 paths.ensure_dirs()?;
321
322 let mut extraction_method: Option<String> = None;
324 let mut extracted_urls: Vec<crate::extraction::ExtractedUrl> = Vec::new();
325 let mut relationships_truncated = false;
326 if args.enable_ner && args.skip_extraction {
327 tracing::warn!(
328 "--enable-ner and --skip-extraction are contradictory; --enable-ner takes precedence"
329 );
330 }
331 if args.skip_extraction && !args.enable_ner {
332 tracing::warn!("--skip-extraction is deprecated and has no effect (NER is disabled by default since v1.0.45); remove this flag");
333 }
334 let gliner_variant: crate::extraction::GlinerVariant =
335 args.gliner_variant.parse().unwrap_or_else(|e| {
336 tracing::warn!("invalid --gliner-variant: {e}; using fp32");
337 crate::extraction::GlinerVariant::Fp32
338 });
339 if args.enable_ner && graph.entities.is_empty() && !raw_body.trim().is_empty() {
340 match crate::extraction::extract_graph_auto(&raw_body, &paths, gliner_variant) {
341 Ok(extracted) => {
342 extraction_method = Some(extracted.extraction_method.clone());
343 extracted_urls = extracted.urls;
344 graph.entities = extracted.entities;
345 graph.relationships = extracted.relationships;
346 relationships_truncated = extracted.relationships_truncated;
347
348 if graph.entities.len() > max_entities_per_memory() {
349 graph.entities.truncate(max_entities_per_memory());
350 }
351 if graph.relationships.len() > MAX_RELATIONSHIPS_PER_MEMORY {
352 relationships_truncated = true;
353 graph.relationships.truncate(MAX_RELATIONSHIPS_PER_MEMORY);
354 }
355 normalize_and_validate_graph_input(&mut graph)?;
356 }
357 Err(e) => {
358 tracing::warn!("auto-extraction failed (graceful degradation): {e:#}");
359 extraction_method = Some("none:extraction-failed".to_string());
360 }
361 }
362 }
363
364 let mut conn = open_rw(&paths.db)?;
365 ensure_schema(&mut conn)?;
366
367 {
368 use crate::constants::MAX_NAMESPACES_ACTIVE;
369 let active_count: u32 = conn.query_row(
370 "SELECT COUNT(DISTINCT namespace) FROM memories WHERE deleted_at IS NULL",
371 [],
372 |r| r.get::<_, i64>(0).map(|v| v as u32),
373 )?;
374 let ns_exists: bool = conn.query_row(
375 "SELECT EXISTS(SELECT 1 FROM memories WHERE namespace = ?1 AND deleted_at IS NULL)",
376 rusqlite::params![namespace],
377 |r| r.get::<_, i64>(0).map(|v| v > 0),
378 )?;
379 if !ns_exists && active_count >= MAX_NAMESPACES_ACTIVE {
380 return Err(AppError::NamespaceError(format!(
381 "active namespace limit of {MAX_NAMESPACES_ACTIVE} reached while trying to create '{namespace}'"
382 )));
383 }
384 }
385
386 if let Some((sd_id, true)) =
388 memories::find_by_name_any_state(&conn, &namespace, &normalized_name)?
389 {
390 if args.force_merge {
391 memories::clear_deleted_at(&conn, sd_id)?;
392 } else {
393 return Err(AppError::Duplicate(
394 errors_msg::duplicate_memory_soft_deleted(&normalized_name, &namespace),
395 ));
396 }
397 }
398
399 let existing_memory = memories::find_by_name(&conn, &namespace, &normalized_name)?;
400 if existing_memory.is_some() && !args.force_merge {
401 return Err(AppError::Duplicate(errors_msg::duplicate_memory(
402 &normalized_name,
403 &namespace,
404 )));
405 }
406
407 let duplicate_hash_id = memories::find_by_hash(&conn, &namespace, &body_hash)?;
408
409 output::emit_progress_i18n(
410 &format!(
411 "Remember stage: validated input; available memory {} MB",
412 crate::memory_guard::available_memory_mb()
413 ),
414 &format!(
415 "Stage remember: input validated; available memory {} MB",
416 crate::memory_guard::available_memory_mb()
417 ),
418 );
419
420 let tokenizer = crate::tokenizer::get_tokenizer(&paths.models)?;
421 let model_max_length = crate::tokenizer::get_model_max_length(&paths.models)?;
422 let total_passage_tokens = crate::tokenizer::count_passage_tokens(tokenizer, &raw_body)?;
423 let chunks_info = chunking::split_into_chunks_hierarchical(&raw_body, tokenizer);
424 let chunks_created = chunks_info.len();
425 let chunks_persisted = compute_chunks_persisted(chunks_info.len());
429
430 output::emit_progress_i18n(
431 &format!(
432 "Remember stage: tokenizer counted {total_passage_tokens} passage tokens (model max {model_max_length}); chunking produced {} chunks; process RSS {} MB",
433 chunks_created,
434 crate::memory_guard::current_process_memory_mb().unwrap_or(0)
435 ),
436 &format!(
437 "Stage remember: tokenizer counted {total_passage_tokens} passage tokens (model max {model_max_length}); chunking produced {} chunks; process RSS {} MB",
438 chunks_created,
439 crate::memory_guard::current_process_memory_mb().unwrap_or(0)
440 ),
441 );
442
443 if chunks_created > crate::constants::REMEMBER_MAX_SAFE_MULTI_CHUNKS {
444 return Err(AppError::LimitExceeded(format!(
445 "document produces {chunks_created} chunks; current safe operational limit is {} chunks; split the document before using remember",
446 crate::constants::REMEMBER_MAX_SAFE_MULTI_CHUNKS
447 )));
448 }
449
450 output::emit_progress_i18n("Computing embedding...", "Calculando embedding...");
451 let mut chunk_embeddings_cache: Option<Vec<Vec<f32>>> = None;
452
453 let embedding = if chunks_info.len() == 1 {
454 crate::daemon::embed_passage_or_local(&paths.models, &raw_body)?
455 } else {
456 let chunk_texts: Vec<&str> = chunks_info
457 .iter()
458 .map(|c| chunking::chunk_text(&raw_body, c))
459 .collect();
460 output::emit_progress_i18n(
461 &format!(
462 "Embedding {} chunks serially to keep memory bounded...",
463 chunks_info.len()
464 ),
465 &format!(
466 "Embedding {} chunks serially to keep memory bounded...",
467 chunks_info.len()
468 ),
469 );
470 let mut chunk_embeddings = Vec::with_capacity(chunk_texts.len());
471 for chunk_text in &chunk_texts {
472 if let Some(rss) = crate::memory_guard::current_process_memory_mb() {
473 if rss > args.max_rss_mb {
474 tracing::error!(
475 rss_mb = rss,
476 max_rss_mb = args.max_rss_mb,
477 "RSS exceeded --max-rss-mb threshold; aborting to prevent system instability"
478 );
479 return Err(AppError::LowMemory {
480 available_mb: crate::memory_guard::available_memory_mb(),
481 required_mb: args.max_rss_mb,
482 });
483 }
484 }
485 chunk_embeddings.push(crate::daemon::embed_passage_or_local(
486 &paths.models,
487 chunk_text,
488 )?);
489 }
490 output::emit_progress_i18n(
491 &format!(
492 "Remember stage: chunk embeddings complete; process RSS {} MB",
493 crate::memory_guard::current_process_memory_mb().unwrap_or(0)
494 ),
495 &format!(
496 "Stage remember: chunk embeddings completed; process RSS {} MB",
497 crate::memory_guard::current_process_memory_mb().unwrap_or(0)
498 ),
499 );
500 let aggregated = chunking::aggregate_embeddings(&chunk_embeddings);
501 chunk_embeddings_cache = Some(chunk_embeddings);
502 aggregated
503 };
504 let body_for_storage = raw_body;
505
506 let memory_type = args.r#type.as_str();
507 let new_memory = NewMemory {
508 namespace: namespace.clone(),
509 name: normalized_name.clone(),
510 memory_type: memory_type.to_string(),
511 description: args.description.clone(),
512 body: body_for_storage,
513 body_hash: body_hash.clone(),
514 session_id: args.session_id.clone(),
515 source: "agent".to_string(),
516 metadata,
517 };
518
519 let mut warnings = Vec::new();
520 let mut entities_persisted = 0usize;
521 let mut relationships_persisted = 0usize;
522
523 let graph_entity_embeddings = graph
524 .entities
525 .iter()
526 .map(|entity| {
527 let entity_text = match &entity.description {
528 Some(desc) => format!("{} {}", entity.name, desc),
529 None => entity.name.clone(),
530 };
531 crate::daemon::embed_passage_or_local(&paths.models, &entity_text)
532 })
533 .collect::<Result<Vec<_>, _>>()?;
534
535 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
536
537 let (memory_id, action, version) = match existing_memory {
538 Some((existing_id, _updated_at, _current_version)) => {
539 if let Some(hash_id) = duplicate_hash_id {
540 if hash_id != existing_id {
541 warnings.push(format!(
542 "identical body already exists as memory id {hash_id}"
543 ));
544 }
545 }
546
547 storage_chunks::delete_chunks(&tx, existing_id)?;
548
549 let next_v = versions::next_version(&tx, existing_id)?;
550 memories::update(&tx, existing_id, &new_memory, args.expected_updated_at)?;
551 versions::insert_version(
552 &tx,
553 existing_id,
554 next_v,
555 &normalized_name,
556 memory_type,
557 &args.description,
558 &new_memory.body,
559 &serde_json::to_string(&new_memory.metadata)?,
560 None,
561 "edit",
562 )?;
563 memories::upsert_vec(
564 &tx,
565 existing_id,
566 &namespace,
567 memory_type,
568 &embedding,
569 &normalized_name,
570 &snippet,
571 )?;
572 (existing_id, "updated".to_string(), next_v)
573 }
574 None => {
575 if let Some(hash_id) = duplicate_hash_id {
576 warnings.push(format!(
577 "identical body already exists as memory id {hash_id}"
578 ));
579 }
580 let id = memories::insert(&tx, &new_memory)?;
581 versions::insert_version(
582 &tx,
583 id,
584 1,
585 &normalized_name,
586 memory_type,
587 &args.description,
588 &new_memory.body,
589 &serde_json::to_string(&new_memory.metadata)?,
590 None,
591 "create",
592 )?;
593 memories::upsert_vec(
594 &tx,
595 id,
596 &namespace,
597 memory_type,
598 &embedding,
599 &normalized_name,
600 &snippet,
601 )?;
602 (id, "created".to_string(), 1)
603 }
604 };
605
606 if chunks_info.len() > 1 {
607 storage_chunks::insert_chunk_slices(&tx, memory_id, &new_memory.body, &chunks_info)?;
608
609 let chunk_embeddings = chunk_embeddings_cache.take().ok_or_else(|| {
610 AppError::Internal(anyhow::anyhow!(
611 "chunk embeddings cache missing in multi-chunk remember path"
612 ))
613 })?;
614
615 for (i, emb) in chunk_embeddings.iter().enumerate() {
616 storage_chunks::upsert_chunk_vec(&tx, i as i64, memory_id, i as i32, emb)?;
617 }
618 output::emit_progress_i18n(
619 &format!(
620 "Remember stage: persisted chunk vectors; process RSS {} MB",
621 crate::memory_guard::current_process_memory_mb().unwrap_or(0)
622 ),
623 &format!(
624 "Etapa remember: vetores de chunks persistidos; RSS do processo {} MB",
625 crate::memory_guard::current_process_memory_mb().unwrap_or(0)
626 ),
627 );
628 }
629
630 if !graph.entities.is_empty() || !graph.relationships.is_empty() {
631 for entity in &graph.entities {
632 let entity_id = entities::upsert_entity(&tx, &namespace, entity)?;
633 let entity_embedding = &graph_entity_embeddings[entities_persisted];
634 entities::upsert_entity_vec(
635 &tx,
636 entity_id,
637 &namespace,
638 entity.entity_type,
639 entity_embedding,
640 &entity.name,
641 )?;
642 entities::link_memory_entity(&tx, memory_id, entity_id)?;
643 entities::increment_degree(&tx, entity_id)?;
644 entities_persisted += 1;
645 }
646 let entity_types: std::collections::HashMap<&str, EntityType> = graph
647 .entities
648 .iter()
649 .map(|entity| (entity.name.as_str(), entity.entity_type))
650 .collect();
651
652 for rel in &graph.relationships {
653 let source_entity = NewEntity {
654 name: rel.source.clone(),
655 entity_type: entity_types
656 .get(rel.source.as_str())
657 .copied()
658 .unwrap_or(EntityType::Concept),
659 description: None,
660 };
661 let target_entity = NewEntity {
662 name: rel.target.clone(),
663 entity_type: entity_types
664 .get(rel.target.as_str())
665 .copied()
666 .unwrap_or(EntityType::Concept),
667 description: None,
668 };
669 let source_id = entities::upsert_entity(&tx, &namespace, &source_entity)?;
670 let target_id = entities::upsert_entity(&tx, &namespace, &target_entity)?;
671 let rel_id = entities::upsert_relationship(&tx, &namespace, source_id, target_id, rel)?;
672 entities::link_memory_relationship(&tx, memory_id, rel_id)?;
673 relationships_persisted += 1;
674 }
675 }
676 tx.commit()?;
677
678 let urls_persisted = if !extracted_urls.is_empty() {
681 let url_entries: Vec<storage_urls::MemoryUrl> = extracted_urls
682 .into_iter()
683 .map(|u| storage_urls::MemoryUrl {
684 url: u.url,
685 offset: Some(u.offset as i64),
686 })
687 .collect();
688 storage_urls::insert_urls(&conn, memory_id, &url_entries)
689 } else {
690 0
691 };
692
693 let created_at_epoch = chrono::Utc::now().timestamp();
694 let created_at_iso = crate::tz::format_iso(chrono::Utc::now());
695
696 output::emit_json(&RememberResponse {
697 memory_id,
698 name: normalized_name.clone(),
702 namespace,
703 action: action.clone(),
704 operation: action,
705 version,
706 entities_persisted,
707 relationships_persisted,
708 relationships_truncated,
709 chunks_created,
710 chunks_persisted,
711 urls_persisted,
712 extraction_method,
713 merged_into_memory_id: None,
714 warnings,
715 created_at: created_at_epoch,
716 created_at_iso,
717 elapsed_ms: inicio.elapsed().as_millis() as u64,
718 name_was_normalized,
719 original_name: name_was_normalized.then_some(original_name),
720 })?;
721
722 Ok(())
723}
724
725#[cfg(test)]
726mod tests {
727 use super::compute_chunks_persisted;
728 use crate::output::RememberResponse;
729
730 #[test]
732 fn chunks_persisted_zero_for_zero_chunks() {
733 assert_eq!(compute_chunks_persisted(0), 0);
734 }
735
736 #[test]
737 fn chunks_persisted_zero_for_single_chunk_body() {
738 assert_eq!(compute_chunks_persisted(1), 0);
741 }
742
743 #[test]
744 fn chunks_persisted_equals_count_for_multi_chunk_body() {
745 assert_eq!(compute_chunks_persisted(2), 2);
747 assert_eq!(compute_chunks_persisted(7), 7);
748 assert_eq!(compute_chunks_persisted(64), 64);
749 }
750
751 #[test]
752 fn remember_response_serializes_required_fields() {
753 let resp = RememberResponse {
754 memory_id: 42,
755 name: "minha-mem".to_string(),
756 namespace: "global".to_string(),
757 action: "created".to_string(),
758 operation: "created".to_string(),
759 version: 1,
760 entities_persisted: 0,
761 relationships_persisted: 0,
762 relationships_truncated: false,
763 chunks_created: 1,
764 chunks_persisted: 0,
765 urls_persisted: 0,
766 extraction_method: None,
767 merged_into_memory_id: None,
768 warnings: vec![],
769 created_at: 1_705_320_000,
770 created_at_iso: "2024-01-15T12:00:00Z".to_string(),
771 elapsed_ms: 55,
772 name_was_normalized: false,
773 original_name: None,
774 };
775
776 let json = serde_json::to_value(&resp).expect("serialization failed");
777 assert_eq!(json["memory_id"], 42);
778 assert_eq!(json["action"], "created");
779 assert_eq!(json["operation"], "created");
780 assert_eq!(json["version"], 1);
781 assert_eq!(json["elapsed_ms"], 55u64);
782 assert!(json["warnings"].is_array());
783 assert!(json["merged_into_memory_id"].is_null());
784 }
785
786 #[test]
787 fn remember_response_action_e_operation_sao_aliases() {
788 let resp = RememberResponse {
789 memory_id: 1,
790 name: "mem".to_string(),
791 namespace: "global".to_string(),
792 action: "updated".to_string(),
793 operation: "updated".to_string(),
794 version: 2,
795 entities_persisted: 3,
796 relationships_persisted: 1,
797 relationships_truncated: false,
798 extraction_method: None,
799 chunks_created: 2,
800 chunks_persisted: 2,
801 urls_persisted: 0,
802 merged_into_memory_id: None,
803 warnings: vec![],
804 created_at: 0,
805 created_at_iso: "1970-01-01T00:00:00Z".to_string(),
806 elapsed_ms: 0,
807 name_was_normalized: false,
808 original_name: None,
809 };
810
811 let json = serde_json::to_value(&resp).expect("serialization failed");
812 assert_eq!(
813 json["action"], json["operation"],
814 "action e operation devem ser iguais"
815 );
816 assert_eq!(json["entities_persisted"], 3);
817 assert_eq!(json["relationships_persisted"], 1);
818 assert_eq!(json["chunks_created"], 2);
819 }
820
821 #[test]
822 fn remember_response_warnings_lista_mensagens() {
823 let resp = RememberResponse {
824 memory_id: 5,
825 name: "dup-mem".to_string(),
826 namespace: "global".to_string(),
827 action: "created".to_string(),
828 operation: "created".to_string(),
829 version: 1,
830 entities_persisted: 0,
831 extraction_method: None,
832 relationships_persisted: 0,
833 relationships_truncated: false,
834 chunks_created: 1,
835 chunks_persisted: 0,
836 urls_persisted: 0,
837 merged_into_memory_id: None,
838 warnings: vec!["identical body already exists as memory id 3".to_string()],
839 created_at: 0,
840 created_at_iso: "1970-01-01T00:00:00Z".to_string(),
841 elapsed_ms: 10,
842 name_was_normalized: false,
843 original_name: None,
844 };
845
846 let json = serde_json::to_value(&resp).expect("serialization failed");
847 let warnings = json["warnings"]
848 .as_array()
849 .expect("warnings deve ser array");
850 assert_eq!(warnings.len(), 1);
851 assert!(warnings[0].as_str().unwrap().contains("identical body"));
852 }
853
854 #[test]
855 fn invalid_name_reserved_prefix_returns_validation_error() {
856 use crate::errors::AppError;
857 let nome = "__reservado";
859 let resultado: Result<(), AppError> = if nome.starts_with("__") {
860 Err(AppError::Validation(
861 crate::i18n::validation::reserved_name(),
862 ))
863 } else {
864 Ok(())
865 };
866 assert!(resultado.is_err());
867 if let Err(AppError::Validation(msg)) = resultado {
868 assert!(!msg.is_empty());
869 }
870 }
871
872 #[test]
873 fn name_too_long_returns_validation_error() {
874 use crate::errors::AppError;
875 let nome_longo = "a".repeat(crate::constants::MAX_MEMORY_NAME_LEN + 1);
876 let resultado: Result<(), AppError> =
877 if nome_longo.is_empty() || nome_longo.len() > crate::constants::MAX_MEMORY_NAME_LEN {
878 Err(AppError::Validation(crate::i18n::validation::name_length(
879 crate::constants::MAX_MEMORY_NAME_LEN,
880 )))
881 } else {
882 Ok(())
883 };
884 assert!(resultado.is_err());
885 }
886
887 #[test]
888 fn remember_response_merged_into_memory_id_some_serializes_integer() {
889 let resp = RememberResponse {
890 memory_id: 10,
891 name: "mem-mergeada".to_string(),
892 namespace: "global".to_string(),
893 action: "updated".to_string(),
894 operation: "updated".to_string(),
895 version: 3,
896 extraction_method: None,
897 entities_persisted: 0,
898 relationships_persisted: 0,
899 relationships_truncated: false,
900 chunks_created: 1,
901 chunks_persisted: 0,
902 urls_persisted: 0,
903 merged_into_memory_id: Some(7),
904 warnings: vec![],
905 created_at: 0,
906 created_at_iso: "1970-01-01T00:00:00Z".to_string(),
907 elapsed_ms: 0,
908 name_was_normalized: false,
909 original_name: None,
910 };
911
912 let json = serde_json::to_value(&resp).expect("serialization failed");
913 assert_eq!(json["merged_into_memory_id"], 7);
914 }
915
916 #[test]
917 fn remember_response_urls_persisted_serializes_field() {
918 let resp = RememberResponse {
920 memory_id: 3,
921 name: "mem-com-urls".to_string(),
922 namespace: "global".to_string(),
923 action: "created".to_string(),
924 operation: "created".to_string(),
925 version: 1,
926 entities_persisted: 0,
927 relationships_persisted: 0,
928 relationships_truncated: false,
929 chunks_created: 1,
930 chunks_persisted: 0,
931 urls_persisted: 3,
932 extraction_method: Some("regex-only".to_string()),
933 merged_into_memory_id: None,
934 warnings: vec![],
935 created_at: 0,
936 created_at_iso: "1970-01-01T00:00:00Z".to_string(),
937 elapsed_ms: 0,
938 name_was_normalized: false,
939 original_name: None,
940 };
941 let json = serde_json::to_value(&resp).expect("serialization failed");
942 assert_eq!(json["urls_persisted"], 3);
943 }
944
945 #[test]
946 fn empty_name_after_normalization_returns_specific_message() {
947 use crate::errors::AppError;
950 let normalized = "---".to_lowercase().replace(['_', ' '], "-");
951 let normalized = normalized.trim_matches('-').to_string();
952 let resultado: Result<(), AppError> = if normalized.is_empty() {
953 Err(AppError::Validation(
954 "name cannot be empty after normalization (input was blank or contained only hyphens/underscores/spaces)".to_string(),
955 ))
956 } else {
957 Ok(())
958 };
959 assert!(resultado.is_err());
960 if let Err(AppError::Validation(msg)) = resultado {
961 assert!(
962 msg.contains("empty after normalization"),
963 "mensagem deve mencionar 'empty after normalization', obteve: {msg}"
964 );
965 }
966 }
967
968 #[test]
969 fn name_only_underscores_after_normalization_returns_specific_message() {
970 use crate::errors::AppError;
972 let normalized = "___".to_lowercase().replace(['_', ' '], "-");
973 let normalized = normalized.trim_matches('-').to_string();
974 assert!(
975 normalized.is_empty(),
976 "underscores devem normalizar para string vazia"
977 );
978 let resultado: Result<(), AppError> = if normalized.is_empty() {
979 Err(AppError::Validation(
980 "name cannot be empty after normalization (input was blank or contained only hyphens/underscores/spaces)".to_string(),
981 ))
982 } else {
983 Ok(())
984 };
985 assert!(resultado.is_err());
986 if let Err(AppError::Validation(msg)) = resultado {
987 assert!(
988 msg.contains("empty after normalization"),
989 "mensagem deve mencionar 'empty after normalization', obteve: {msg}"
990 );
991 }
992 }
993
994 #[test]
995 fn remember_response_relationships_truncated_serializes_field() {
996 let resp_false = RememberResponse {
998 memory_id: 1,
999 name: "test".to_string(),
1000 namespace: "global".to_string(),
1001 action: "created".to_string(),
1002 operation: "created".to_string(),
1003 version: 1,
1004 entities_persisted: 2,
1005 relationships_persisted: 1,
1006 relationships_truncated: false,
1007 chunks_created: 1,
1008 chunks_persisted: 0,
1009 urls_persisted: 0,
1010 extraction_method: None,
1011 merged_into_memory_id: None,
1012 warnings: vec![],
1013 created_at: 0,
1014 created_at_iso: "1970-01-01T00:00:00Z".to_string(),
1015 elapsed_ms: 0,
1016 name_was_normalized: false,
1017 original_name: None,
1018 };
1019 let json_false = serde_json::to_value(&resp_false).expect("serialization failed");
1020 assert_eq!(json_false["relationships_truncated"], false);
1021
1022 let resp_true = RememberResponse {
1023 relationships_truncated: true,
1024 ..resp_false
1025 };
1026 let json_true = serde_json::to_value(&resp_true).expect("serialization failed");
1027 assert_eq!(json_true["relationships_truncated"], true);
1028 }
1029}