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 mut entities_provided_externally =
265 args.entities_file.is_some() || args.relationships_file.is_some();
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 if args.graph_stdin && !graph.entities.is_empty() {
283 entities_provided_externally = true;
284 }
285
286 if graph.entities.len() > max_entities_per_memory() {
287 return Err(AppError::LimitExceeded(errors_msg::entity_limit_exceeded(
288 max_entities_per_memory(),
289 )));
290 }
291 if graph.relationships.len() > MAX_RELATIONSHIPS_PER_MEMORY {
292 return Err(AppError::LimitExceeded(
293 errors_msg::relationship_limit_exceeded(MAX_RELATIONSHIPS_PER_MEMORY),
294 ));
295 }
296 normalize_and_validate_graph_input(&mut graph)?;
297
298 if raw_body.len() > MAX_MEMORY_BODY_LEN {
299 return Err(AppError::LimitExceeded(
300 crate::i18n::validation::body_exceeds(MAX_MEMORY_BODY_LEN),
301 ));
302 }
303
304 if !entities_provided_externally && graph.entities.is_empty() && raw_body.trim().is_empty() {
307 return Err(AppError::Validation(crate::i18n::validation::empty_body()));
308 }
309
310 let metadata: serde_json::Value = if let Some(m) = args.metadata {
311 serde_json::from_str(&m)?
312 } else if let Some(path) = args.metadata_file {
313 let content = std::fs::read_to_string(&path).map_err(AppError::Io)?;
314 serde_json::from_str(&content)?
315 } else {
316 serde_json::json!({})
317 };
318
319 let body_hash = blake3::hash(raw_body.as_bytes()).to_hex().to_string();
320 let snippet: String = raw_body.chars().take(200).collect();
321
322 let paths = AppPaths::resolve(args.db.as_deref())?;
323 paths.ensure_dirs()?;
324
325 let mut extraction_method: Option<String> = None;
327 let mut extracted_urls: Vec<crate::extraction::ExtractedUrl> = Vec::with_capacity(4);
328 let mut relationships_truncated = false;
329 if args.enable_ner && args.skip_extraction {
330 tracing::warn!(
331 "--enable-ner and --skip-extraction are contradictory; --enable-ner takes precedence"
332 );
333 }
334 if args.skip_extraction && !args.enable_ner {
335 tracing::warn!("--skip-extraction is deprecated and has no effect (NER is disabled by default since v1.0.45); remove this flag");
336 }
337 let gliner_variant: crate::extraction::GlinerVariant =
338 args.gliner_variant.parse().unwrap_or_else(|e| {
339 tracing::warn!("invalid --gliner-variant: {e}; using fp32");
340 crate::extraction::GlinerVariant::Fp32
341 });
342 if args.enable_ner && graph.entities.is_empty() && !raw_body.trim().is_empty() {
343 match crate::extraction::extract_graph_auto(&raw_body, &paths, gliner_variant) {
344 Ok(extracted) => {
345 extraction_method = Some(extracted.extraction_method.clone());
346 extracted_urls = extracted.urls;
347 graph.entities = extracted.entities;
348 graph.relationships = extracted.relationships;
349 relationships_truncated = extracted.relationships_truncated;
350
351 if graph.entities.len() > max_entities_per_memory() {
352 graph.entities.truncate(max_entities_per_memory());
353 }
354 if graph.relationships.len() > MAX_RELATIONSHIPS_PER_MEMORY {
355 relationships_truncated = true;
356 graph.relationships.truncate(MAX_RELATIONSHIPS_PER_MEMORY);
357 }
358 normalize_and_validate_graph_input(&mut graph)?;
359 }
360 Err(e) => {
361 tracing::warn!("auto-extraction failed (graceful degradation): {e:#}");
362 extraction_method = Some("none:extraction-failed".to_string());
363 }
364 }
365 }
366
367 let mut conn = open_rw(&paths.db)?;
368 ensure_schema(&mut conn)?;
369
370 {
371 use crate::constants::MAX_NAMESPACES_ACTIVE;
372 let active_count: u32 = conn.query_row(
373 "SELECT COUNT(DISTINCT namespace) FROM memories WHERE deleted_at IS NULL",
374 [],
375 |r| r.get::<_, i64>(0).map(|v| v as u32),
376 )?;
377 let ns_exists: bool = conn.query_row(
378 "SELECT EXISTS(SELECT 1 FROM memories WHERE namespace = ?1 AND deleted_at IS NULL)",
379 rusqlite::params![namespace],
380 |r| r.get::<_, i64>(0).map(|v| v > 0),
381 )?;
382 if !ns_exists && active_count >= MAX_NAMESPACES_ACTIVE {
383 return Err(AppError::NamespaceError(format!(
384 "active namespace limit of {MAX_NAMESPACES_ACTIVE} reached while trying to create '{namespace}'"
385 )));
386 }
387 }
388
389 if let Some((sd_id, true)) =
391 memories::find_by_name_any_state(&conn, &namespace, &normalized_name)?
392 {
393 if args.force_merge {
394 memories::clear_deleted_at(&conn, sd_id)?;
395 } else {
396 return Err(AppError::Duplicate(
397 errors_msg::duplicate_memory_soft_deleted(&normalized_name, &namespace),
398 ));
399 }
400 }
401
402 let existing_memory = memories::find_by_name(&conn, &namespace, &normalized_name)?;
403 if existing_memory.is_some() && !args.force_merge {
404 return Err(AppError::Duplicate(errors_msg::duplicate_memory(
405 &normalized_name,
406 &namespace,
407 )));
408 }
409
410 let duplicate_hash_id = memories::find_by_hash(&conn, &namespace, &body_hash)?;
411
412 output::emit_progress_i18n(
413 &format!(
414 "Remember stage: validated input; available memory {} MB",
415 crate::memory_guard::available_memory_mb()
416 ),
417 &format!(
418 "Stage remember: input validated; available memory {} MB",
419 crate::memory_guard::available_memory_mb()
420 ),
421 );
422
423 let tokenizer = crate::tokenizer::get_tokenizer(&paths.models)?;
424 let model_max_length = crate::tokenizer::get_model_max_length(&paths.models)?;
425 let total_passage_tokens = crate::tokenizer::count_passage_tokens(tokenizer, &raw_body)?;
426 let chunks_info = chunking::split_into_chunks_hierarchical(&raw_body, tokenizer);
427 let chunks_created = chunks_info.len();
428 let chunks_persisted = compute_chunks_persisted(chunks_info.len());
432
433 output::emit_progress_i18n(
434 &format!(
435 "Remember stage: tokenizer counted {total_passage_tokens} passage tokens (model max {model_max_length}); chunking produced {} chunks; process RSS {} MB",
436 chunks_created,
437 crate::memory_guard::current_process_memory_mb().unwrap_or(0)
438 ),
439 &format!(
440 "Stage remember: tokenizer counted {total_passage_tokens} passage tokens (model max {model_max_length}); chunking produced {} chunks; process RSS {} MB",
441 chunks_created,
442 crate::memory_guard::current_process_memory_mb().unwrap_or(0)
443 ),
444 );
445
446 if chunks_created > crate::constants::REMEMBER_MAX_SAFE_MULTI_CHUNKS {
447 return Err(AppError::LimitExceeded(format!(
448 "document produces {chunks_created} chunks; current safe operational limit is {} chunks; split the document before using remember",
449 crate::constants::REMEMBER_MAX_SAFE_MULTI_CHUNKS
450 )));
451 }
452
453 output::emit_progress_i18n("Computing embedding...", "Calculando embedding...");
454 let mut chunk_embeddings_cache: Option<Vec<Vec<f32>>> = None;
455
456 let embedding = if chunks_info.len() == 1 {
457 crate::daemon::embed_passage_or_local(&paths.models, &raw_body)?
458 } else {
459 let chunk_texts: Vec<&str> = chunks_info
460 .iter()
461 .map(|c| chunking::chunk_text(&raw_body, c))
462 .collect();
463 output::emit_progress_i18n(
464 &format!(
465 "Embedding {} chunks serially to keep memory bounded...",
466 chunks_info.len()
467 ),
468 &format!(
469 "Embedding {} chunks serially to keep memory bounded...",
470 chunks_info.len()
471 ),
472 );
473 let mut chunk_embeddings = Vec::with_capacity(chunk_texts.len());
474 for chunk_text in &chunk_texts {
475 if let Some(rss) = crate::memory_guard::current_process_memory_mb() {
476 if rss > args.max_rss_mb {
477 tracing::error!(
478 rss_mb = rss,
479 max_rss_mb = args.max_rss_mb,
480 "RSS exceeded --max-rss-mb threshold; aborting to prevent system instability"
481 );
482 return Err(AppError::LowMemory {
483 available_mb: crate::memory_guard::available_memory_mb(),
484 required_mb: args.max_rss_mb,
485 });
486 }
487 }
488 chunk_embeddings.push(crate::daemon::embed_passage_or_local(
489 &paths.models,
490 chunk_text,
491 )?);
492 }
493 output::emit_progress_i18n(
494 &format!(
495 "Remember stage: chunk embeddings complete; process RSS {} MB",
496 crate::memory_guard::current_process_memory_mb().unwrap_or(0)
497 ),
498 &format!(
499 "Stage remember: chunk embeddings completed; process RSS {} MB",
500 crate::memory_guard::current_process_memory_mb().unwrap_or(0)
501 ),
502 );
503 let aggregated = chunking::aggregate_embeddings(&chunk_embeddings);
504 chunk_embeddings_cache = Some(chunk_embeddings);
505 aggregated
506 };
507 let body_for_storage = raw_body;
508
509 let memory_type = args.r#type.as_str();
510 let new_memory = NewMemory {
511 namespace: namespace.clone(),
512 name: normalized_name.clone(),
513 memory_type: memory_type.to_string(),
514 description: args.description.clone(),
515 body: body_for_storage,
516 body_hash: body_hash.clone(),
517 session_id: args.session_id.clone(),
518 source: "agent".to_string(),
519 metadata,
520 };
521
522 let mut warnings = Vec::with_capacity(4);
523 let mut entities_persisted = 0usize;
524 let mut relationships_persisted = 0usize;
525
526 let graph_entity_embeddings = graph
527 .entities
528 .iter()
529 .map(|entity| {
530 let entity_text = match &entity.description {
531 Some(desc) => format!("{} {}", entity.name, desc),
532 None => entity.name.clone(),
533 };
534 crate::daemon::embed_passage_or_local(&paths.models, &entity_text)
535 })
536 .collect::<Result<Vec<_>, _>>()?;
537
538 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
539
540 let (memory_id, action, version) = match existing_memory {
541 Some((existing_id, _updated_at, _current_version)) => {
542 if let Some(hash_id) = duplicate_hash_id {
543 if hash_id != existing_id {
544 warnings.push(format!(
545 "identical body already exists as memory id {hash_id}"
546 ));
547 }
548 }
549
550 storage_chunks::delete_chunks(&tx, existing_id)?;
551
552 let next_v = versions::next_version(&tx, existing_id)?;
553 memories::update(&tx, existing_id, &new_memory, args.expected_updated_at)?;
554 versions::insert_version(
555 &tx,
556 existing_id,
557 next_v,
558 &normalized_name,
559 memory_type,
560 &args.description,
561 &new_memory.body,
562 &serde_json::to_string(&new_memory.metadata)?,
563 None,
564 "edit",
565 )?;
566 memories::upsert_vec(
567 &tx,
568 existing_id,
569 &namespace,
570 memory_type,
571 &embedding,
572 &normalized_name,
573 &snippet,
574 )?;
575 (existing_id, "updated".to_string(), next_v)
576 }
577 None => {
578 if let Some(hash_id) = duplicate_hash_id {
579 warnings.push(format!(
580 "identical body already exists as memory id {hash_id}"
581 ));
582 }
583 let id = memories::insert(&tx, &new_memory)?;
584 versions::insert_version(
585 &tx,
586 id,
587 1,
588 &normalized_name,
589 memory_type,
590 &args.description,
591 &new_memory.body,
592 &serde_json::to_string(&new_memory.metadata)?,
593 None,
594 "create",
595 )?;
596 memories::upsert_vec(
597 &tx,
598 id,
599 &namespace,
600 memory_type,
601 &embedding,
602 &normalized_name,
603 &snippet,
604 )?;
605 (id, "created".to_string(), 1)
606 }
607 };
608
609 if chunks_info.len() > 1 {
610 storage_chunks::insert_chunk_slices(&tx, memory_id, &new_memory.body, &chunks_info)?;
611
612 let chunk_embeddings = chunk_embeddings_cache.take().ok_or_else(|| {
613 AppError::Internal(anyhow::anyhow!(
614 "chunk embeddings cache missing in multi-chunk remember path"
615 ))
616 })?;
617
618 for (i, emb) in chunk_embeddings.iter().enumerate() {
619 storage_chunks::upsert_chunk_vec(&tx, i as i64, memory_id, i as i32, emb)?;
620 }
621 output::emit_progress_i18n(
622 &format!(
623 "Remember stage: persisted chunk vectors; process RSS {} MB",
624 crate::memory_guard::current_process_memory_mb().unwrap_or(0)
625 ),
626 &format!(
627 "Etapa remember: vetores de chunks persistidos; RSS do processo {} MB",
628 crate::memory_guard::current_process_memory_mb().unwrap_or(0)
629 ),
630 );
631 }
632
633 if !graph.entities.is_empty() || !graph.relationships.is_empty() {
634 for entity in &graph.entities {
635 let entity_id = entities::upsert_entity(&tx, &namespace, entity)?;
636 let entity_embedding = &graph_entity_embeddings[entities_persisted];
637 entities::upsert_entity_vec(
638 &tx,
639 entity_id,
640 &namespace,
641 entity.entity_type,
642 entity_embedding,
643 &entity.name,
644 )?;
645 entities::link_memory_entity(&tx, memory_id, entity_id)?;
646 entities::increment_degree(&tx, entity_id)?;
647 entities_persisted += 1;
648 }
649 let entity_types: std::collections::HashMap<&str, EntityType> = graph
650 .entities
651 .iter()
652 .map(|entity| (entity.name.as_str(), entity.entity_type))
653 .collect();
654
655 for rel in &graph.relationships {
656 let source_entity = NewEntity {
657 name: rel.source.clone(),
658 entity_type: entity_types
659 .get(rel.source.as_str())
660 .copied()
661 .unwrap_or(EntityType::Concept),
662 description: None,
663 };
664 let target_entity = NewEntity {
665 name: rel.target.clone(),
666 entity_type: entity_types
667 .get(rel.target.as_str())
668 .copied()
669 .unwrap_or(EntityType::Concept),
670 description: None,
671 };
672 let source_id = entities::upsert_entity(&tx, &namespace, &source_entity)?;
673 let target_id = entities::upsert_entity(&tx, &namespace, &target_entity)?;
674 let rel_id = entities::upsert_relationship(&tx, &namespace, source_id, target_id, rel)?;
675 entities::link_memory_relationship(&tx, memory_id, rel_id)?;
676 relationships_persisted += 1;
677 }
678 }
679 tx.commit()?;
680
681 let urls_persisted = if !extracted_urls.is_empty() {
684 let url_entries: Vec<storage_urls::MemoryUrl> = extracted_urls
685 .into_iter()
686 .map(|u| storage_urls::MemoryUrl {
687 url: u.url,
688 offset: Some(u.offset as i64),
689 })
690 .collect();
691 storage_urls::insert_urls(&conn, memory_id, &url_entries)
692 } else {
693 0
694 };
695
696 conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
697
698 let created_at_epoch = chrono::Utc::now().timestamp();
699 let created_at_iso = crate::tz::format_iso(chrono::Utc::now());
700
701 output::emit_json(&RememberResponse {
702 memory_id,
703 name: normalized_name.clone(),
707 namespace,
708 action: action.clone(),
709 operation: action,
710 version,
711 entities_persisted,
712 relationships_persisted,
713 relationships_truncated,
714 chunks_created,
715 chunks_persisted,
716 urls_persisted,
717 extraction_method,
718 merged_into_memory_id: None,
719 warnings,
720 created_at: created_at_epoch,
721 created_at_iso,
722 elapsed_ms: inicio.elapsed().as_millis() as u64,
723 name_was_normalized,
724 original_name: name_was_normalized.then_some(original_name),
725 })?;
726
727 Ok(())
728}
729
730#[cfg(test)]
731mod tests {
732 use super::compute_chunks_persisted;
733 use crate::output::RememberResponse;
734
735 #[test]
737 fn chunks_persisted_zero_for_zero_chunks() {
738 assert_eq!(compute_chunks_persisted(0), 0);
739 }
740
741 #[test]
742 fn chunks_persisted_zero_for_single_chunk_body() {
743 assert_eq!(compute_chunks_persisted(1), 0);
746 }
747
748 #[test]
749 fn chunks_persisted_equals_count_for_multi_chunk_body() {
750 assert_eq!(compute_chunks_persisted(2), 2);
752 assert_eq!(compute_chunks_persisted(7), 7);
753 assert_eq!(compute_chunks_persisted(64), 64);
754 }
755
756 #[test]
757 fn remember_response_serializes_required_fields() {
758 let resp = RememberResponse {
759 memory_id: 42,
760 name: "minha-mem".to_string(),
761 namespace: "global".to_string(),
762 action: "created".to_string(),
763 operation: "created".to_string(),
764 version: 1,
765 entities_persisted: 0,
766 relationships_persisted: 0,
767 relationships_truncated: false,
768 chunks_created: 1,
769 chunks_persisted: 0,
770 urls_persisted: 0,
771 extraction_method: None,
772 merged_into_memory_id: None,
773 warnings: vec![],
774 created_at: 1_705_320_000,
775 created_at_iso: "2024-01-15T12:00:00Z".to_string(),
776 elapsed_ms: 55,
777 name_was_normalized: false,
778 original_name: None,
779 };
780
781 let json = serde_json::to_value(&resp).expect("serialization failed");
782 assert_eq!(json["memory_id"], 42);
783 assert_eq!(json["action"], "created");
784 assert_eq!(json["operation"], "created");
785 assert_eq!(json["version"], 1);
786 assert_eq!(json["elapsed_ms"], 55u64);
787 assert!(json["warnings"].is_array());
788 assert!(json["merged_into_memory_id"].is_null());
789 }
790
791 #[test]
792 fn remember_response_action_e_operation_sao_aliases() {
793 let resp = RememberResponse {
794 memory_id: 1,
795 name: "mem".to_string(),
796 namespace: "global".to_string(),
797 action: "updated".to_string(),
798 operation: "updated".to_string(),
799 version: 2,
800 entities_persisted: 3,
801 relationships_persisted: 1,
802 relationships_truncated: false,
803 extraction_method: None,
804 chunks_created: 2,
805 chunks_persisted: 2,
806 urls_persisted: 0,
807 merged_into_memory_id: None,
808 warnings: vec![],
809 created_at: 0,
810 created_at_iso: "1970-01-01T00:00:00Z".to_string(),
811 elapsed_ms: 0,
812 name_was_normalized: false,
813 original_name: None,
814 };
815
816 let json = serde_json::to_value(&resp).expect("serialization failed");
817 assert_eq!(
818 json["action"], json["operation"],
819 "action e operation devem ser iguais"
820 );
821 assert_eq!(json["entities_persisted"], 3);
822 assert_eq!(json["relationships_persisted"], 1);
823 assert_eq!(json["chunks_created"], 2);
824 }
825
826 #[test]
827 fn remember_response_warnings_lista_mensagens() {
828 let resp = RememberResponse {
829 memory_id: 5,
830 name: "dup-mem".to_string(),
831 namespace: "global".to_string(),
832 action: "created".to_string(),
833 operation: "created".to_string(),
834 version: 1,
835 entities_persisted: 0,
836 extraction_method: None,
837 relationships_persisted: 0,
838 relationships_truncated: false,
839 chunks_created: 1,
840 chunks_persisted: 0,
841 urls_persisted: 0,
842 merged_into_memory_id: None,
843 warnings: vec!["identical body already exists as memory id 3".to_string()],
844 created_at: 0,
845 created_at_iso: "1970-01-01T00:00:00Z".to_string(),
846 elapsed_ms: 10,
847 name_was_normalized: false,
848 original_name: None,
849 };
850
851 let json = serde_json::to_value(&resp).expect("serialization failed");
852 let warnings = json["warnings"]
853 .as_array()
854 .expect("warnings deve ser array");
855 assert_eq!(warnings.len(), 1);
856 assert!(warnings[0].as_str().unwrap().contains("identical body"));
857 }
858
859 #[test]
860 fn invalid_name_reserved_prefix_returns_validation_error() {
861 use crate::errors::AppError;
862 let nome = "__reservado";
864 let resultado: Result<(), AppError> = if nome.starts_with("__") {
865 Err(AppError::Validation(
866 crate::i18n::validation::reserved_name(),
867 ))
868 } else {
869 Ok(())
870 };
871 assert!(resultado.is_err());
872 if let Err(AppError::Validation(msg)) = resultado {
873 assert!(!msg.is_empty());
874 }
875 }
876
877 #[test]
878 fn name_too_long_returns_validation_error() {
879 use crate::errors::AppError;
880 let nome_longo = "a".repeat(crate::constants::MAX_MEMORY_NAME_LEN + 1);
881 let resultado: Result<(), AppError> =
882 if nome_longo.is_empty() || nome_longo.len() > crate::constants::MAX_MEMORY_NAME_LEN {
883 Err(AppError::Validation(crate::i18n::validation::name_length(
884 crate::constants::MAX_MEMORY_NAME_LEN,
885 )))
886 } else {
887 Ok(())
888 };
889 assert!(resultado.is_err());
890 }
891
892 #[test]
893 fn remember_response_merged_into_memory_id_some_serializes_integer() {
894 let resp = RememberResponse {
895 memory_id: 10,
896 name: "mem-mergeada".to_string(),
897 namespace: "global".to_string(),
898 action: "updated".to_string(),
899 operation: "updated".to_string(),
900 version: 3,
901 extraction_method: None,
902 entities_persisted: 0,
903 relationships_persisted: 0,
904 relationships_truncated: false,
905 chunks_created: 1,
906 chunks_persisted: 0,
907 urls_persisted: 0,
908 merged_into_memory_id: Some(7),
909 warnings: vec![],
910 created_at: 0,
911 created_at_iso: "1970-01-01T00:00:00Z".to_string(),
912 elapsed_ms: 0,
913 name_was_normalized: false,
914 original_name: None,
915 };
916
917 let json = serde_json::to_value(&resp).expect("serialization failed");
918 assert_eq!(json["merged_into_memory_id"], 7);
919 }
920
921 #[test]
922 fn remember_response_urls_persisted_serializes_field() {
923 let resp = RememberResponse {
925 memory_id: 3,
926 name: "mem-com-urls".to_string(),
927 namespace: "global".to_string(),
928 action: "created".to_string(),
929 operation: "created".to_string(),
930 version: 1,
931 entities_persisted: 0,
932 relationships_persisted: 0,
933 relationships_truncated: false,
934 chunks_created: 1,
935 chunks_persisted: 0,
936 urls_persisted: 3,
937 extraction_method: Some("regex-only".to_string()),
938 merged_into_memory_id: None,
939 warnings: vec![],
940 created_at: 0,
941 created_at_iso: "1970-01-01T00:00:00Z".to_string(),
942 elapsed_ms: 0,
943 name_was_normalized: false,
944 original_name: None,
945 };
946 let json = serde_json::to_value(&resp).expect("serialization failed");
947 assert_eq!(json["urls_persisted"], 3);
948 }
949
950 #[test]
951 fn empty_name_after_normalization_returns_specific_message() {
952 use crate::errors::AppError;
955 let normalized = "---".to_lowercase().replace(['_', ' '], "-");
956 let normalized = normalized.trim_matches('-').to_string();
957 let resultado: Result<(), AppError> = if normalized.is_empty() {
958 Err(AppError::Validation(
959 "name cannot be empty after normalization (input was blank or contained only hyphens/underscores/spaces)".to_string(),
960 ))
961 } else {
962 Ok(())
963 };
964 assert!(resultado.is_err());
965 if let Err(AppError::Validation(msg)) = resultado {
966 assert!(
967 msg.contains("empty after normalization"),
968 "mensagem deve mencionar 'empty after normalization', obteve: {msg}"
969 );
970 }
971 }
972
973 #[test]
974 fn name_only_underscores_after_normalization_returns_specific_message() {
975 use crate::errors::AppError;
977 let normalized = "___".to_lowercase().replace(['_', ' '], "-");
978 let normalized = normalized.trim_matches('-').to_string();
979 assert!(
980 normalized.is_empty(),
981 "underscores devem normalizar para string vazia"
982 );
983 let resultado: Result<(), AppError> = if normalized.is_empty() {
984 Err(AppError::Validation(
985 "name cannot be empty after normalization (input was blank or contained only hyphens/underscores/spaces)".to_string(),
986 ))
987 } else {
988 Ok(())
989 };
990 assert!(resultado.is_err());
991 if let Err(AppError::Validation(msg)) = resultado {
992 assert!(
993 msg.contains("empty after normalization"),
994 "mensagem deve mencionar 'empty after normalization', obteve: {msg}"
995 );
996 }
997 }
998
999 #[test]
1000 fn remember_response_relationships_truncated_serializes_field() {
1001 let resp_false = RememberResponse {
1003 memory_id: 1,
1004 name: "test".to_string(),
1005 namespace: "global".to_string(),
1006 action: "created".to_string(),
1007 operation: "created".to_string(),
1008 version: 1,
1009 entities_persisted: 2,
1010 relationships_persisted: 1,
1011 relationships_truncated: false,
1012 chunks_created: 1,
1013 chunks_persisted: 0,
1014 urls_persisted: 0,
1015 extraction_method: None,
1016 merged_into_memory_id: None,
1017 warnings: vec![],
1018 created_at: 0,
1019 created_at_iso: "1970-01-01T00:00:00Z".to_string(),
1020 elapsed_ms: 0,
1021 name_was_normalized: false,
1022 original_name: None,
1023 };
1024 let json_false = serde_json::to_value(&resp_false).expect("serialization failed");
1025 assert_eq!(json_false["relationships_truncated"], false);
1026
1027 let resp_true = RememberResponse {
1028 relationships_truncated: true,
1029 ..resp_false
1030 };
1031 let json_true = serde_json::to_value(&resp_true).expect("serialization failed");
1032 assert_eq!(json_true["relationships_truncated"], true);
1033 }
1034}