Skip to main content

sqlite_graphrag/commands/
remember.rs

1use crate::chunking;
2use crate::cli::MemoryType;
3use crate::errors::AppError;
4use crate::i18n::erros;
5use crate::output::{self, JsonOutputFormat, RememberResponse};
6use crate::paths::AppPaths;
7use crate::storage::chunks as storage_chunks;
8use crate::storage::connection::{ensure_schema, open_rw};
9use crate::storage::entities::{NewEntity, NewRelationship};
10use crate::storage::memories::NewMemory;
11use crate::storage::{entities, memories, versions};
12use serde::Deserialize;
13use std::io::Read as _;
14
15#[derive(clap::Args)]
16pub struct RememberArgs {
17    #[arg(long)]
18    pub name: String,
19    #[arg(
20        long,
21        value_enum,
22        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."
23    )]
24    pub r#type: MemoryType,
25    #[arg(long)]
26    pub description: String,
27    #[arg(
28        long,
29        conflicts_with_all = ["body_file", "body_stdin", "graph_stdin"]
30    )]
31    pub body: Option<String>,
32    #[arg(
33        long,
34        help = "Read body from a file instead of --body",
35        conflicts_with_all = ["body", "body_stdin", "graph_stdin"]
36    )]
37    pub body_file: Option<std::path::PathBuf>,
38    #[arg(
39        long,
40        conflicts_with_all = ["body", "body_file", "graph_stdin"]
41    )]
42    pub body_stdin: bool,
43    #[arg(
44        long,
45        help = "JSON file containing entities to associate with this memory"
46    )]
47    pub entities_file: Option<std::path::PathBuf>,
48    #[arg(
49        long,
50        help = "JSON file containing relationships to associate with this memory"
51    )]
52    pub relationships_file: Option<std::path::PathBuf>,
53    #[arg(
54        long,
55        help = "Read graph JSON (body + entities + relationships) from stdin",
56        conflicts_with_all = [
57            "body",
58            "body_file",
59            "body_stdin",
60            "entities_file",
61            "relationships_file"
62        ]
63    )]
64    pub graph_stdin: bool,
65    #[arg(long, default_value = "global")]
66    pub namespace: Option<String>,
67    #[arg(long)]
68    pub metadata: Option<String>,
69    #[arg(long, help = "JSON file containing metadata key-value pairs")]
70    pub metadata_file: Option<std::path::PathBuf>,
71    #[arg(long)]
72    pub force_merge: bool,
73    #[arg(
74        long,
75        value_name = "EPOCH_OR_RFC3339",
76        value_parser = crate::parsers::parse_expected_updated_at,
77        long_help = "Optimistic lock: reject if updated_at does not match. \
78Accepts Unix epoch (e.g. 1700000000) or RFC 3339 (e.g. 2026-04-19T12:00:00Z)."
79    )]
80    pub expected_updated_at: Option<i64>,
81    #[arg(
82        long,
83        help = "Disable automatic entity/relationship extraction from body"
84    )]
85    pub skip_extraction: bool,
86    #[arg(long)]
87    pub session_id: Option<String>,
88    #[arg(long, value_enum, default_value_t = JsonOutputFormat::Json)]
89    pub format: JsonOutputFormat,
90    #[arg(long, help = "No-op; JSON is always emitted on stdout")]
91    pub json: bool,
92    #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
93    pub db: Option<String>,
94}
95
96#[derive(Deserialize, Default)]
97#[serde(deny_unknown_fields)]
98struct GraphInput {
99    #[serde(default)]
100    body: Option<String>,
101    #[serde(default)]
102    entities: Vec<NewEntity>,
103    #[serde(default)]
104    relationships: Vec<NewRelationship>,
105}
106
107fn normalize_and_validate_graph_input(graph: &mut GraphInput) -> Result<(), AppError> {
108    for entity in &graph.entities {
109        if !is_valid_entity_type(&entity.entity_type) {
110            return Err(AppError::Validation(format!(
111                "entity_type '{}' inválido para entidade '{}'",
112                entity.entity_type, entity.name
113            )));
114        }
115    }
116
117    for rel in &mut graph.relationships {
118        rel.relation = rel.relation.replace('-', "_");
119        if !is_valid_relation(&rel.relation) {
120            return Err(AppError::Validation(format!(
121                "relation '{}' inválida para relacionamento '{}' -> '{}'",
122                rel.relation, rel.source, rel.target
123            )));
124        }
125        if !(0.0..=1.0).contains(&rel.strength) {
126            return Err(AppError::Validation(format!(
127                "strength {} inválido para relacionamento '{}' -> '{}'; esperado valor em [0.0, 1.0]",
128                rel.strength, rel.source, rel.target
129            )));
130        }
131    }
132
133    Ok(())
134}
135
136fn is_valid_entity_type(entity_type: &str) -> bool {
137    matches!(
138        entity_type,
139        "project"
140            | "tool"
141            | "person"
142            | "file"
143            | "concept"
144            | "incident"
145            | "decision"
146            | "memory"
147            | "dashboard"
148            | "issue_tracker"
149    )
150}
151
152fn is_valid_relation(relation: &str) -> bool {
153    matches!(
154        relation,
155        "applies_to"
156            | "uses"
157            | "depends_on"
158            | "causes"
159            | "fixes"
160            | "contradicts"
161            | "supports"
162            | "follows"
163            | "related"
164            | "mentions"
165            | "replaces"
166            | "tracked_in"
167    )
168}
169
170pub fn run(args: RememberArgs) -> Result<(), AppError> {
171    use crate::constants::*;
172
173    let inicio = std::time::Instant::now();
174    let _ = args.format;
175    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
176
177    // Auto-normalizar para kebab-case antes de validar (P2-H).
178    let normalized_name = {
179        let n = args.name.to_lowercase().replace(['_', ' '], "-");
180        if n != args.name {
181            tracing::warn!(
182                original = %args.name,
183                normalized = %n,
184                "name auto-normalized to kebab-case"
185            );
186        }
187        n
188    };
189
190    if normalized_name.is_empty() || normalized_name.len() > MAX_MEMORY_NAME_LEN {
191        return Err(AppError::Validation(
192            crate::i18n::validacao::nome_comprimento(MAX_MEMORY_NAME_LEN),
193        ));
194    }
195
196    if normalized_name.starts_with("__") {
197        return Err(AppError::Validation(
198            crate::i18n::validacao::nome_reservado(),
199        ));
200    }
201
202    {
203        let slug_re = regex::Regex::new(crate::constants::NAME_SLUG_REGEX)
204            .map_err(|e| AppError::Internal(anyhow::anyhow!("regex: {e}")))?;
205        if !slug_re.is_match(&normalized_name) {
206            return Err(AppError::Validation(crate::i18n::validacao::nome_kebab(
207                &normalized_name,
208            )));
209        }
210    }
211
212    if args.description.len() > MAX_MEMORY_DESCRIPTION_LEN {
213        return Err(AppError::Validation(
214            crate::i18n::validacao::descricao_excede(MAX_MEMORY_DESCRIPTION_LEN),
215        ));
216    }
217
218    let mut raw_body = if let Some(b) = args.body {
219        b
220    } else if let Some(path) = args.body_file {
221        std::fs::read_to_string(&path).map_err(AppError::Io)?
222    } else if args.body_stdin || args.graph_stdin {
223        let mut buf = String::new();
224        std::io::stdin()
225            .read_to_string(&mut buf)
226            .map_err(AppError::Io)?;
227        buf
228    } else {
229        String::new()
230    };
231
232    let entities_provided_externally =
233        args.entities_file.is_some() || args.relationships_file.is_some() || args.graph_stdin;
234
235    let mut graph = GraphInput::default();
236    if let Some(path) = args.entities_file {
237        let content = std::fs::read_to_string(&path).map_err(AppError::Io)?;
238        graph.entities = serde_json::from_str(&content)?;
239    }
240    if let Some(path) = args.relationships_file {
241        let content = std::fs::read_to_string(&path).map_err(AppError::Io)?;
242        graph.relationships = serde_json::from_str(&content)?;
243    }
244    if args.graph_stdin {
245        graph = serde_json::from_str::<GraphInput>(&raw_body).map_err(|e| {
246            AppError::Validation(format!("payload JSON inválido em --graph-stdin: {e}"))
247        })?;
248        raw_body = graph.body.take().unwrap_or_default();
249    }
250
251    if graph.entities.len() > MAX_ENTITIES_PER_MEMORY {
252        return Err(AppError::LimitExceeded(erros::limite_entidades(
253            MAX_ENTITIES_PER_MEMORY,
254        )));
255    }
256    if graph.relationships.len() > MAX_RELATIONSHIPS_PER_MEMORY {
257        return Err(AppError::LimitExceeded(erros::limite_relacionamentos(
258            MAX_RELATIONSHIPS_PER_MEMORY,
259        )));
260    }
261    normalize_and_validate_graph_input(&mut graph)?;
262
263    if raw_body.len() > MAX_MEMORY_BODY_LEN {
264        return Err(AppError::LimitExceeded(
265            crate::i18n::validacao::body_excede(MAX_MEMORY_BODY_LEN),
266        ));
267    }
268
269    let metadata: serde_json::Value = if let Some(m) = args.metadata {
270        serde_json::from_str(&m)?
271    } else if let Some(path) = args.metadata_file {
272        let content = std::fs::read_to_string(&path).map_err(AppError::Io)?;
273        serde_json::from_str(&content)?
274    } else {
275        serde_json::json!({})
276    };
277
278    let body_hash = blake3::hash(raw_body.as_bytes()).to_hex().to_string();
279    let snippet: String = raw_body.chars().take(200).collect();
280
281    let paths = AppPaths::resolve(args.db.as_deref())?;
282    paths.ensure_dirs()?;
283
284    if !args.skip_extraction
285        && !entities_provided_externally
286        && graph.entities.is_empty()
287        && !raw_body.is_empty()
288    {
289        match crate::extraction::extract_graph_auto(&raw_body, &paths) {
290            Ok(extracted) => {
291                graph.entities = extracted.entities;
292                graph.relationships = extracted.relationships;
293
294                if graph.entities.len() > MAX_ENTITIES_PER_MEMORY {
295                    graph.entities.truncate(MAX_ENTITIES_PER_MEMORY);
296                }
297                if graph.relationships.len() > MAX_RELATIONSHIPS_PER_MEMORY {
298                    graph.relationships.truncate(MAX_RELATIONSHIPS_PER_MEMORY);
299                }
300                normalize_and_validate_graph_input(&mut graph)?;
301            }
302            Err(e) => {
303                tracing::warn!("auto-extraction falhou (graceful degradation): {e:#}");
304            }
305        }
306    }
307
308    let mut conn = open_rw(&paths.db)?;
309    ensure_schema(&mut conn)?;
310
311    {
312        use crate::constants::MAX_NAMESPACES_ACTIVE;
313        let active_count: u32 = conn.query_row(
314            "SELECT COUNT(DISTINCT namespace) FROM memories WHERE deleted_at IS NULL",
315            [],
316            |r| r.get::<_, i64>(0).map(|v| v as u32),
317        )?;
318        let ns_exists: bool = conn.query_row(
319            "SELECT EXISTS(SELECT 1 FROM memories WHERE namespace = ?1 AND deleted_at IS NULL)",
320            rusqlite::params![namespace],
321            |r| r.get::<_, i64>(0).map(|v| v > 0),
322        )?;
323        if !ns_exists && active_count >= MAX_NAMESPACES_ACTIVE {
324            return Err(AppError::NamespaceError(format!(
325                "limite de {MAX_NAMESPACES_ACTIVE} namespaces ativos excedido ao tentar criar '{namespace}'"
326            )));
327        }
328    }
329
330    let existing_memory = memories::find_by_name(&conn, &namespace, &normalized_name)?;
331    if existing_memory.is_some() && !args.force_merge {
332        return Err(AppError::Duplicate(erros::memoria_duplicada(
333            &normalized_name,
334            &namespace,
335        )));
336    }
337
338    let duplicate_hash_id = memories::find_by_hash(&conn, &namespace, &body_hash)?;
339
340    output::emit_progress_i18n(
341        &format!(
342            "Remember stage: validated input; available memory {} MB",
343            crate::memory_guard::available_memory_mb()
344        ),
345        &format!(
346            "Etapa remember: entrada validada; memória disponível {} MB",
347            crate::memory_guard::available_memory_mb()
348        ),
349    );
350
351    let tokenizer = crate::tokenizer::get_tokenizer(&paths.models)?;
352    let model_max_length = crate::tokenizer::get_model_max_length(&paths.models)?;
353    let total_passage_tokens = crate::tokenizer::count_passage_tokens(tokenizer, &raw_body)?;
354    let chunks_info = chunking::split_into_chunks_hierarchical(&raw_body, tokenizer);
355    let chunks_created = chunks_info.len();
356
357    output::emit_progress_i18n(
358        &format!(
359            "Remember stage: tokenizer counted {total_passage_tokens} passage tokens (model max {model_max_length}); chunking produced {} chunks; process RSS {} MB",
360            chunks_created,
361            crate::memory_guard::current_process_memory_mb().unwrap_or(0)
362        ),
363        &format!(
364            "Etapa remember: tokenizer contou {total_passage_tokens} tokens de passagem (máximo do modelo {model_max_length}); chunking gerou {} chunks; RSS do processo {} MB",
365            chunks_created,
366            crate::memory_guard::current_process_memory_mb().unwrap_or(0)
367        ),
368    );
369
370    if chunks_created > crate::constants::REMEMBER_MAX_SAFE_MULTI_CHUNKS {
371        return Err(AppError::LimitExceeded(format!(
372            "documento gera {chunks_created} chunks; limite operacional seguro atual é {} chunks; divida o documento antes de usar remember",
373            crate::constants::REMEMBER_MAX_SAFE_MULTI_CHUNKS
374        )));
375    }
376
377    output::emit_progress_i18n("Computing embedding...", "Calculando embedding...");
378    let mut chunk_embeddings_cache: Option<Vec<Vec<f32>>> = None;
379
380    let embedding = if chunks_info.len() == 1 {
381        crate::daemon::embed_passage_or_local(&paths.models, &raw_body)?
382    } else {
383        let chunk_texts: Vec<&str> = chunks_info
384            .iter()
385            .map(|c| chunking::chunk_text(&raw_body, c))
386            .collect();
387        output::emit_progress_i18n(
388            &format!(
389                "Embedding {} chunks serially to keep memory bounded...",
390                chunks_info.len()
391            ),
392            &format!(
393                "Embedando {} chunks serialmente para manter memória limitada...",
394                chunks_info.len()
395            ),
396        );
397        let mut chunk_embeddings = Vec::with_capacity(chunk_texts.len());
398        for chunk_text in &chunk_texts {
399            chunk_embeddings.push(crate::daemon::embed_passage_or_local(
400                &paths.models,
401                chunk_text,
402            )?);
403        }
404        output::emit_progress_i18n(
405            &format!(
406                "Remember stage: chunk embeddings complete; process RSS {} MB",
407                crate::memory_guard::current_process_memory_mb().unwrap_or(0)
408            ),
409            &format!(
410                "Etapa remember: embeddings dos chunks concluídos; RSS do processo {} MB",
411                crate::memory_guard::current_process_memory_mb().unwrap_or(0)
412            ),
413        );
414        let aggregated = chunking::aggregate_embeddings(&chunk_embeddings);
415        chunk_embeddings_cache = Some(chunk_embeddings);
416        aggregated
417    };
418    let body_for_storage = raw_body;
419
420    let memory_type = args.r#type.as_str();
421    let new_memory = NewMemory {
422        namespace: namespace.clone(),
423        name: normalized_name.clone(),
424        memory_type: memory_type.to_string(),
425        description: args.description.clone(),
426        body: body_for_storage,
427        body_hash: body_hash.clone(),
428        session_id: args.session_id.clone(),
429        source: "agent".to_string(),
430        metadata,
431    };
432
433    let mut warnings = Vec::new();
434    let mut entities_persisted = 0usize;
435    let mut relationships_persisted = 0usize;
436
437    let graph_entity_embeddings = graph
438        .entities
439        .iter()
440        .map(|entity| {
441            let entity_text = match &entity.description {
442                Some(desc) => format!("{} {}", entity.name, desc),
443                None => entity.name.clone(),
444            };
445            crate::daemon::embed_passage_or_local(&paths.models, &entity_text)
446        })
447        .collect::<Result<Vec<_>, _>>()?;
448
449    let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
450
451    let (memory_id, action, version) = match existing_memory {
452        Some((existing_id, _updated_at, _current_version)) => {
453            if let Some(hash_id) = duplicate_hash_id {
454                if hash_id != existing_id {
455                    warnings.push(format!(
456                        "identical body already exists as memory id {hash_id}"
457                    ));
458                }
459            }
460
461            storage_chunks::delete_chunks(&tx, existing_id)?;
462
463            let next_v = versions::next_version(&tx, existing_id)?;
464            memories::update(&tx, existing_id, &new_memory, args.expected_updated_at)?;
465            versions::insert_version(
466                &tx,
467                existing_id,
468                next_v,
469                &normalized_name,
470                memory_type,
471                &args.description,
472                &new_memory.body,
473                &serde_json::to_string(&new_memory.metadata)?,
474                None,
475                "edit",
476            )?;
477            memories::upsert_vec(
478                &tx,
479                existing_id,
480                &namespace,
481                memory_type,
482                &embedding,
483                &normalized_name,
484                &snippet,
485            )?;
486            (existing_id, "updated".to_string(), next_v)
487        }
488        None => {
489            if let Some(hash_id) = duplicate_hash_id {
490                warnings.push(format!(
491                    "identical body already exists as memory id {hash_id}"
492                ));
493            }
494            let id = memories::insert(&tx, &new_memory)?;
495            versions::insert_version(
496                &tx,
497                id,
498                1,
499                &normalized_name,
500                memory_type,
501                &args.description,
502                &new_memory.body,
503                &serde_json::to_string(&new_memory.metadata)?,
504                None,
505                "create",
506            )?;
507            memories::upsert_vec(
508                &tx,
509                id,
510                &namespace,
511                memory_type,
512                &embedding,
513                &normalized_name,
514                &snippet,
515            )?;
516            (id, "created".to_string(), 1)
517        }
518    };
519
520    if chunks_info.len() > 1 {
521        storage_chunks::insert_chunk_slices(&tx, memory_id, &new_memory.body, &chunks_info)?;
522
523        let chunk_embeddings = chunk_embeddings_cache.take().ok_or_else(|| {
524            AppError::Internal(anyhow::anyhow!(
525                "cache de embeddings de chunks ausente no caminho multi-chunk do remember"
526            ))
527        })?;
528
529        for (i, emb) in chunk_embeddings.iter().enumerate() {
530            storage_chunks::upsert_chunk_vec(&tx, i as i64, memory_id, i as i32, emb)?;
531        }
532        output::emit_progress_i18n(
533            &format!(
534                "Remember stage: persisted chunk vectors; process RSS {} MB",
535                crate::memory_guard::current_process_memory_mb().unwrap_or(0)
536            ),
537            &format!(
538                "Etapa remember: vetores de chunks persistidos; RSS do processo {} MB",
539                crate::memory_guard::current_process_memory_mb().unwrap_or(0)
540            ),
541        );
542    }
543
544    if !graph.entities.is_empty() || !graph.relationships.is_empty() {
545        for entity in &graph.entities {
546            let entity_id = entities::upsert_entity(&tx, &namespace, entity)?;
547            let entity_embedding = &graph_entity_embeddings[entities_persisted];
548            entities::upsert_entity_vec(
549                &tx,
550                entity_id,
551                &namespace,
552                &entity.entity_type,
553                entity_embedding,
554                &entity.name,
555            )?;
556            entities::link_memory_entity(&tx, memory_id, entity_id)?;
557            entities::increment_degree(&tx, entity_id)?;
558            entities_persisted += 1;
559        }
560        let entity_types: std::collections::HashMap<&str, &str> = graph
561            .entities
562            .iter()
563            .map(|entity| (entity.name.as_str(), entity.entity_type.as_str()))
564            .collect();
565
566        for rel in &graph.relationships {
567            let source_entity = NewEntity {
568                name: rel.source.clone(),
569                entity_type: entity_types
570                    .get(rel.source.as_str())
571                    .copied()
572                    .unwrap_or("concept")
573                    .to_string(),
574                description: None,
575            };
576            let target_entity = NewEntity {
577                name: rel.target.clone(),
578                entity_type: entity_types
579                    .get(rel.target.as_str())
580                    .copied()
581                    .unwrap_or("concept")
582                    .to_string(),
583                description: None,
584            };
585            let source_id = entities::upsert_entity(&tx, &namespace, &source_entity)?;
586            let target_id = entities::upsert_entity(&tx, &namespace, &target_entity)?;
587            let rel_id = entities::upsert_relationship(&tx, &namespace, source_id, target_id, rel)?;
588            entities::link_memory_relationship(&tx, memory_id, rel_id)?;
589            relationships_persisted += 1;
590        }
591    }
592    tx.commit()?;
593
594    let created_at_epoch = chrono::Utc::now().timestamp();
595    let created_at_iso = crate::tz::formatar_iso(chrono::Utc::now());
596
597    output::emit_json(&RememberResponse {
598        memory_id,
599        name: args.name,
600        namespace,
601        action: action.clone(),
602        operation: action,
603        version,
604        entities_persisted,
605        relationships_persisted,
606        chunks_created,
607        merged_into_memory_id: None,
608        warnings,
609        created_at: created_at_epoch,
610        created_at_iso,
611        elapsed_ms: inicio.elapsed().as_millis() as u64,
612    })?;
613
614    Ok(())
615}
616
617#[cfg(test)]
618mod testes {
619    use crate::output::RememberResponse;
620
621    #[test]
622    fn remember_response_serializa_campos_obrigatorios() {
623        let resp = RememberResponse {
624            memory_id: 42,
625            name: "minha-mem".to_string(),
626            namespace: "global".to_string(),
627            action: "created".to_string(),
628            operation: "created".to_string(),
629            version: 1,
630            entities_persisted: 0,
631            relationships_persisted: 0,
632            chunks_created: 1,
633            merged_into_memory_id: None,
634            warnings: vec![],
635            created_at: 1_705_320_000,
636            created_at_iso: "2024-01-15T12:00:00Z".to_string(),
637            elapsed_ms: 55,
638        };
639
640        let json = serde_json::to_value(&resp).expect("serialização falhou");
641        assert_eq!(json["memory_id"], 42);
642        assert_eq!(json["action"], "created");
643        assert_eq!(json["operation"], "created");
644        assert_eq!(json["version"], 1);
645        assert_eq!(json["elapsed_ms"], 55u64);
646        assert!(json["warnings"].is_array());
647        assert!(json["merged_into_memory_id"].is_null());
648    }
649
650    #[test]
651    fn remember_response_action_e_operation_sao_aliases() {
652        let resp = RememberResponse {
653            memory_id: 1,
654            name: "mem".to_string(),
655            namespace: "global".to_string(),
656            action: "updated".to_string(),
657            operation: "updated".to_string(),
658            version: 2,
659            entities_persisted: 3,
660            relationships_persisted: 1,
661            chunks_created: 2,
662            merged_into_memory_id: None,
663            warnings: vec![],
664            created_at: 0,
665            created_at_iso: "1970-01-01T00:00:00Z".to_string(),
666            elapsed_ms: 0,
667        };
668
669        let json = serde_json::to_value(&resp).expect("serialização falhou");
670        assert_eq!(
671            json["action"], json["operation"],
672            "action e operation devem ser iguais"
673        );
674        assert_eq!(json["entities_persisted"], 3);
675        assert_eq!(json["relationships_persisted"], 1);
676        assert_eq!(json["chunks_created"], 2);
677    }
678
679    #[test]
680    fn remember_response_warnings_lista_mensagens() {
681        let resp = RememberResponse {
682            memory_id: 5,
683            name: "dup-mem".to_string(),
684            namespace: "global".to_string(),
685            action: "created".to_string(),
686            operation: "created".to_string(),
687            version: 1,
688            entities_persisted: 0,
689            relationships_persisted: 0,
690            chunks_created: 1,
691            merged_into_memory_id: None,
692            warnings: vec!["identical body already exists as memory id 3".to_string()],
693            created_at: 0,
694            created_at_iso: "1970-01-01T00:00:00Z".to_string(),
695            elapsed_ms: 10,
696        };
697
698        let json = serde_json::to_value(&resp).expect("serialização falhou");
699        let warnings = json["warnings"]
700            .as_array()
701            .expect("warnings deve ser array");
702        assert_eq!(warnings.len(), 1);
703        assert!(warnings[0].as_str().unwrap().contains("identical body"));
704    }
705
706    #[test]
707    fn nome_invalido_prefixo_reservado_retorna_validation_error() {
708        use crate::errors::AppError;
709        // Valida a lógica de rejeição de nomes com prefixo "__" diretamente
710        let nome = "__reservado";
711        let resultado: Result<(), AppError> = if nome.starts_with("__") {
712            Err(AppError::Validation(
713                crate::i18n::validacao::nome_reservado(),
714            ))
715        } else {
716            Ok(())
717        };
718        assert!(resultado.is_err());
719        if let Err(AppError::Validation(msg)) = resultado {
720            assert!(!msg.is_empty());
721        }
722    }
723
724    #[test]
725    fn nome_muito_longo_retorna_validation_error() {
726        use crate::errors::AppError;
727        let nome_longo = "a".repeat(crate::constants::MAX_MEMORY_NAME_LEN + 1);
728        let resultado: Result<(), AppError> =
729            if nome_longo.is_empty() || nome_longo.len() > crate::constants::MAX_MEMORY_NAME_LEN {
730                Err(AppError::Validation(
731                    crate::i18n::validacao::nome_comprimento(crate::constants::MAX_MEMORY_NAME_LEN),
732                ))
733            } else {
734                Ok(())
735            };
736        assert!(resultado.is_err());
737    }
738
739    #[test]
740    fn remember_response_merged_into_memory_id_some_serializa_inteiro() {
741        let resp = RememberResponse {
742            memory_id: 10,
743            name: "mem-mergeada".to_string(),
744            namespace: "global".to_string(),
745            action: "updated".to_string(),
746            operation: "updated".to_string(),
747            version: 3,
748            entities_persisted: 0,
749            relationships_persisted: 0,
750            chunks_created: 1,
751            merged_into_memory_id: Some(7),
752            warnings: vec![],
753            created_at: 0,
754            created_at_iso: "1970-01-01T00:00:00Z".to_string(),
755            elapsed_ms: 0,
756        };
757
758        let json = serde_json::to_value(&resp).expect("serialização falhou");
759        assert_eq!(json["merged_into_memory_id"], 7);
760    }
761}