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