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    // v1.0.20: também faz trim de hífens em borda (incluindo trailing) para evitar rejeição
179    // após truncamento por filename longo terminando em hífen.
180    let normalized_name = {
181        let lower = args.name.to_lowercase().replace(['_', ' '], "-");
182        let trimmed = lower.trim_matches('-').to_string();
183        if trimmed != args.name {
184            tracing::warn!(
185                original = %args.name,
186                normalized = %trimmed,
187                "name auto-normalized to kebab-case"
188            );
189        }
190        trimmed
191    };
192
193    if normalized_name.is_empty() || normalized_name.len() > MAX_MEMORY_NAME_LEN {
194        return Err(AppError::Validation(
195            crate::i18n::validacao::nome_comprimento(MAX_MEMORY_NAME_LEN),
196        ));
197    }
198
199    if normalized_name.starts_with("__") {
200        return Err(AppError::Validation(
201            crate::i18n::validacao::nome_reservado(),
202        ));
203    }
204
205    {
206        let slug_re = regex::Regex::new(crate::constants::NAME_SLUG_REGEX)
207            .map_err(|e| AppError::Internal(anyhow::anyhow!("regex: {e}")))?;
208        if !slug_re.is_match(&normalized_name) {
209            return Err(AppError::Validation(crate::i18n::validacao::nome_kebab(
210                &normalized_name,
211            )));
212        }
213    }
214
215    if args.description.len() > MAX_MEMORY_DESCRIPTION_LEN {
216        return Err(AppError::Validation(
217            crate::i18n::validacao::descricao_excede(MAX_MEMORY_DESCRIPTION_LEN),
218        ));
219    }
220
221    let mut raw_body = if let Some(b) = args.body {
222        b
223    } else if let Some(path) = args.body_file {
224        std::fs::read_to_string(&path).map_err(AppError::Io)?
225    } else if args.body_stdin || args.graph_stdin {
226        let mut buf = String::new();
227        std::io::stdin()
228            .read_to_string(&mut buf)
229            .map_err(AppError::Io)?;
230        buf
231    } else {
232        String::new()
233    };
234
235    let entities_provided_externally =
236        args.entities_file.is_some() || args.relationships_file.is_some() || args.graph_stdin;
237
238    let mut graph = GraphInput::default();
239    if let Some(path) = args.entities_file {
240        let content = std::fs::read_to_string(&path).map_err(AppError::Io)?;
241        graph.entities = serde_json::from_str(&content)?;
242    }
243    if let Some(path) = args.relationships_file {
244        let content = std::fs::read_to_string(&path).map_err(AppError::Io)?;
245        graph.relationships = serde_json::from_str(&content)?;
246    }
247    if args.graph_stdin {
248        graph = serde_json::from_str::<GraphInput>(&raw_body).map_err(|e| {
249            AppError::Validation(format!("payload JSON inválido em --graph-stdin: {e}"))
250        })?;
251        raw_body = graph.body.take().unwrap_or_default();
252    }
253
254    if graph.entities.len() > MAX_ENTITIES_PER_MEMORY {
255        return Err(AppError::LimitExceeded(erros::limite_entidades(
256            MAX_ENTITIES_PER_MEMORY,
257        )));
258    }
259    if graph.relationships.len() > MAX_RELATIONSHIPS_PER_MEMORY {
260        return Err(AppError::LimitExceeded(erros::limite_relacionamentos(
261            MAX_RELATIONSHIPS_PER_MEMORY,
262        )));
263    }
264    normalize_and_validate_graph_input(&mut graph)?;
265
266    if raw_body.len() > MAX_MEMORY_BODY_LEN {
267        return Err(AppError::LimitExceeded(
268            crate::i18n::validacao::body_excede(MAX_MEMORY_BODY_LEN),
269        ));
270    }
271
272    let metadata: serde_json::Value = if let Some(m) = args.metadata {
273        serde_json::from_str(&m)?
274    } else if let Some(path) = args.metadata_file {
275        let content = std::fs::read_to_string(&path).map_err(AppError::Io)?;
276        serde_json::from_str(&content)?
277    } else {
278        serde_json::json!({})
279    };
280
281    let body_hash = blake3::hash(raw_body.as_bytes()).to_hex().to_string();
282    let snippet: String = raw_body.chars().take(200).collect();
283
284    let paths = AppPaths::resolve(args.db.as_deref())?;
285    paths.ensure_dirs()?;
286
287    // v1.0.20: usar .trim().is_empty() para rejeitar bodies que são apenas whitespace.
288    let mut extraction_method: Option<String> = None;
289    if !args.skip_extraction
290        && !entities_provided_externally
291        && graph.entities.is_empty()
292        && !raw_body.trim().is_empty()
293    {
294        match crate::extraction::extract_graph_auto(&raw_body, &paths) {
295            Ok(extracted) => {
296                extraction_method = Some(extracted.extraction_method.clone());
297                graph.entities = extracted.entities;
298                graph.relationships = extracted.relationships;
299
300                if graph.entities.len() > MAX_ENTITIES_PER_MEMORY {
301                    graph.entities.truncate(MAX_ENTITIES_PER_MEMORY);
302                }
303                if graph.relationships.len() > MAX_RELATIONSHIPS_PER_MEMORY {
304                    graph.relationships.truncate(MAX_RELATIONSHIPS_PER_MEMORY);
305                }
306                normalize_and_validate_graph_input(&mut graph)?;
307            }
308            Err(e) => {
309                tracing::warn!("auto-extraction falhou (graceful degradation): {e:#}");
310            }
311        }
312    }
313
314    let mut conn = open_rw(&paths.db)?;
315    ensure_schema(&mut conn)?;
316
317    {
318        use crate::constants::MAX_NAMESPACES_ACTIVE;
319        let active_count: u32 = conn.query_row(
320            "SELECT COUNT(DISTINCT namespace) FROM memories WHERE deleted_at IS NULL",
321            [],
322            |r| r.get::<_, i64>(0).map(|v| v as u32),
323        )?;
324        let ns_exists: bool = conn.query_row(
325            "SELECT EXISTS(SELECT 1 FROM memories WHERE namespace = ?1 AND deleted_at IS NULL)",
326            rusqlite::params![namespace],
327            |r| r.get::<_, i64>(0).map(|v| v > 0),
328        )?;
329        if !ns_exists && active_count >= MAX_NAMESPACES_ACTIVE {
330            return Err(AppError::NamespaceError(format!(
331                "limite de {MAX_NAMESPACES_ACTIVE} namespaces ativos excedido ao tentar criar '{namespace}'"
332            )));
333        }
334    }
335
336    let existing_memory = memories::find_by_name(&conn, &namespace, &normalized_name)?;
337    if existing_memory.is_some() && !args.force_merge {
338        return Err(AppError::Duplicate(erros::memoria_duplicada(
339            &normalized_name,
340            &namespace,
341        )));
342    }
343
344    let duplicate_hash_id = memories::find_by_hash(&conn, &namespace, &body_hash)?;
345
346    output::emit_progress_i18n(
347        &format!(
348            "Remember stage: validated input; available memory {} MB",
349            crate::memory_guard::available_memory_mb()
350        ),
351        &format!(
352            "Etapa remember: entrada validada; memória disponível {} MB",
353            crate::memory_guard::available_memory_mb()
354        ),
355    );
356
357    let tokenizer = crate::tokenizer::get_tokenizer(&paths.models)?;
358    let model_max_length = crate::tokenizer::get_model_max_length(&paths.models)?;
359    let total_passage_tokens = crate::tokenizer::count_passage_tokens(tokenizer, &raw_body)?;
360    let chunks_info = chunking::split_into_chunks_hierarchical(&raw_body, tokenizer);
361    let chunks_created = chunks_info.len();
362
363    output::emit_progress_i18n(
364        &format!(
365            "Remember stage: tokenizer counted {total_passage_tokens} passage tokens (model max {model_max_length}); chunking produced {} chunks; process RSS {} MB",
366            chunks_created,
367            crate::memory_guard::current_process_memory_mb().unwrap_or(0)
368        ),
369        &format!(
370            "Etapa remember: tokenizer contou {total_passage_tokens} tokens de passagem (máximo do modelo {model_max_length}); chunking gerou {} chunks; RSS do processo {} MB",
371            chunks_created,
372            crate::memory_guard::current_process_memory_mb().unwrap_or(0)
373        ),
374    );
375
376    if chunks_created > crate::constants::REMEMBER_MAX_SAFE_MULTI_CHUNKS {
377        return Err(AppError::LimitExceeded(format!(
378            "documento gera {chunks_created} chunks; limite operacional seguro atual é {} chunks; divida o documento antes de usar remember",
379            crate::constants::REMEMBER_MAX_SAFE_MULTI_CHUNKS
380        )));
381    }
382
383    output::emit_progress_i18n("Computing embedding...", "Calculando embedding...");
384    let mut chunk_embeddings_cache: Option<Vec<Vec<f32>>> = None;
385
386    let embedding = if chunks_info.len() == 1 {
387        crate::daemon::embed_passage_or_local(&paths.models, &raw_body)?
388    } else {
389        let chunk_texts: Vec<&str> = chunks_info
390            .iter()
391            .map(|c| chunking::chunk_text(&raw_body, c))
392            .collect();
393        output::emit_progress_i18n(
394            &format!(
395                "Embedding {} chunks serially to keep memory bounded...",
396                chunks_info.len()
397            ),
398            &format!(
399                "Embedando {} chunks serialmente para manter memória limitada...",
400                chunks_info.len()
401            ),
402        );
403        let mut chunk_embeddings = Vec::with_capacity(chunk_texts.len());
404        for chunk_text in &chunk_texts {
405            chunk_embeddings.push(crate::daemon::embed_passage_or_local(
406                &paths.models,
407                chunk_text,
408            )?);
409        }
410        output::emit_progress_i18n(
411            &format!(
412                "Remember stage: chunk embeddings complete; process RSS {} MB",
413                crate::memory_guard::current_process_memory_mb().unwrap_or(0)
414            ),
415            &format!(
416                "Etapa remember: embeddings dos chunks concluídos; RSS do processo {} MB",
417                crate::memory_guard::current_process_memory_mb().unwrap_or(0)
418            ),
419        );
420        let aggregated = chunking::aggregate_embeddings(&chunk_embeddings);
421        chunk_embeddings_cache = Some(chunk_embeddings);
422        aggregated
423    };
424    let body_for_storage = raw_body;
425
426    let memory_type = args.r#type.as_str();
427    let new_memory = NewMemory {
428        namespace: namespace.clone(),
429        name: normalized_name.clone(),
430        memory_type: memory_type.to_string(),
431        description: args.description.clone(),
432        body: body_for_storage,
433        body_hash: body_hash.clone(),
434        session_id: args.session_id.clone(),
435        source: "agent".to_string(),
436        metadata,
437    };
438
439    let mut warnings = Vec::new();
440    let mut entities_persisted = 0usize;
441    let mut relationships_persisted = 0usize;
442
443    let graph_entity_embeddings = graph
444        .entities
445        .iter()
446        .map(|entity| {
447            let entity_text = match &entity.description {
448                Some(desc) => format!("{} {}", entity.name, desc),
449                None => entity.name.clone(),
450            };
451            crate::daemon::embed_passage_or_local(&paths.models, &entity_text)
452        })
453        .collect::<Result<Vec<_>, _>>()?;
454
455    let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
456
457    let (memory_id, action, version) = match existing_memory {
458        Some((existing_id, _updated_at, _current_version)) => {
459            if let Some(hash_id) = duplicate_hash_id {
460                if hash_id != existing_id {
461                    warnings.push(format!(
462                        "identical body already exists as memory id {hash_id}"
463                    ));
464                }
465            }
466
467            storage_chunks::delete_chunks(&tx, existing_id)?;
468
469            let next_v = versions::next_version(&tx, existing_id)?;
470            memories::update(&tx, existing_id, &new_memory, args.expected_updated_at)?;
471            versions::insert_version(
472                &tx,
473                existing_id,
474                next_v,
475                &normalized_name,
476                memory_type,
477                &args.description,
478                &new_memory.body,
479                &serde_json::to_string(&new_memory.metadata)?,
480                None,
481                "edit",
482            )?;
483            memories::upsert_vec(
484                &tx,
485                existing_id,
486                &namespace,
487                memory_type,
488                &embedding,
489                &normalized_name,
490                &snippet,
491            )?;
492            (existing_id, "updated".to_string(), next_v)
493        }
494        None => {
495            if let Some(hash_id) = duplicate_hash_id {
496                warnings.push(format!(
497                    "identical body already exists as memory id {hash_id}"
498                ));
499            }
500            let id = memories::insert(&tx, &new_memory)?;
501            versions::insert_version(
502                &tx,
503                id,
504                1,
505                &normalized_name,
506                memory_type,
507                &args.description,
508                &new_memory.body,
509                &serde_json::to_string(&new_memory.metadata)?,
510                None,
511                "create",
512            )?;
513            memories::upsert_vec(
514                &tx,
515                id,
516                &namespace,
517                memory_type,
518                &embedding,
519                &normalized_name,
520                &snippet,
521            )?;
522            (id, "created".to_string(), 1)
523        }
524    };
525
526    if chunks_info.len() > 1 {
527        storage_chunks::insert_chunk_slices(&tx, memory_id, &new_memory.body, &chunks_info)?;
528
529        let chunk_embeddings = chunk_embeddings_cache.take().ok_or_else(|| {
530            AppError::Internal(anyhow::anyhow!(
531                "cache de embeddings de chunks ausente no caminho multi-chunk do remember"
532            ))
533        })?;
534
535        for (i, emb) in chunk_embeddings.iter().enumerate() {
536            storage_chunks::upsert_chunk_vec(&tx, i as i64, memory_id, i as i32, emb)?;
537        }
538        output::emit_progress_i18n(
539            &format!(
540                "Remember stage: persisted chunk vectors; process RSS {} MB",
541                crate::memory_guard::current_process_memory_mb().unwrap_or(0)
542            ),
543            &format!(
544                "Etapa remember: vetores de chunks persistidos; RSS do processo {} MB",
545                crate::memory_guard::current_process_memory_mb().unwrap_or(0)
546            ),
547        );
548    }
549
550    if !graph.entities.is_empty() || !graph.relationships.is_empty() {
551        for entity in &graph.entities {
552            let entity_id = entities::upsert_entity(&tx, &namespace, entity)?;
553            let entity_embedding = &graph_entity_embeddings[entities_persisted];
554            entities::upsert_entity_vec(
555                &tx,
556                entity_id,
557                &namespace,
558                &entity.entity_type,
559                entity_embedding,
560                &entity.name,
561            )?;
562            entities::link_memory_entity(&tx, memory_id, entity_id)?;
563            entities::increment_degree(&tx, entity_id)?;
564            entities_persisted += 1;
565        }
566        let entity_types: std::collections::HashMap<&str, &str> = graph
567            .entities
568            .iter()
569            .map(|entity| (entity.name.as_str(), entity.entity_type.as_str()))
570            .collect();
571
572        for rel in &graph.relationships {
573            let source_entity = NewEntity {
574                name: rel.source.clone(),
575                entity_type: entity_types
576                    .get(rel.source.as_str())
577                    .copied()
578                    .unwrap_or("concept")
579                    .to_string(),
580                description: None,
581            };
582            let target_entity = NewEntity {
583                name: rel.target.clone(),
584                entity_type: entity_types
585                    .get(rel.target.as_str())
586                    .copied()
587                    .unwrap_or("concept")
588                    .to_string(),
589                description: None,
590            };
591            let source_id = entities::upsert_entity(&tx, &namespace, &source_entity)?;
592            let target_id = entities::upsert_entity(&tx, &namespace, &target_entity)?;
593            let rel_id = entities::upsert_relationship(&tx, &namespace, source_id, target_id, rel)?;
594            entities::link_memory_relationship(&tx, memory_id, rel_id)?;
595            relationships_persisted += 1;
596        }
597    }
598    tx.commit()?;
599
600    let created_at_epoch = chrono::Utc::now().timestamp();
601    let created_at_iso = crate::tz::formatar_iso(chrono::Utc::now());
602
603    output::emit_json(&RememberResponse {
604        memory_id,
605        name: args.name,
606        namespace,
607        action: action.clone(),
608        operation: action,
609        version,
610        entities_persisted,
611        relationships_persisted,
612        chunks_created,
613        extraction_method,
614        merged_into_memory_id: None,
615        warnings,
616        created_at: created_at_epoch,
617        created_at_iso,
618        elapsed_ms: inicio.elapsed().as_millis() as u64,
619    })?;
620
621    Ok(())
622}
623
624#[cfg(test)]
625mod testes {
626    use crate::output::RememberResponse;
627
628    #[test]
629    fn remember_response_serializa_campos_obrigatorios() {
630        let resp = RememberResponse {
631            memory_id: 42,
632            name: "minha-mem".to_string(),
633            namespace: "global".to_string(),
634            action: "created".to_string(),
635            operation: "created".to_string(),
636            version: 1,
637            entities_persisted: 0,
638            relationships_persisted: 0,
639            chunks_created: 1,
640            extraction_method: None,
641            merged_into_memory_id: None,
642            warnings: vec![],
643            created_at: 1_705_320_000,
644            created_at_iso: "2024-01-15T12:00:00Z".to_string(),
645            elapsed_ms: 55,
646        };
647
648        let json = serde_json::to_value(&resp).expect("serialização falhou");
649        assert_eq!(json["memory_id"], 42);
650        assert_eq!(json["action"], "created");
651        assert_eq!(json["operation"], "created");
652        assert_eq!(json["version"], 1);
653        assert_eq!(json["elapsed_ms"], 55u64);
654        assert!(json["warnings"].is_array());
655        assert!(json["merged_into_memory_id"].is_null());
656    }
657
658    #[test]
659    fn remember_response_action_e_operation_sao_aliases() {
660        let resp = RememberResponse {
661            memory_id: 1,
662            name: "mem".to_string(),
663            namespace: "global".to_string(),
664            action: "updated".to_string(),
665            operation: "updated".to_string(),
666            version: 2,
667            entities_persisted: 3,
668            relationships_persisted: 1,
669            extraction_method: None,
670            chunks_created: 2,
671            merged_into_memory_id: None,
672            warnings: vec![],
673            created_at: 0,
674            created_at_iso: "1970-01-01T00:00:00Z".to_string(),
675            elapsed_ms: 0,
676        };
677
678        let json = serde_json::to_value(&resp).expect("serialização falhou");
679        assert_eq!(
680            json["action"], json["operation"],
681            "action e operation devem ser iguais"
682        );
683        assert_eq!(json["entities_persisted"], 3);
684        assert_eq!(json["relationships_persisted"], 1);
685        assert_eq!(json["chunks_created"], 2);
686    }
687
688    #[test]
689    fn remember_response_warnings_lista_mensagens() {
690        let resp = RememberResponse {
691            memory_id: 5,
692            name: "dup-mem".to_string(),
693            namespace: "global".to_string(),
694            action: "created".to_string(),
695            operation: "created".to_string(),
696            version: 1,
697            entities_persisted: 0,
698            extraction_method: None,
699            relationships_persisted: 0,
700            chunks_created: 1,
701            merged_into_memory_id: None,
702            warnings: vec!["identical body already exists as memory id 3".to_string()],
703            created_at: 0,
704            created_at_iso: "1970-01-01T00:00:00Z".to_string(),
705            elapsed_ms: 10,
706        };
707
708        let json = serde_json::to_value(&resp).expect("serialização falhou");
709        let warnings = json["warnings"]
710            .as_array()
711            .expect("warnings deve ser array");
712        assert_eq!(warnings.len(), 1);
713        assert!(warnings[0].as_str().unwrap().contains("identical body"));
714    }
715
716    #[test]
717    fn nome_invalido_prefixo_reservado_retorna_validation_error() {
718        use crate::errors::AppError;
719        // Valida a lógica de rejeição de nomes com prefixo "__" diretamente
720        let nome = "__reservado";
721        let resultado: Result<(), AppError> = if nome.starts_with("__") {
722            Err(AppError::Validation(
723                crate::i18n::validacao::nome_reservado(),
724            ))
725        } else {
726            Ok(())
727        };
728        assert!(resultado.is_err());
729        if let Err(AppError::Validation(msg)) = resultado {
730            assert!(!msg.is_empty());
731        }
732    }
733
734    #[test]
735    fn nome_muito_longo_retorna_validation_error() {
736        use crate::errors::AppError;
737        let nome_longo = "a".repeat(crate::constants::MAX_MEMORY_NAME_LEN + 1);
738        let resultado: Result<(), AppError> =
739            if nome_longo.is_empty() || nome_longo.len() > crate::constants::MAX_MEMORY_NAME_LEN {
740                Err(AppError::Validation(
741                    crate::i18n::validacao::nome_comprimento(crate::constants::MAX_MEMORY_NAME_LEN),
742                ))
743            } else {
744                Ok(())
745            };
746        assert!(resultado.is_err());
747    }
748
749    #[test]
750    fn remember_response_merged_into_memory_id_some_serializa_inteiro() {
751        let resp = RememberResponse {
752            memory_id: 10,
753            name: "mem-mergeada".to_string(),
754            namespace: "global".to_string(),
755            action: "updated".to_string(),
756            operation: "updated".to_string(),
757            version: 3,
758            extraction_method: None,
759            entities_persisted: 0,
760            relationships_persisted: 0,
761            chunks_created: 1,
762            merged_into_memory_id: Some(7),
763            warnings: vec![],
764            created_at: 0,
765            created_at_iso: "1970-01-01T00:00:00Z".to_string(),
766            elapsed_ms: 0,
767        };
768
769        let json = serde_json::to_value(&resp).expect("serialização falhou");
770        assert_eq!(json["merged_into_memory_id"], 7);
771    }
772}