Skip to main content

sqlite_graphrag/commands/
remember.rs

1//! Handler for the `remember` CLI subcommand.
2
3use crate::chunking;
4use crate::cli::MemoryType;
5use crate::errors::AppError;
6use crate::i18n::errors_msg;
7use crate::output::{self, JsonOutputFormat, RememberResponse};
8use crate::paths::AppPaths;
9use crate::storage::chunks as storage_chunks;
10use crate::storage::connection::{ensure_schema, open_rw};
11use crate::storage::entities::{NewEntity, NewRelationship};
12use crate::storage::memories::NewMemory;
13use crate::storage::{entities, memories, urls as storage_urls, versions};
14use serde::Deserialize;
15
16#[derive(clap::Args)]
17pub struct RememberArgs {
18    /// Memory name in kebab-case (lowercase letters, digits, hyphens).
19    /// Acts as unique key within the namespace; collisions trigger merge or rejection.
20    #[arg(long)]
21    pub name: String,
22    #[arg(
23        long,
24        value_enum,
25        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."
26    )]
27    pub r#type: MemoryType,
28    /// Short description (≤500 chars) summarizing the memory for use in `list` and `recall` snippets.
29    #[arg(long)]
30    pub description: String,
31    /// Inline body content. Mutually exclusive with --body-file, --body-stdin, --graph-stdin.
32    /// Maximum 512000 bytes; rejected if empty without an external graph.
33    #[arg(
34        long,
35        conflicts_with_all = ["body_file", "body_stdin", "graph_stdin"]
36    )]
37    pub body: Option<String>,
38    #[arg(
39        long,
40        help = "Read body from a file instead of --body",
41        conflicts_with_all = ["body", "body_stdin", "graph_stdin"]
42    )]
43    pub body_file: Option<std::path::PathBuf>,
44    /// Read body from stdin until EOF. Useful in pipes (echo "..." | sqlite-graphrag remember ...).
45    /// Mutually exclusive with --body, --body-file, --graph-stdin.
46    #[arg(
47        long,
48        conflicts_with_all = ["body", "body_file", "graph_stdin"]
49    )]
50    pub body_stdin: bool,
51    #[arg(
52        long,
53        help = "JSON file containing entities to associate with this memory"
54    )]
55    pub entities_file: Option<std::path::PathBuf>,
56    #[arg(
57        long,
58        help = "JSON file containing relationships to associate with this memory"
59    )]
60    pub relationships_file: Option<std::path::PathBuf>,
61    #[arg(
62        long,
63        help = "Read graph JSON (body + entities + relationships) from stdin",
64        conflicts_with_all = [
65            "body",
66            "body_file",
67            "body_stdin",
68            "entities_file",
69            "relationships_file"
70        ]
71    )]
72    pub graph_stdin: bool,
73    #[arg(long, default_value = "global")]
74    pub namespace: Option<String>,
75    /// Inline JSON object with arbitrary metadata key-value pairs. Mutually exclusive with --metadata-file.
76    #[arg(long)]
77    pub metadata: Option<String>,
78    #[arg(long, help = "JSON file containing metadata key-value pairs")]
79    pub metadata_file: Option<std::path::PathBuf>,
80    #[arg(long)]
81    pub force_merge: bool,
82    #[arg(
83        long,
84        value_name = "EPOCH_OR_RFC3339",
85        value_parser = crate::parsers::parse_expected_updated_at,
86        long_help = "Optimistic lock: reject if updated_at does not match. \
87Accepts Unix epoch (e.g. 1700000000) or RFC 3339 (e.g. 2026-04-19T12:00:00Z)."
88    )]
89    pub expected_updated_at: Option<i64>,
90    #[arg(
91        long,
92        help = "Disable automatic entity/relationship extraction from body"
93    )]
94    pub skip_extraction: bool,
95    /// Optional opaque session identifier for tracing memory provenance across multi-agent runs.
96    #[arg(long)]
97    pub session_id: Option<String>,
98    #[arg(long, value_enum, default_value_t = JsonOutputFormat::Json)]
99    pub format: JsonOutputFormat,
100    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
101    pub json: bool,
102    #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
103    pub db: Option<String>,
104}
105
106#[derive(Deserialize, Default)]
107#[serde(deny_unknown_fields)]
108struct GraphInput {
109    #[serde(default)]
110    body: Option<String>,
111    #[serde(default)]
112    entities: Vec<NewEntity>,
113    #[serde(default)]
114    relationships: Vec<NewRelationship>,
115}
116
117fn normalize_and_validate_graph_input(graph: &mut GraphInput) -> Result<(), AppError> {
118    for entity in &graph.entities {
119        if !is_valid_entity_type(&entity.entity_type) {
120            return Err(AppError::Validation(format!(
121                "invalid entity_type '{}' for entity '{}'",
122                entity.entity_type, entity.name
123            )));
124        }
125    }
126
127    for rel in &mut graph.relationships {
128        rel.relation = rel.relation.replace('-', "_");
129        if !is_valid_relation(&rel.relation) {
130            return Err(AppError::Validation(format!(
131                "invalid relation '{}' for relationship '{}' -> '{}'",
132                rel.relation, rel.source, rel.target
133            )));
134        }
135        if !(0.0..=1.0).contains(&rel.strength) {
136            return Err(AppError::Validation(format!(
137                "invalid strength {} for relationship '{}' -> '{}'; expected value in [0.0, 1.0]",
138                rel.strength, rel.source, rel.target
139            )));
140        }
141    }
142
143    Ok(())
144}
145
146fn is_valid_entity_type(entity_type: &str) -> bool {
147    matches!(
148        entity_type,
149        "project"
150            | "tool"
151            | "person"
152            | "file"
153            | "concept"
154            | "incident"
155            | "decision"
156            | "memory"
157            | "dashboard"
158            | "issue_tracker"
159            | "organization"
160            | "location"
161            | "date"
162    )
163}
164
165fn is_valid_relation(relation: &str) -> bool {
166    matches!(
167        relation,
168        "applies_to"
169            | "uses"
170            | "depends_on"
171            | "causes"
172            | "fixes"
173            | "contradicts"
174            | "supports"
175            | "follows"
176            | "related"
177            | "mentions"
178            | "replaces"
179            | "tracked_in"
180    )
181}
182
183pub fn run(args: RememberArgs) -> Result<(), AppError> {
184    use crate::constants::*;
185
186    let inicio = std::time::Instant::now();
187    let _ = args.format;
188    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
189
190    // Capture the original `--name` before normalization so the JSON response can
191    // surface `name_was_normalized` + `original_name` (B_4 in v1.0.32). Stored as
192    // an owned String because `args.name` is moved into the response below.
193    let original_name = args.name.clone();
194
195    // Auto-normalize to kebab-case before validation (P2-H).
196    // v1.0.20: also trims hyphens at the boundary (including trailing) to avoid rejection
197    // after truncation by a long filename ending in a hyphen.
198    let normalized_name = {
199        let lower = args.name.to_lowercase().replace(['_', ' '], "-");
200        let trimmed = lower.trim_matches('-').to_string();
201        if trimmed != args.name {
202            tracing::warn!(
203                original = %args.name,
204                normalized = %trimmed,
205                "name auto-normalized to kebab-case"
206            );
207        }
208        trimmed
209    };
210    let name_was_normalized = normalized_name != original_name;
211
212    if normalized_name.is_empty() {
213        return Err(AppError::Validation(
214            "name cannot be empty after normalization (input was blank or contained only hyphens/underscores/spaces)".to_string(),
215        ));
216    }
217    if normalized_name.len() > MAX_MEMORY_NAME_LEN {
218        return Err(AppError::LimitExceeded(
219            crate::i18n::validation::name_length(MAX_MEMORY_NAME_LEN),
220        ));
221    }
222
223    if normalized_name.starts_with("__") {
224        return Err(AppError::Validation(
225            crate::i18n::validation::reserved_name(),
226        ));
227    }
228
229    {
230        let slug_re = regex::Regex::new(crate::constants::NAME_SLUG_REGEX)
231            .map_err(|e| AppError::Internal(anyhow::anyhow!("regex: {e}")))?;
232        if !slug_re.is_match(&normalized_name) {
233            return Err(AppError::Validation(crate::i18n::validation::name_kebab(
234                &normalized_name,
235            )));
236        }
237    }
238
239    if args.description.len() > MAX_MEMORY_DESCRIPTION_LEN {
240        return Err(AppError::Validation(
241            crate::i18n::validation::description_exceeds(MAX_MEMORY_DESCRIPTION_LEN),
242        ));
243    }
244
245    let mut raw_body = if let Some(b) = args.body {
246        b
247    } else if let Some(path) = args.body_file {
248        std::fs::read_to_string(&path).map_err(AppError::Io)?
249    } else if args.body_stdin || args.graph_stdin {
250        crate::stdin_helper::read_stdin_with_timeout(60)?
251    } else {
252        String::new()
253    };
254
255    let entities_provided_externally =
256        args.entities_file.is_some() || args.relationships_file.is_some() || args.graph_stdin;
257
258    let mut graph = GraphInput::default();
259    if let Some(path) = args.entities_file {
260        let content = std::fs::read_to_string(&path).map_err(AppError::Io)?;
261        graph.entities = serde_json::from_str(&content)?;
262    }
263    if let Some(path) = args.relationships_file {
264        let content = std::fs::read_to_string(&path).map_err(AppError::Io)?;
265        graph.relationships = serde_json::from_str(&content)?;
266    }
267    if args.graph_stdin {
268        graph = serde_json::from_str::<GraphInput>(&raw_body).map_err(|e| {
269            AppError::Validation(format!("invalid JSON payload on --graph-stdin: {e}"))
270        })?;
271        raw_body = graph.body.take().unwrap_or_default();
272    }
273
274    if graph.entities.len() > MAX_ENTITIES_PER_MEMORY {
275        return Err(AppError::LimitExceeded(errors_msg::entity_limit_exceeded(
276            MAX_ENTITIES_PER_MEMORY,
277        )));
278    }
279    if graph.relationships.len() > MAX_RELATIONSHIPS_PER_MEMORY {
280        return Err(AppError::LimitExceeded(
281            errors_msg::relationship_limit_exceeded(MAX_RELATIONSHIPS_PER_MEMORY),
282        ));
283    }
284    normalize_and_validate_graph_input(&mut graph)?;
285
286    if raw_body.len() > MAX_MEMORY_BODY_LEN {
287        return Err(AppError::LimitExceeded(
288            crate::i18n::validation::body_exceeds(MAX_MEMORY_BODY_LEN),
289        ));
290    }
291
292    // v1.0.22 P1: reject empty or whitespace-only body when no external graph is provided.
293    // Without this check, empty embeddings would be persisted, breaking recall semantics.
294    if !entities_provided_externally && graph.entities.is_empty() && raw_body.trim().is_empty() {
295        return Err(AppError::Validation(crate::i18n::validation::empty_body()));
296    }
297
298    let metadata: serde_json::Value = if let Some(m) = args.metadata {
299        serde_json::from_str(&m)?
300    } else if let Some(path) = args.metadata_file {
301        let content = std::fs::read_to_string(&path).map_err(AppError::Io)?;
302        serde_json::from_str(&content)?
303    } else {
304        serde_json::json!({})
305    };
306
307    let body_hash = blake3::hash(raw_body.as_bytes()).to_hex().to_string();
308    let snippet: String = raw_body.chars().take(200).collect();
309
310    let paths = AppPaths::resolve(args.db.as_deref())?;
311    paths.ensure_dirs()?;
312
313    // v1.0.20: use .trim().is_empty() to reject bodies that are only whitespace.
314    let mut extraction_method: Option<String> = None;
315    let mut extracted_urls: Vec<crate::extraction::ExtractedUrl> = Vec::new();
316    let mut relationships_truncated = false;
317    if !args.skip_extraction
318        && !entities_provided_externally
319        && graph.entities.is_empty()
320        && !raw_body.trim().is_empty()
321    {
322        match crate::extraction::extract_graph_auto(&raw_body, &paths) {
323            Ok(extracted) => {
324                extraction_method = Some(extracted.extraction_method.clone());
325                extracted_urls = extracted.urls;
326                graph.entities = extracted.entities;
327                graph.relationships = extracted.relationships;
328                relationships_truncated = extracted.relationships_truncated;
329
330                if graph.entities.len() > MAX_ENTITIES_PER_MEMORY {
331                    graph.entities.truncate(MAX_ENTITIES_PER_MEMORY);
332                }
333                if graph.relationships.len() > MAX_RELATIONSHIPS_PER_MEMORY {
334                    relationships_truncated = true;
335                    graph.relationships.truncate(MAX_RELATIONSHIPS_PER_MEMORY);
336                }
337                normalize_and_validate_graph_input(&mut graph)?;
338            }
339            Err(e) => {
340                tracing::warn!("auto-extraction failed (graceful degradation): {e:#}");
341            }
342        }
343    }
344
345    let mut conn = open_rw(&paths.db)?;
346    ensure_schema(&mut conn)?;
347
348    {
349        use crate::constants::MAX_NAMESPACES_ACTIVE;
350        let active_count: u32 = conn.query_row(
351            "SELECT COUNT(DISTINCT namespace) FROM memories WHERE deleted_at IS NULL",
352            [],
353            |r| r.get::<_, i64>(0).map(|v| v as u32),
354        )?;
355        let ns_exists: bool = conn.query_row(
356            "SELECT EXISTS(SELECT 1 FROM memories WHERE namespace = ?1 AND deleted_at IS NULL)",
357            rusqlite::params![namespace],
358            |r| r.get::<_, i64>(0).map(|v| v > 0),
359        )?;
360        if !ns_exists && active_count >= MAX_NAMESPACES_ACTIVE {
361            return Err(AppError::NamespaceError(format!(
362                "active namespace limit of {MAX_NAMESPACES_ACTIVE} reached while trying to create '{namespace}'"
363            )));
364        }
365    }
366
367    let existing_memory = memories::find_by_name(&conn, &namespace, &normalized_name)?;
368    if existing_memory.is_some() && !args.force_merge {
369        return Err(AppError::Duplicate(errors_msg::duplicate_memory(
370            &normalized_name,
371            &namespace,
372        )));
373    }
374
375    let duplicate_hash_id = memories::find_by_hash(&conn, &namespace, &body_hash)?;
376
377    output::emit_progress_i18n(
378        &format!(
379            "Remember stage: validated input; available memory {} MB",
380            crate::memory_guard::available_memory_mb()
381        ),
382        &format!(
383            "Stage remember: input validated; available memory {} MB",
384            crate::memory_guard::available_memory_mb()
385        ),
386    );
387
388    let tokenizer = crate::tokenizer::get_tokenizer(&paths.models)?;
389    let model_max_length = crate::tokenizer::get_model_max_length(&paths.models)?;
390    let total_passage_tokens = crate::tokenizer::count_passage_tokens(tokenizer, &raw_body)?;
391    let chunks_info = chunking::split_into_chunks_hierarchical(&raw_body, tokenizer);
392    let chunks_created = chunks_info.len();
393    // For single-chunk bodies the memory row itself stores the content and no
394    // entry is appended to `memory_chunks` (see line ~545). For multi-chunk
395    // bodies every chunk is persisted via `insert_chunk_slices`.
396    let chunks_persisted = if chunks_info.len() > 1 {
397        chunks_info.len()
398    } else {
399        0
400    };
401
402    output::emit_progress_i18n(
403        &format!(
404            "Remember stage: tokenizer counted {total_passage_tokens} passage tokens (model max {model_max_length}); chunking produced {} chunks; process RSS {} MB",
405            chunks_created,
406            crate::memory_guard::current_process_memory_mb().unwrap_or(0)
407        ),
408        &format!(
409            "Stage remember: tokenizer counted {total_passage_tokens} passage tokens (model max {model_max_length}); chunking produced {} chunks; process RSS {} MB",
410            chunks_created,
411            crate::memory_guard::current_process_memory_mb().unwrap_or(0)
412        ),
413    );
414
415    if chunks_created > crate::constants::REMEMBER_MAX_SAFE_MULTI_CHUNKS {
416        return Err(AppError::LimitExceeded(format!(
417            "document produces {chunks_created} chunks; current safe operational limit is {} chunks; split the document before using remember",
418            crate::constants::REMEMBER_MAX_SAFE_MULTI_CHUNKS
419        )));
420    }
421
422    output::emit_progress_i18n("Computing embedding...", "Calculando embedding...");
423    let mut chunk_embeddings_cache: Option<Vec<Vec<f32>>> = None;
424
425    let embedding = if chunks_info.len() == 1 {
426        crate::daemon::embed_passage_or_local(&paths.models, &raw_body)?
427    } else {
428        let chunk_texts: Vec<&str> = chunks_info
429            .iter()
430            .map(|c| chunking::chunk_text(&raw_body, c))
431            .collect();
432        output::emit_progress_i18n(
433            &format!(
434                "Embedding {} chunks serially to keep memory bounded...",
435                chunks_info.len()
436            ),
437            &format!(
438                "Embedding {} chunks serially to keep memory bounded...",
439                chunks_info.len()
440            ),
441        );
442        let mut chunk_embeddings = Vec::with_capacity(chunk_texts.len());
443        for chunk_text in &chunk_texts {
444            chunk_embeddings.push(crate::daemon::embed_passage_or_local(
445                &paths.models,
446                chunk_text,
447            )?);
448        }
449        output::emit_progress_i18n(
450            &format!(
451                "Remember stage: chunk embeddings complete; process RSS {} MB",
452                crate::memory_guard::current_process_memory_mb().unwrap_or(0)
453            ),
454            &format!(
455                "Stage remember: chunk embeddings completed; process RSS {} MB",
456                crate::memory_guard::current_process_memory_mb().unwrap_or(0)
457            ),
458        );
459        let aggregated = chunking::aggregate_embeddings(&chunk_embeddings);
460        chunk_embeddings_cache = Some(chunk_embeddings);
461        aggregated
462    };
463    let body_for_storage = raw_body;
464
465    let memory_type = args.r#type.as_str();
466    let new_memory = NewMemory {
467        namespace: namespace.clone(),
468        name: normalized_name.clone(),
469        memory_type: memory_type.to_string(),
470        description: args.description.clone(),
471        body: body_for_storage,
472        body_hash: body_hash.clone(),
473        session_id: args.session_id.clone(),
474        source: "agent".to_string(),
475        metadata,
476    };
477
478    let mut warnings = Vec::new();
479    let mut entities_persisted = 0usize;
480    let mut relationships_persisted = 0usize;
481
482    let graph_entity_embeddings = graph
483        .entities
484        .iter()
485        .map(|entity| {
486            let entity_text = match &entity.description {
487                Some(desc) => format!("{} {}", entity.name, desc),
488                None => entity.name.clone(),
489            };
490            crate::daemon::embed_passage_or_local(&paths.models, &entity_text)
491        })
492        .collect::<Result<Vec<_>, _>>()?;
493
494    let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
495
496    let (memory_id, action, version) = match existing_memory {
497        Some((existing_id, _updated_at, _current_version)) => {
498            if let Some(hash_id) = duplicate_hash_id {
499                if hash_id != existing_id {
500                    warnings.push(format!(
501                        "identical body already exists as memory id {hash_id}"
502                    ));
503                }
504            }
505
506            storage_chunks::delete_chunks(&tx, existing_id)?;
507
508            let next_v = versions::next_version(&tx, existing_id)?;
509            memories::update(&tx, existing_id, &new_memory, args.expected_updated_at)?;
510            versions::insert_version(
511                &tx,
512                existing_id,
513                next_v,
514                &normalized_name,
515                memory_type,
516                &args.description,
517                &new_memory.body,
518                &serde_json::to_string(&new_memory.metadata)?,
519                None,
520                "edit",
521            )?;
522            memories::upsert_vec(
523                &tx,
524                existing_id,
525                &namespace,
526                memory_type,
527                &embedding,
528                &normalized_name,
529                &snippet,
530            )?;
531            (existing_id, "updated".to_string(), next_v)
532        }
533        None => {
534            if let Some(hash_id) = duplicate_hash_id {
535                warnings.push(format!(
536                    "identical body already exists as memory id {hash_id}"
537                ));
538            }
539            let id = memories::insert(&tx, &new_memory)?;
540            versions::insert_version(
541                &tx,
542                id,
543                1,
544                &normalized_name,
545                memory_type,
546                &args.description,
547                &new_memory.body,
548                &serde_json::to_string(&new_memory.metadata)?,
549                None,
550                "create",
551            )?;
552            memories::upsert_vec(
553                &tx,
554                id,
555                &namespace,
556                memory_type,
557                &embedding,
558                &normalized_name,
559                &snippet,
560            )?;
561            (id, "created".to_string(), 1)
562        }
563    };
564
565    if chunks_info.len() > 1 {
566        storage_chunks::insert_chunk_slices(&tx, memory_id, &new_memory.body, &chunks_info)?;
567
568        let chunk_embeddings = chunk_embeddings_cache.take().ok_or_else(|| {
569            AppError::Internal(anyhow::anyhow!(
570                "cache de embeddings de chunks ausente no caminho multi-chunk do remember"
571            ))
572        })?;
573
574        for (i, emb) in chunk_embeddings.iter().enumerate() {
575            storage_chunks::upsert_chunk_vec(&tx, i as i64, memory_id, i as i32, emb)?;
576        }
577        output::emit_progress_i18n(
578            &format!(
579                "Remember stage: persisted chunk vectors; process RSS {} MB",
580                crate::memory_guard::current_process_memory_mb().unwrap_or(0)
581            ),
582            &format!(
583                "Etapa remember: vetores de chunks persistidos; RSS do processo {} MB",
584                crate::memory_guard::current_process_memory_mb().unwrap_or(0)
585            ),
586        );
587    }
588
589    if !graph.entities.is_empty() || !graph.relationships.is_empty() {
590        for entity in &graph.entities {
591            let entity_id = entities::upsert_entity(&tx, &namespace, entity)?;
592            let entity_embedding = &graph_entity_embeddings[entities_persisted];
593            entities::upsert_entity_vec(
594                &tx,
595                entity_id,
596                &namespace,
597                &entity.entity_type,
598                entity_embedding,
599                &entity.name,
600            )?;
601            entities::link_memory_entity(&tx, memory_id, entity_id)?;
602            entities::increment_degree(&tx, entity_id)?;
603            entities_persisted += 1;
604        }
605        let entity_types: std::collections::HashMap<&str, &str> = graph
606            .entities
607            .iter()
608            .map(|entity| (entity.name.as_str(), entity.entity_type.as_str()))
609            .collect();
610
611        for rel in &graph.relationships {
612            let source_entity = NewEntity {
613                name: rel.source.clone(),
614                entity_type: entity_types
615                    .get(rel.source.as_str())
616                    .copied()
617                    .unwrap_or("concept")
618                    .to_string(),
619                description: None,
620            };
621            let target_entity = NewEntity {
622                name: rel.target.clone(),
623                entity_type: entity_types
624                    .get(rel.target.as_str())
625                    .copied()
626                    .unwrap_or("concept")
627                    .to_string(),
628                description: None,
629            };
630            let source_id = entities::upsert_entity(&tx, &namespace, &source_entity)?;
631            let target_id = entities::upsert_entity(&tx, &namespace, &target_entity)?;
632            let rel_id = entities::upsert_relationship(&tx, &namespace, source_id, target_id, rel)?;
633            entities::link_memory_relationship(&tx, memory_id, rel_id)?;
634            relationships_persisted += 1;
635        }
636    }
637    tx.commit()?;
638
639    // v1.0.24 P0-2: persist URLs in a dedicated table, outside the main transaction.
640    // Failures do not propagate — non-critical path with graceful degradation.
641    let urls_persisted = if !extracted_urls.is_empty() {
642        let url_entries: Vec<storage_urls::MemoryUrl> = extracted_urls
643            .into_iter()
644            .map(|u| storage_urls::MemoryUrl {
645                url: u.url,
646                offset: Some(u.offset as i64),
647            })
648            .collect();
649        storage_urls::insert_urls(&conn, memory_id, &url_entries)
650    } else {
651        0
652    };
653
654    let created_at_epoch = chrono::Utc::now().timestamp();
655    let created_at_iso = crate::tz::format_iso(chrono::Utc::now());
656
657    output::emit_json(&RememberResponse {
658        memory_id,
659        // Persist the normalized (kebab-case) slug as `name` since that is the
660        // storage key. The original input is exposed via `original_name` only
661        // when normalization actually changed something (B_4 in v1.0.32).
662        name: normalized_name.clone(),
663        namespace,
664        action: action.clone(),
665        operation: action,
666        version,
667        entities_persisted,
668        relationships_persisted,
669        relationships_truncated,
670        chunks_created,
671        chunks_persisted,
672        urls_persisted,
673        extraction_method,
674        merged_into_memory_id: None,
675        warnings,
676        created_at: created_at_epoch,
677        created_at_iso,
678        elapsed_ms: inicio.elapsed().as_millis() as u64,
679        name_was_normalized,
680        original_name: name_was_normalized.then_some(original_name),
681    })?;
682
683    Ok(())
684}
685
686#[cfg(test)]
687mod tests {
688    use crate::output::RememberResponse;
689
690    #[test]
691    fn remember_response_serializes_required_fields() {
692        let resp = RememberResponse {
693            memory_id: 42,
694            name: "minha-mem".to_string(),
695            namespace: "global".to_string(),
696            action: "created".to_string(),
697            operation: "created".to_string(),
698            version: 1,
699            entities_persisted: 0,
700            relationships_persisted: 0,
701            relationships_truncated: false,
702            chunks_created: 1,
703            chunks_persisted: 0,
704            urls_persisted: 0,
705            extraction_method: None,
706            merged_into_memory_id: None,
707            warnings: vec![],
708            created_at: 1_705_320_000,
709            created_at_iso: "2024-01-15T12:00:00Z".to_string(),
710            elapsed_ms: 55,
711            name_was_normalized: false,
712            original_name: None,
713        };
714
715        let json = serde_json::to_value(&resp).expect("serialization failed");
716        assert_eq!(json["memory_id"], 42);
717        assert_eq!(json["action"], "created");
718        assert_eq!(json["operation"], "created");
719        assert_eq!(json["version"], 1);
720        assert_eq!(json["elapsed_ms"], 55u64);
721        assert!(json["warnings"].is_array());
722        assert!(json["merged_into_memory_id"].is_null());
723    }
724
725    #[test]
726    fn remember_response_action_e_operation_sao_aliases() {
727        let resp = RememberResponse {
728            memory_id: 1,
729            name: "mem".to_string(),
730            namespace: "global".to_string(),
731            action: "updated".to_string(),
732            operation: "updated".to_string(),
733            version: 2,
734            entities_persisted: 3,
735            relationships_persisted: 1,
736            relationships_truncated: false,
737            extraction_method: None,
738            chunks_created: 2,
739            chunks_persisted: 2,
740            urls_persisted: 0,
741            merged_into_memory_id: None,
742            warnings: vec![],
743            created_at: 0,
744            created_at_iso: "1970-01-01T00:00:00Z".to_string(),
745            elapsed_ms: 0,
746            name_was_normalized: false,
747            original_name: None,
748        };
749
750        let json = serde_json::to_value(&resp).expect("serialization failed");
751        assert_eq!(
752            json["action"], json["operation"],
753            "action e operation devem ser iguais"
754        );
755        assert_eq!(json["entities_persisted"], 3);
756        assert_eq!(json["relationships_persisted"], 1);
757        assert_eq!(json["chunks_created"], 2);
758    }
759
760    #[test]
761    fn remember_response_warnings_lista_mensagens() {
762        let resp = RememberResponse {
763            memory_id: 5,
764            name: "dup-mem".to_string(),
765            namespace: "global".to_string(),
766            action: "created".to_string(),
767            operation: "created".to_string(),
768            version: 1,
769            entities_persisted: 0,
770            extraction_method: None,
771            relationships_persisted: 0,
772            relationships_truncated: false,
773            chunks_created: 1,
774            chunks_persisted: 0,
775            urls_persisted: 0,
776            merged_into_memory_id: None,
777            warnings: vec!["identical body already exists as memory id 3".to_string()],
778            created_at: 0,
779            created_at_iso: "1970-01-01T00:00:00Z".to_string(),
780            elapsed_ms: 10,
781            name_was_normalized: false,
782            original_name: None,
783        };
784
785        let json = serde_json::to_value(&resp).expect("serialization failed");
786        let warnings = json["warnings"]
787            .as_array()
788            .expect("warnings deve ser array");
789        assert_eq!(warnings.len(), 1);
790        assert!(warnings[0].as_str().unwrap().contains("identical body"));
791    }
792
793    #[test]
794    fn invalid_name_reserved_prefix_returns_validation_error() {
795        use crate::errors::AppError;
796        // Validates the rejection logic for names with the "__" prefix directly
797        let nome = "__reservado";
798        let resultado: Result<(), AppError> = if nome.starts_with("__") {
799            Err(AppError::Validation(
800                crate::i18n::validation::reserved_name(),
801            ))
802        } else {
803            Ok(())
804        };
805        assert!(resultado.is_err());
806        if let Err(AppError::Validation(msg)) = resultado {
807            assert!(!msg.is_empty());
808        }
809    }
810
811    #[test]
812    fn name_too_long_returns_validation_error() {
813        use crate::errors::AppError;
814        let nome_longo = "a".repeat(crate::constants::MAX_MEMORY_NAME_LEN + 1);
815        let resultado: Result<(), AppError> =
816            if nome_longo.is_empty() || nome_longo.len() > crate::constants::MAX_MEMORY_NAME_LEN {
817                Err(AppError::Validation(crate::i18n::validation::name_length(
818                    crate::constants::MAX_MEMORY_NAME_LEN,
819                )))
820            } else {
821                Ok(())
822            };
823        assert!(resultado.is_err());
824    }
825
826    #[test]
827    fn remember_response_merged_into_memory_id_some_serializes_integer() {
828        let resp = RememberResponse {
829            memory_id: 10,
830            name: "mem-mergeada".to_string(),
831            namespace: "global".to_string(),
832            action: "updated".to_string(),
833            operation: "updated".to_string(),
834            version: 3,
835            extraction_method: None,
836            entities_persisted: 0,
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: Some(7),
843            warnings: vec![],
844            created_at: 0,
845            created_at_iso: "1970-01-01T00:00:00Z".to_string(),
846            elapsed_ms: 0,
847            name_was_normalized: false,
848            original_name: None,
849        };
850
851        let json = serde_json::to_value(&resp).expect("serialization failed");
852        assert_eq!(json["merged_into_memory_id"], 7);
853    }
854
855    #[test]
856    fn remember_response_urls_persisted_serializes_field() {
857        // v1.0.24 P0-2: garante que urls_persisted aparece no JSON e aceita valor > 0.
858        let resp = RememberResponse {
859            memory_id: 3,
860            name: "mem-com-urls".to_string(),
861            namespace: "global".to_string(),
862            action: "created".to_string(),
863            operation: "created".to_string(),
864            version: 1,
865            entities_persisted: 0,
866            relationships_persisted: 0,
867            relationships_truncated: false,
868            chunks_created: 1,
869            chunks_persisted: 0,
870            urls_persisted: 3,
871            extraction_method: Some("regex-only".to_string()),
872            merged_into_memory_id: None,
873            warnings: vec![],
874            created_at: 0,
875            created_at_iso: "1970-01-01T00:00:00Z".to_string(),
876            elapsed_ms: 0,
877            name_was_normalized: false,
878            original_name: None,
879        };
880        let json = serde_json::to_value(&resp).expect("serialization failed");
881        assert_eq!(json["urls_persisted"], 3);
882    }
883
884    #[test]
885    fn empty_name_after_normalization_returns_specific_message() {
886        // P0-4 regression: name consisting only of hyphens normalizes to empty string;
887        // must produce a distinct error message, not the "too long" message.
888        use crate::errors::AppError;
889        let normalized = "---".to_lowercase().replace(['_', ' '], "-");
890        let normalized = normalized.trim_matches('-').to_string();
891        let resultado: Result<(), AppError> = if normalized.is_empty() {
892            Err(AppError::Validation(
893                "name cannot be empty after normalization (input was blank or contained only hyphens/underscores/spaces)".to_string(),
894            ))
895        } else {
896            Ok(())
897        };
898        assert!(resultado.is_err());
899        if let Err(AppError::Validation(msg)) = resultado {
900            assert!(
901                msg.contains("empty after normalization"),
902                "mensagem deve mencionar 'empty after normalization', obteve: {msg}"
903            );
904        }
905    }
906
907    #[test]
908    fn name_only_underscores_after_normalization_returns_specific_message() {
909        // P0-4 regression: name consisting only of underscores normalizes to empty string.
910        use crate::errors::AppError;
911        let normalized = "___".to_lowercase().replace(['_', ' '], "-");
912        let normalized = normalized.trim_matches('-').to_string();
913        assert!(
914            normalized.is_empty(),
915            "underscores devem normalizar para string vazia"
916        );
917        let resultado: Result<(), AppError> = if normalized.is_empty() {
918            Err(AppError::Validation(
919                "name cannot be empty after normalization (input was blank or contained only hyphens/underscores/spaces)".to_string(),
920            ))
921        } else {
922            Ok(())
923        };
924        assert!(resultado.is_err());
925        if let Err(AppError::Validation(msg)) = resultado {
926            assert!(
927                msg.contains("empty after normalization"),
928                "mensagem deve mencionar 'empty after normalization', obteve: {msg}"
929            );
930        }
931    }
932
933    #[test]
934    fn remember_response_relationships_truncated_serializes_field() {
935        // P1-D: garante que relationships_truncated aparece no JSON como bool.
936        let resp_false = RememberResponse {
937            memory_id: 1,
938            name: "test".to_string(),
939            namespace: "global".to_string(),
940            action: "created".to_string(),
941            operation: "created".to_string(),
942            version: 1,
943            entities_persisted: 2,
944            relationships_persisted: 1,
945            relationships_truncated: false,
946            chunks_created: 1,
947            chunks_persisted: 0,
948            urls_persisted: 0,
949            extraction_method: None,
950            merged_into_memory_id: None,
951            warnings: vec![],
952            created_at: 0,
953            created_at_iso: "1970-01-01T00:00:00Z".to_string(),
954            elapsed_ms: 0,
955            name_was_normalized: false,
956            original_name: None,
957        };
958        let json_false = serde_json::to_value(&resp_false).expect("serialization failed");
959        assert_eq!(json_false["relationships_truncated"], false);
960
961        let resp_true = RememberResponse {
962            relationships_truncated: true,
963            ..resp_false
964        };
965        let json_true = serde_json::to_value(&resp_true).expect("serialization failed");
966        assert_eq!(json_true["relationships_truncated"], true);
967    }
968
969    #[test]
970    fn is_valid_entity_type_accepts_v008_types() {
971        // V008 added organization, location, date — ensure the validator accepts them.
972        assert!(super::is_valid_entity_type("organization"));
973        assert!(super::is_valid_entity_type("location"));
974        assert!(super::is_valid_entity_type("date"));
975        assert!(!super::is_valid_entity_type("unknown_type_xyz"));
976    }
977}