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::entity_type::EntityType;
6use crate::errors::AppError;
7use crate::i18n::errors_msg;
8use crate::output::{self, JsonOutputFormat, RememberResponse};
9use crate::paths::AppPaths;
10use crate::storage::chunks as storage_chunks;
11use crate::storage::connection::{ensure_schema, open_rw};
12use crate::storage::entities::{NewEntity, NewRelationship};
13use crate::storage::memories::NewMemory;
14use crate::storage::{entities, memories, urls as storage_urls, versions};
15use serde::Deserialize;
16
17#[derive(clap::Args)]
18#[command(after_long_help = "EXAMPLES:\n  \
19    # Create a memory with inline body\n  \
20    sqlite-graphrag remember --name design-auth --type decision \\\n    \
21    --description \"auth design\" --body \"JWT for stateless auth\"\n\n  \
22    # Create with curated graph via --graph-stdin\n  \
23    echo '{\"body\":\"...\",\"entities\":[],\"relationships\":[]}' | \\\n    \
24    sqlite-graphrag remember --name my-mem --type note --description \"desc\" --graph-stdin\n\n  \
25    # Enable automatic URL extraction with --graph-stdin (URL-regex only since v1.0.79)\n  \
26    echo '{\"body\":\"See https://docs.rs ...\",\"entities\":[],\"relationships\":[]}' | \\\n    \
27    sqlite-graphrag remember --name url-test --type note --description \"test\" \\\n    \
28    --graph-stdin --enable-ner\n\n  \
29    # Idempotent upsert with --force-merge\n  \
30    sqlite-graphrag remember --name my-mem --type note --description \"updated\" \\\n    \
31    --body \"new content\" --force-merge\n\n\
32NOTE:\n  \
33    remember does NOT accept positional arguments.\n  \
34    Use --body \"text\" for inline content\n  \
35    Use --body-file path for file content\n  \
36    Use --body-stdin for piped content\n  \
37    Use --graph-stdin for JSON with entities and relationships\n\n\
38ENTITY TYPES (for --graph-stdin entities, NOT memory --type):\n  \
39    concept, tool, person, file, project, decision, incident,\n  \
40    organization, location, date, dashboard, issue_tracker, memory\n  \
41    WARNING: reference, skill, document, note, user, feedback are\n  \
42    MEMORY types only — NOT valid for entities.\n  \
43    Mapping: reference→concept, document→file, user→person")]
44pub struct RememberArgs {
45    /// Memory name in kebab-case (lowercase letters, digits, hyphens).
46    /// Acts as unique key within the namespace; collisions trigger merge or rejection.
47    #[arg(long)]
48    pub name: String,
49    #[arg(
50        long,
51        value_enum,
52        long_help = "Memory kind stored in `memories.type`. Required when creating a new memory. Optional with --force-merge: if omitted the existing memory type is inherited. This is NOT the graph `entity_type` used in `--entities-file`. Valid values: user, feedback, project, reference, decision, incident, skill, document, note."
53    )]
54    pub r#type: Option<MemoryType>,
55    /// Short description (≤500 chars) summarizing the memory for use in `list` and `recall` snippets.
56    /// Required when creating a new memory. Optional with --force-merge: if omitted the existing description is inherited.
57    ///
58    /// GAP-SG-33: `allow_hyphen_values` lets a description that begins with a
59    /// hyphen (e.g. `"- bullet"`) be accepted as a value instead of being
60    /// mistaken for a flag.
61    #[arg(long, allow_hyphen_values = true)]
62    pub description: Option<String>,
63    /// Inline body content. Mutually exclusive with --body-file, --body-stdin, --graph-stdin.
64    /// Maximum 512000 bytes; rejected if empty without an external graph.
65    ///
66    /// GAP-SG-33: `allow_hyphen_values` lets a body that begins with a hyphen
67    /// (e.g. a markdown bullet list) be accepted as a value.
68    #[arg(
69        long,
70        allow_hyphen_values = true,
71        help = "Inline body content (max 500 KB / 512000 bytes and 30000 estimated tokens; split dense bodies into multiple memories at ~25000 tokens, or use --body-file)",
72        conflicts_with_all = ["body_file", "body_stdin", "graph_stdin"]
73    )]
74    pub body: Option<String>,
75    #[arg(
76        long,
77        help = "Read body from a file instead of --body",
78        conflicts_with_all = ["body", "body_stdin", "graph_stdin"]
79    )]
80    pub body_file: Option<std::path::PathBuf>,
81    /// Read body from stdin until EOF. Useful in pipes (echo "..." | sqlite-graphrag remember ...).
82    /// Mutually exclusive with --body, --body-file, --graph-stdin.
83    #[arg(
84        long,
85        conflicts_with_all = ["body", "body_file", "graph_stdin"]
86    )]
87    pub body_stdin: bool,
88    #[arg(
89        long,
90        help = "JSON file containing entities to associate with this memory"
91    )]
92    pub entities_file: Option<std::path::PathBuf>,
93    #[arg(
94        long,
95        help = "JSON file containing relationships to associate with this memory"
96    )]
97    pub relationships_file: Option<std::path::PathBuf>,
98    #[arg(
99        long,
100        help = "Read graph JSON (body + entities + relationships) from stdin",
101        conflicts_with_all = [
102            "body",
103            "body_file",
104            "body_stdin",
105            "entities_file",
106            "relationships_file",
107            "graph_file"
108        ]
109    )]
110    pub graph_stdin: bool,
111    /// GAP-SG-30: read graph JSON (`{body, entities, relationships}`) from a
112    /// FILE instead of stdin, so a curated graph can combine with a body
113    /// supplied via --body / --body-file / --body-stdin (which previously
114    /// conflicted with --graph-stdin over the single stdin). The file's `body`
115    /// field is used only when no other body source is given; otherwise the
116    /// body source wins and only the file's entities/relationships are applied.
117    #[arg(
118        long,
119        value_name = "PATH",
120        help = "Read graph JSON (body + entities + relationships) from a file (combines with --body/--body-file/--body-stdin)",
121        conflicts_with_all = ["graph_stdin", "entities_file", "relationships_file"]
122    )]
123    pub graph_file: Option<std::path::PathBuf>,
124    #[arg(
125        long,
126        help = "Namespace (flag / XDG namespace.default / global)"
127    )]
128    pub namespace: Option<String>,
129    /// Inline JSON object with arbitrary metadata key-value pairs. Mutually exclusive with --metadata-file.
130    #[arg(long)]
131    pub metadata: Option<String>,
132    #[arg(long, help = "JSON file containing metadata key-value pairs")]
133    pub metadata_file: Option<std::path::PathBuf>,
134    #[arg(long)]
135    pub force_merge: bool,
136    #[arg(
137        long,
138        value_name = "EPOCH_OR_RFC3339",
139        value_parser = crate::parsers::parse_expected_updated_at,
140        long_help = "Optimistic lock: reject if updated_at does not match. \
141Accepts Unix epoch (e.g. 1700000000) or RFC 3339 (e.g. 2026-04-19T12:00:00Z)."
142    )]
143    pub expected_updated_at: Option<i64>,
144    #[arg(
145        long,
146                value_parser = crate::parsers::parse_bool_flexible,
147        action = clap::ArgAction::Set,
148        num_args = 0..=1,
149        default_missing_value = "true",
150        default_value = "false",
151        help = "Enable automatic URL-regex extraction from body (URL-regex only since v1.0.79)"
152    )]
153    pub enable_ner: bool,
154    #[arg(long, hide = true)]
155    pub skip_extraction: bool,
156    /// Explicitly clear the body content (set to empty string). Required to distinguish
157    /// intentional body clearing from accidental omission during --force-merge.
158    /// Without this flag, an empty body passed to --force-merge preserves the existing body.
159    #[arg(
160        long,
161        default_value_t = false,
162        help = "Explicitly clear body content during --force-merge (without this flag, an empty body is ignored and the existing body is kept)"
163    )]
164    pub clear_body: bool,
165    /// Validate input and report planned actions without persisting.
166    #[arg(
167        long,
168        default_value_t = false,
169        help = "Validate input and report planned actions without persisting"
170    )]
171    pub dry_run: bool,
172    /// GAP-SG-37: reject (instead of silently normalizing) when the supplied
173    /// --name is not already canonical kebab-case. Use this when the literal
174    /// name matters and a silent transform would surprise downstream lookups.
175    #[arg(
176        long,
177        default_value_t = false,
178        help = "Reject the write if --name would be normalized to kebab-case (preserve-name guard)"
179    )]
180    pub strict_name: bool,
181    /// GAP-SG-51: with --force-merge, REPLACE the memory's entity/relationship
182    /// bindings with the supplied set instead of merging additively. Combined
183    /// with an empty `entities`/`relationships` payload this clears all bindings
184    /// without deleting the memory.
185    #[arg(
186        long,
187        default_value_t = false,
188        help = "With --force-merge, replace (not merge) the memory's graph bindings; empty entities clears them"
189    )]
190    pub replace_graph: bool,
191    /// Optional opaque session identifier for tracing memory provenance across multi-agent runs.
192    #[arg(long)]
193    pub session_id: Option<String>,
194    #[arg(long, value_enum, default_value_t = JsonOutputFormat::Json)]
195    pub format: JsonOutputFormat,
196    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
197    pub json: bool,
198    #[arg(long)]
199    pub db: Option<String>,
200    /// Maximum process RSS in MiB; abort if exceeded during embedding.
201    #[arg(long, default_value_t = crate::constants::DEFAULT_MAX_RSS_MB,
202          help = "Maximum process RSS in MiB; abort if exceeded during embedding (default: 8192)")]
203    pub max_rss_mb: u64,
204    /// G42/S3 (v1.0.79): maximum simultaneous LLM embedding subprocesses.
205    /// The effective value is further bounded by CPU count and available
206    /// RAM (permits = min(N, cpus, ram_livre*0.5/350MB), clamp [1, 32]).
207    #[arg(long, default_value_t = 4, value_name = "N",
208          value_parser = clap::value_parser!(u64).range(1..=32),
209          help = "Maximum simultaneous LLM embedding subprocesses (default: 4, clamp [1,32])")]
210    pub llm_parallelism: u64,
211    /// GAP-CLI-PRIO-02: after write, enqueue entity-descriptions for the
212    /// entities linked in this call (priority hot set). Default false —
213    /// operators enable when they want automatic priority enrich.
214    #[arg(long, default_value_t = false)]
215    pub enqueue_enrich: bool,
216}
217
218#[derive(Deserialize, Default)]
219#[serde(deny_unknown_fields)]
220struct GraphInput {
221    #[serde(default)]
222    body: Option<String>,
223    #[serde(default)]
224    entities: Vec<NewEntity>,
225    #[serde(default)]
226    relationships: Vec<NewRelationship>,
227}
228
229fn normalize_and_validate_graph_input(graph: &mut GraphInput) -> Result<(), AppError> {
230    for rel in &mut graph.relationships {
231        rel.relation = crate::parsers::normalize_relation(&rel.relation);
232        if let Err(e) = crate::parsers::validate_relation_format(&rel.relation) {
233            return Err(AppError::Validation(format!(
234                "{e} for relationship '{}' -> '{}'",
235                rel.source, rel.target
236            )));
237        }
238        crate::parsers::warn_if_non_canonical(&rel.relation);
239        if !(0.0..=1.0).contains(&rel.strength) {
240            return Err(AppError::Validation(format!(
241                "invalid strength {} for relationship '{}' -> '{}'; expected value in [0.0, 1.0]",
242                rel.strength, rel.source, rel.target
243            )));
244        }
245    }
246
247    Ok(())
248}
249
250#[tracing::instrument(skip_all, level = "debug", name = "remember")]
251pub fn run(
252    args: RememberArgs,
253    llm_backend: crate::cli::LlmBackendChoice,
254    embedding_backend: crate::cli::EmbeddingBackendChoice,
255) -> Result<(), AppError> {
256    use crate::constants::*;
257
258    let inicio = std::time::Instant::now();
259    let _ = args.format;
260    tracing::debug!(target: "remember", name = %args.name, "persisting memory");
261    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
262
263    // Capture the original `--name` before normalization so the JSON response can
264    // surface `name_was_normalized` + `original_name` (B_4 in v1.0.32). Stored as
265    // an owned String because `args.name` is moved into the response below.
266    let original_name = args.name.clone();
267
268    // Auto-normalize to kebab-case before validation (P2-H).
269    // v1.0.20: also trims hyphens at the boundary (including trailing) to avoid rejection
270    // after truncation by a long filename ending in a hyphen.
271    let normalized_name = {
272        let lower = args.name.to_lowercase().replace(['_', ' '], "-");
273        let trimmed = lower.trim_matches('-').to_string();
274        if trimmed != args.name {
275            tracing::warn!(target: "remember",
276                original = %args.name,
277                normalized = %trimmed,
278                "name auto-normalized to kebab-case"
279            );
280        }
281        trimmed
282    };
283    let name_was_normalized = normalized_name != original_name;
284
285    // GAP-SG-37: when --strict-name is set, refuse to silently rewrite the name.
286    // The operator gets the canonical form so they can re-submit it explicitly.
287    if args.strict_name && name_was_normalized {
288        return Err(AppError::Validation(format!(
289            "--strict-name is set but '{original_name}' is not canonical kebab-case; \
290             re-run with --name '{normalized_name}' (or drop --strict-name to allow auto-normalization)"
291        )));
292    }
293
294    if normalized_name.is_empty() {
295        return Err(AppError::Validation(
296            "name cannot be empty after normalization (input was blank or contained only hyphens/underscores/spaces)".to_string(),
297        ));
298    }
299    if normalized_name.len() > MAX_MEMORY_NAME_LEN {
300        return Err(AppError::LimitExceeded(
301            crate::i18n::validation::name_length(MAX_MEMORY_NAME_LEN),
302        ));
303    }
304
305    if normalized_name.starts_with("__") {
306        return Err(AppError::Validation(
307            crate::i18n::validation::reserved_name(),
308        ));
309    }
310
311    {
312        let slug_re = crate::constants::name_slug_regex();
313        if !slug_re.is_match(&normalized_name) {
314            return Err(AppError::Validation(crate::i18n::validation::name_kebab(
315                &normalized_name,
316            )));
317        }
318    }
319
320    if let Some(ref desc) = args.description {
321        if desc.len() > MAX_MEMORY_DESCRIPTION_LEN {
322            return Err(AppError::Validation(
323                crate::i18n::validation::description_exceeds(MAX_MEMORY_DESCRIPTION_LEN),
324            ));
325        }
326    }
327
328    // GAP-SG-30: capture whether the body comes from an explicit source before
329    // `args.body` is moved below; --graph-file only adopts the file's body when
330    // no explicit body source was supplied.
331    let body_explicitly_provided =
332        args.body.is_some() || args.body_file.is_some() || args.body_stdin;
333
334    let mut raw_body = if let Some(b) = args.body {
335        b
336    } else if let Some(ref path) = args.body_file {
337        let file_size = std::fs::metadata(path).map_err(AppError::Io)?.len();
338        if file_size > MAX_MEMORY_BODY_LEN as u64 {
339            return Err(AppError::BodyTooLarge {
340                bytes: file_size,
341                limit: MAX_MEMORY_BODY_LEN as u64,
342            });
343        }
344        match std::fs::read_to_string(path) {
345            Ok(s) => s,
346            Err(e) if e.kind() == std::io::ErrorKind::InvalidData => {
347                let bytes = std::fs::read(path).map_err(AppError::Io)?;
348                tracing::warn!(target: "remember", "body file contains invalid UTF-8; replacing invalid sequences");
349                String::from_utf8_lossy(&bytes).into_owned()
350            }
351            Err(e) => return Err(AppError::Io(e)),
352        }
353    } else if args.body_stdin || args.graph_stdin {
354        crate::stdin_helper::read_stdin_with_timeout(60)?
355    } else {
356        String::new()
357    };
358
359    let mut entities_provided_externally =
360        args.entities_file.is_some() || args.relationships_file.is_some();
361
362    let mut graph = GraphInput::default();
363    if let Some(path) = args.entities_file {
364        let file_size = std::fs::metadata(&path).map_err(AppError::Io)?.len();
365        if file_size > MAX_MEMORY_BODY_LEN as u64 {
366            return Err(AppError::BodyTooLarge {
367                bytes: file_size,
368                limit: MAX_MEMORY_BODY_LEN as u64,
369            });
370        }
371        let content = std::fs::read_to_string(&path).map_err(AppError::Io)?;
372        // v1.1.1 (P7): boundary validation with context — an invalid
373        // entity_type surfaces the FromStr message (13 valid values + hints)
374        // as a Validation error instead of a bare Json error (exit 20).
375        graph.entities = serde_json::from_str(&content)
376            .map_err(|e| AppError::Validation(format!("invalid JSON in --entities-file: {e}")))?;
377    }
378    if let Some(path) = args.relationships_file {
379        let file_size = std::fs::metadata(&path).map_err(AppError::Io)?.len();
380        if file_size > MAX_MEMORY_BODY_LEN as u64 {
381            return Err(AppError::BodyTooLarge {
382                bytes: file_size,
383                limit: MAX_MEMORY_BODY_LEN as u64,
384            });
385        }
386        let content = std::fs::read_to_string(&path).map_err(AppError::Io)?;
387        graph.relationships = serde_json::from_str(&content).map_err(|e| {
388            AppError::Validation(format!("invalid JSON in --relationships-file: {e}"))
389        })?;
390    }
391    if args.graph_stdin {
392        graph = serde_json::from_str::<GraphInput>(&raw_body).map_err(|e| {
393            AppError::Validation(format!("invalid JSON payload on --graph-stdin: {e}"))
394        })?;
395        raw_body = graph.body.take().unwrap_or_default();
396    }
397    if args.graph_stdin && !graph.entities.is_empty() {
398        entities_provided_externally = true;
399    }
400    // GAP-SG-30: graph from a file, combinable with any body source. Conflicts
401    // with --graph-stdin/--entities-file/--relationships-file (enforced by clap),
402    // so `graph` is still empty here when --graph-file is set.
403    if let Some(path) = args.graph_file {
404        let file_size = std::fs::metadata(&path).map_err(AppError::Io)?.len();
405        if file_size > MAX_MEMORY_BODY_LEN as u64 {
406            return Err(AppError::BodyTooLarge {
407                bytes: file_size,
408                limit: MAX_MEMORY_BODY_LEN as u64,
409            });
410        }
411        let content = std::fs::read_to_string(&path).map_err(AppError::Io)?;
412        let mut gf = serde_json::from_str::<GraphInput>(&content)
413            .map_err(|e| AppError::Validation(format!("invalid JSON in --graph-file: {e}")))?;
414        graph.entities = gf.entities;
415        graph.relationships = gf.relationships;
416        if !body_explicitly_provided {
417            raw_body = gf.body.take().unwrap_or_default();
418        }
419        if !graph.entities.is_empty() {
420            entities_provided_externally = true;
421        }
422    }
423
424    if graph.entities.len() > max_entities_per_memory() {
425        return Err(AppError::LimitExceeded(errors_msg::entity_limit_exceeded(
426            max_entities_per_memory(),
427        )));
428    }
429    let mut relationships_truncated = false;
430    let rel_cap = max_relationships_per_memory();
431    if graph.relationships.len() > rel_cap {
432        tracing::warn!(target: "remember",
433            count = graph.relationships.len(),
434            cap = rel_cap,
435            "truncating relationships to cap"
436        );
437        graph.relationships.truncate(rel_cap);
438        relationships_truncated = true;
439    }
440    normalize_and_validate_graph_input(&mut graph)?;
441
442    // v1.1.2 (Gap 2): boundary validation of BOTH payload ceilings — bytes
443    // (BodyTooLarge) and estimated tokens (TooManyTokens), exit 6 — reusing
444    // the same guard the REST embedding client keeps as defence in depth.
445    // The token cap used to fire only deep inside the embedding call.
446    crate::memory_guard::check_embedding_input_size(&raw_body)?;
447
448    // v1.0.22 P1: reject empty or whitespace-only body when no external graph is provided.
449    // Without this check, empty embeddings would be persisted, breaking recall semantics.
450    // GAP-08: skip this guard when --force-merge without --clear-body; the existing body
451    // will be preserved from the database, so the effective body will not be empty.
452    let body_will_be_preserved = args.force_merge && raw_body.trim().is_empty() && !args.clear_body;
453    if !entities_provided_externally
454        && graph.entities.is_empty()
455        && raw_body.trim().is_empty()
456        && !body_will_be_preserved
457        && !args.clear_body
458    {
459        return Err(AppError::Validation(crate::i18n::validation::empty_body()));
460    }
461
462    let metadata: serde_json::Value = if let Some(m) = args.metadata {
463        serde_json::from_str(&m)?
464    } else if let Some(path) = args.metadata_file {
465        let file_size = std::fs::metadata(&path).map_err(AppError::Io)?.len();
466        if file_size > MAX_MEMORY_BODY_LEN as u64 {
467            return Err(AppError::BodyTooLarge {
468                bytes: file_size,
469                limit: MAX_MEMORY_BODY_LEN as u64,
470            });
471        }
472        let content = std::fs::read_to_string(&path).map_err(AppError::Io)?;
473        serde_json::from_str(&content)?
474    } else {
475        serde_json::json!({})
476    };
477
478    let mut body_hash = blake3::hash(raw_body.as_bytes()).to_hex().to_string();
479    let mut snippet: String = raw_body.chars().take(200).collect();
480
481    let paths = AppPaths::resolve(args.db.as_deref())?;
482    paths.ensure_dirs()?;
483
484    // v1.0.20: use .trim().is_empty() to reject bodies that are only whitespace.
485    let mut extraction_method: Option<String> = None;
486    let mut extracted_urls: Vec<crate::extraction::ExtractedUrl> = Vec::with_capacity(4);
487    if args.enable_ner && args.skip_extraction {
488        return Err(AppError::Validation(
489            "--enable-ner and --skip-extraction are mutually exclusive; remove one".to_string(),
490        ));
491    }
492    if args.skip_extraction && !args.enable_ner {
493        // v1.0.74: revert to v1.0.45 hidden no-op behavior. The v1.0.67
494        // commit (9ddb17b) promoted this to a hard validation error, which
495        // broke the "kept as a hidden no-op for backwards compatibility"
496        // promise documented in CHANGELOG v1.0.45 and started failing
497        // 5+ CI jobs whose E2E tests use this flag to skip the
498        // (since-removed) GLiNER-ONNX model download in CI environments.
499        tracing::warn!(
500            "--skip-extraction is deprecated since v1.0.45 and has no effect (NER is disabled by default); remove this flag to silence the warning"
501        );
502    }
503    if args.enable_ner && graph.entities.is_empty() && !raw_body.trim().is_empty() {
504        match crate::extraction::extract_graph_auto(&raw_body, &paths) {
505            Ok(extracted) => {
506                // v1.0.76: ExtractionResult is URL + entity + elapsed_ms;
507                // the LLM ExtractionBackend returns typed relationships
508                // separately. The default build is URL-only extraction.
509                extraction_method = Some("url-regex".to_string());
510                extracted_urls = extracted.urls;
511                // Convert ExtractedEntity → NewEntity (no offsets,
512                // type defaults to Concept).
513                graph.entities = extracted
514                    .entities
515                    .into_iter()
516                    .map(|e| NewEntity {
517                        name: e.name,
518                        entity_type: crate::entity_type::EntityType::Concept,
519                        description: None,
520                    })
521                    .collect();
522                graph.relationships.clear();
523                relationships_truncated = false;
524
525                if graph.entities.len() > max_entities_per_memory() {
526                    graph.entities.truncate(max_entities_per_memory());
527                }
528                if graph.relationships.len() > max_relationships_per_memory() {
529                    relationships_truncated = true;
530                    graph.relationships.truncate(max_relationships_per_memory());
531                }
532                normalize_and_validate_graph_input(&mut graph)?;
533            }
534            Err(e) => {
535                tracing::warn!(target: "remember", error = %e, "auto-extraction failed, graceful degradation");
536                extraction_method = Some("none:extraction-failed".to_string());
537            }
538        }
539    }
540
541    let mut conn = open_rw(&paths.db)?;
542    ensure_schema(&mut conn)?;
543
544    // --dry-run: emit planned action without any DB writes and return.
545    if args.dry_run {
546        let existing = memories::find_by_name(&conn, &namespace, &normalized_name)?;
547        let planned_action = if existing.is_some() && args.force_merge {
548            "would_update"
549        } else {
550            "would_create"
551        };
552        output::emit_json(&serde_json::json!({
553            "dry_run": true,
554            "name": normalized_name,
555            "namespace": namespace,
556            "planned_action": planned_action,
557        }))?;
558        return Ok(());
559    }
560
561    {
562        use crate::constants::MAX_NAMESPACES_ACTIVE;
563        let active_count: u32 = conn.query_row(
564            "SELECT COUNT(DISTINCT namespace) FROM memories WHERE deleted_at IS NULL",
565            [],
566            |r| r.get::<_, i64>(0).map(|v| v as u32),
567        )?;
568        let ns_exists: bool = conn.query_row(
569            "SELECT EXISTS(SELECT 1 FROM memories WHERE namespace = ?1 AND deleted_at IS NULL)",
570            rusqlite::params![namespace],
571            |r| r.get::<_, i64>(0).map(|v| v > 0),
572        )?;
573        if !ns_exists && active_count >= MAX_NAMESPACES_ACTIVE {
574            return Err(AppError::NamespaceError(format!(
575                "active namespace limit of {MAX_NAMESPACES_ACTIVE} reached while trying to create '{namespace}'"
576            )));
577        }
578    }
579
580    // M7: detect soft-deleted memory before the standard duplicate check.
581    if let Some((sd_id, true)) =
582        memories::find_by_name_any_state(&conn, &namespace, &normalized_name)?
583    {
584        if args.force_merge {
585            memories::clear_deleted_at(&conn, sd_id)?;
586        } else {
587            return Err(AppError::Duplicate(
588                errors_msg::duplicate_memory_soft_deleted(&normalized_name, &namespace),
589            ));
590        }
591    }
592
593    let existing_memory = memories::find_by_name(&conn, &namespace, &normalized_name)?;
594    if existing_memory.is_some() && !args.force_merge {
595        return Err(AppError::Duplicate(errors_msg::duplicate_memory(
596            &normalized_name,
597            &namespace,
598        )));
599    }
600
601    // GAP-10: resolve type and description.
602    // For CREATE path (new memory): both are required.
603    // For UPDATE path (--force-merge on existing memory): inherit from existing row when omitted.
604    let (resolved_type, resolved_description) = if existing_memory.is_none() {
605        // CREATE path — both fields are mandatory.
606        let t = args.r#type.ok_or_else(|| {
607            AppError::Validation(
608                "--type and --description are required when creating a new memory".to_string(),
609            )
610        })?;
611        let d = args.description.clone().ok_or_else(|| {
612            AppError::Validation(
613                "--type and --description are required when creating a new memory".to_string(),
614            )
615        })?;
616        (t.as_str().to_string(), d)
617    } else {
618        // UPDATE path (--force-merge) — inherit missing fields from stored row.
619        let existing_row = memories::read_by_name(&conn, &namespace, &normalized_name)?
620            .ok_or_else(|| {
621                AppError::NotFound(format!(
622                    "memory '{normalized_name}' not found in namespace '{namespace}'"
623                ))
624            })?;
625        let t = args
626            .r#type
627            .map(|v| v.as_str().to_string())
628            .unwrap_or_else(|| existing_row.memory_type.clone());
629        let d = args
630            .description
631            .clone()
632            .unwrap_or_else(|| existing_row.description.clone());
633        (t, d)
634    };
635
636    // GAP-08/GAP-09: protect existing body from accidental destruction during --force-merge.
637    // When the caller omits a body (or passes an empty one) without --clear-body, silently
638    // preserve the existing body from the database.  This prevents a common scripting mistake
639    // where a cron job updates metadata fields and inadvertently wipes the stored content.
640    if body_will_be_preserved {
641        if let Some(existing_row) = memories::read_by_name(&conn, &namespace, &normalized_name)? {
642            if !existing_row.body.is_empty() {
643                tracing::debug!(target: "remember",
644                    name = %normalized_name,
645                    "GAP-08: empty body with --force-merge and no --clear-body; preserving existing body"
646                );
647                raw_body = existing_row.body;
648                body_hash = blake3::hash(raw_body.as_bytes()).to_hex().to_string();
649                snippet = raw_body.chars().take(200).collect();
650            }
651        }
652    }
653
654    let duplicate_hash_id = memories::find_by_hash(&conn, &namespace, &body_hash)?;
655
656    output::emit_progress_i18n(
657        &format!(
658            "Remember stage: validated input; available memory {} MB",
659            crate::memory_guard::available_memory_mb()
660        ),
661        &format!(
662            "Stage remember: input validated; available memory {} MB",
663            crate::memory_guard::available_memory_mb()
664        ),
665    );
666
667    let model_max_length = crate::tokenizer::get_model_max_length();
668    let total_passage_tokens = crate::tokenizer::count_passage_tokens(&raw_body)?;
669    let chunks_info = chunking::split_into_chunks_hierarchical(&raw_body);
670    let chunks_created = chunks_info.len();
671    // GAP-SG-40: `chunks_persisted` is no longer a pre-commit estimate. It is
672    // read back from `memory_chunks` AFTER the transaction commits (see below)
673    // so the reported count matches the observable database state. Single-chunk
674    // bodies store inline in the memories row and append no chunk rows.
675
676    output::emit_progress_i18n(
677        &format!(
678            "Remember stage: tokenizer counted {total_passage_tokens} passage tokens (model max {model_max_length}); chunking produced {} chunks; process RSS {} MB",
679            chunks_created,
680            crate::memory_guard::current_process_memory_mb().unwrap_or(0)
681        ),
682        &format!(
683            "Stage remember: tokenizer counted {total_passage_tokens} passage tokens (model max {model_max_length}); chunking produced {} chunks; process RSS {} MB",
684            chunks_created,
685            crate::memory_guard::current_process_memory_mb().unwrap_or(0)
686        ),
687    );
688
689    if chunks_created > crate::constants::REMEMBER_MAX_SAFE_MULTI_CHUNKS {
690        return Err(AppError::TooManyChunks {
691            chunks: chunks_created,
692            limit: crate::constants::REMEMBER_MAX_SAFE_MULTI_CHUNKS,
693        });
694    }
695
696    output::emit_progress_i18n("Computing embedding...", "Calculando embedding...");
697    let mut chunk_embeddings_cache: Option<Vec<Vec<f32>>> = None;
698
699    // v1.0.84 (ADR-0042): extrai o backend que efetivamente executou o
700    // embedding da passagem (ou do batch em chunks) para popular
701    // `backend_invoked` no envelope de resposta.
702    let skip_embed = crate::embedder::should_skip_embedding_on_failure();
703    let (embedding, backend_invoked_passage): (Option<Vec<f32>>, Option<&str>) = if chunks_info
704        .len()
705        == 1
706    {
707        match crate::embedder::embed_passage_with_embedding_choice(
708            &paths.models,
709            &raw_body,
710            embedding_backend,
711            llm_backend,
712        ) {
713            // GAP-CLI-EMBED-NONE (v1.1.8): intentional `--llm-backend none`
714            // returns an empty vector — treat as no embedding (do not
715            // persist a zero-dim vector).
716            Ok((v, k)) if v.is_empty() => (None, Some(k.as_str())),
717            Ok((v, k)) => (Some(v), Some(k.as_str())),
718            // v1.1.2 (Gap 2): permanent payload rejections from the embedder
719            // (typed ceilings) must not be swallowed by --skip-embedding-on-failure.
720            Err(
721                e @ (AppError::Validation(_)
722                | AppError::BodyTooLarge { .. }
723                | AppError::TooManyTokens { .. }),
724            ) => return Err(e),
725            Err(e) if skip_embed => {
726                tracing::warn!(error = %e, "embedding failed; --skip-embedding-on-failure active, persisting without embedding");
727                (None, None)
728            }
729            Err(e) => return Err(e),
730        }
731    } else {
732        let chunk_texts: Vec<String> = chunks_info
733            .iter()
734            .map(|c| chunking::chunk_text(&raw_body, c).to_string())
735            .collect();
736        // G42/S2+S3 (v1.0.79): chunks are embedded in dim-adaptive
737        // batches per LLM call (G44: clamp(base*64/dim, 1, base)), with up to
738        // --llm-parallelism bounded subprocesses in flight. The old
739        // serial loop spent SUM(items) wall time; the fan-out spends
740        // roughly MAX(batch).
741        output::emit_progress_i18n(
742            &format!(
743                "Embedding {} chunks in parallel batches (parallelism {})...",
744                chunks_info.len(),
745                args.llm_parallelism
746            ),
747            &format!(
748                "Embedding {} chunks em lotes paralelos (paralelismo {})...",
749                chunks_info.len(),
750                args.llm_parallelism
751            ),
752        );
753        if let Some(rss) = crate::memory_guard::current_process_memory_mb() {
754            if rss > args.max_rss_mb {
755                tracing::error!(target: "remember",
756                    rss_mb = rss,
757                    max_rss_mb = args.max_rss_mb,
758                    "RSS exceeded --max-rss-mb threshold; aborting to prevent system instability"
759                );
760                return Err(AppError::LowMemory {
761                    available_mb: crate::memory_guard::available_memory_mb(),
762                    required_mb: args.max_rss_mb,
763                });
764            }
765        }
766        match crate::embedder::embed_passages_parallel_with_embedding_choice(
767            &paths.models,
768            &chunk_texts,
769            args.llm_parallelism as usize,
770            crate::embedder::chunk_embed_batch_size(),
771            embedding_backend,
772            llm_backend,
773        ) {
774            Ok(chunk_embeddings) => {
775                output::emit_progress_i18n(
776                    &format!(
777                        "Remember stage: chunk embeddings complete; process RSS {} MB",
778                        crate::memory_guard::current_process_memory_mb().unwrap_or(0)
779                    ),
780                    &format!(
781                        "Stage remember: chunk embeddings completed; process RSS {} MB",
782                        crate::memory_guard::current_process_memory_mb().unwrap_or(0)
783                    ),
784                );
785                let aggregated = chunking::aggregate_embeddings(&chunk_embeddings);
786                chunk_embeddings_cache = Some(chunk_embeddings);
787                (Some(aggregated), None)
788            }
789            Err(e) if skip_embed => {
790                tracing::warn!(error = %e, "chunk embedding failed; --skip-embedding-on-failure active, persisting without embedding");
791                (None, None)
792            }
793            Err(e) => return Err(e),
794        }
795    };
796    let body_for_storage = raw_body;
797
798    let memory_type = resolved_type.as_str();
799    let new_memory = NewMemory {
800        namespace: namespace.clone(),
801        name: normalized_name.clone(),
802        memory_type: memory_type.to_string(),
803        description: resolved_description.clone(),
804        body: body_for_storage,
805        body_hash: body_hash.clone(),
806        session_id: args.session_id.clone(),
807        source: "agent".to_string(),
808        metadata,
809    };
810
811    let mut warnings = Vec::with_capacity(4);
812    let mut entities_persisted = 0usize;
813    let mut relationships_persisted = 0usize;
814
815    // G42/S2+A4 (v1.0.79): entity names are SHORT texts — they get their
816    // own batch profile (25 per LLM call) instead of one subprocess per
817    // 3-15 byte name (21 names used to cost ~12 minutes, 46% of the
818    // measured remember total).
819    let entity_texts: Vec<String> = graph
820        .entities
821        .iter()
822        .map(|entity| match &entity.description {
823            Some(desc) => format!("{} {}", entity.name, desc),
824            None => entity.name.clone(),
825        })
826        .collect();
827    // G56 (v1.0.80): route entity-name embedding through the in-process
828    // cache. Repeated `remember` invocations within one CLI process — and
829    // re-embedded entities inside a single batch — skip the LLM call
830    // entirely when the (model, text) pair was already produced. The
831    // chunk body embedding below still uses `embed_passages_parallel_local`
832    // because chunks are unique per memory and the cache hit rate is
833    // effectively zero.
834    let (graph_entity_embeddings, embed_cache_stats) =
835        match crate::embedder::embed_entity_texts_cached(
836            &paths.models,
837            &entity_texts,
838            args.llm_parallelism as usize,
839            embedding_backend,
840            llm_backend,
841        ) {
842            Ok(r) => r,
843            Err(e) if skip_embed => {
844                tracing::warn!(error = %e, "entity embedding failed; --skip-embedding-on-failure active");
845                let empty: Vec<Vec<f32>> = entity_texts.iter().map(|_| vec![]).collect();
846                (empty, crate::embedder::EmbedCacheStats::default())
847            }
848            Err(e) => return Err(e),
849        };
850    if embed_cache_stats.hits > 0 {
851        tracing::debug!(
852            hits = embed_cache_stats.hits,
853            misses = embed_cache_stats.misses,
854            requested = embed_cache_stats.requested,
855            "G56: entity embed cache hit (remember)"
856        );
857    }
858
859    let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
860
861    let mut skip_reindex = false;
862    let (memory_id, action, version) = match existing_memory {
863        Some((existing_id, _updated_at, _current_version)) => {
864            if let Some(hash_id) = duplicate_hash_id {
865                if hash_id != existing_id {
866                    warnings.push(format!(
867                        "identical body already exists as memory id {hash_id}"
868                    ));
869                }
870            }
871
872            // C1 fix: capture old values for FTS5 sync before update
873            let (old_fts_name, old_fts_desc, old_fts_body): (String, String, String) = tx
874                .query_row(
875                    "SELECT name, description, body FROM memories WHERE id = ?1",
876                    rusqlite::params![existing_id],
877                    |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
878                )?;
879
880            // G15: skip re-indexing when body hash matches (common in --force-merge loops)
881            let existing_body_hash: Option<String> = tx
882                .query_row(
883                    "SELECT body_hash FROM memories WHERE id = ?1",
884                    rusqlite::params![existing_id],
885                    |r| r.get(0),
886                )
887                .ok();
888            let body_unchanged = existing_body_hash.as_deref() == Some(&body_hash);
889            skip_reindex = body_unchanged;
890            if !body_unchanged {
891                storage_chunks::delete_chunks(&tx, existing_id)?;
892            }
893
894            let next_v = versions::next_version(&tx, existing_id)?;
895            memories::update(&tx, existing_id, &new_memory, args.expected_updated_at)?;
896
897            // C1 fix: sync FTS5 external-content index after update
898            // (trg_fts_au trigger is absent by design due to sqlite-vec conflict)
899            memories::sync_fts_after_update(
900                &tx,
901                existing_id,
902                &old_fts_name,
903                &old_fts_desc,
904                &old_fts_body,
905                &normalized_name,
906                &resolved_description,
907                &new_memory.body,
908            )?;
909
910            versions::insert_version(
911                &tx,
912                existing_id,
913                next_v,
914                &normalized_name,
915                memory_type,
916                &resolved_description,
917                &new_memory.body,
918                &serde_json::to_string(&new_memory.metadata)?,
919                None,
920                "edit",
921            )?;
922            if !body_unchanged {
923                if let Some(ref emb) = embedding {
924                    memories::upsert_vec(
925                        &tx,
926                        existing_id,
927                        &namespace,
928                        memory_type,
929                        emb,
930                        &normalized_name,
931                        &snippet,
932                    )?;
933                }
934            }
935            (existing_id, "updated".to_string(), next_v)
936        }
937        None => {
938            if let Some(hash_id) = duplicate_hash_id {
939                warnings.push(format!(
940                    "identical body already exists as memory id {hash_id}"
941                ));
942            }
943            let id = memories::insert(&tx, &new_memory)?;
944            versions::insert_version(
945                &tx,
946                id,
947                1,
948                &normalized_name,
949                memory_type,
950                &resolved_description,
951                &new_memory.body,
952                &serde_json::to_string(&new_memory.metadata)?,
953                None,
954                "create",
955            )?;
956            if let Some(ref emb) = embedding {
957                memories::upsert_vec(
958                    &tx,
959                    id,
960                    &namespace,
961                    memory_type,
962                    emb,
963                    &normalized_name,
964                    &snippet,
965                )?;
966            }
967            (id, "created".to_string(), 1)
968        }
969    };
970
971    // GAP-SG-51: when --force-merge --replace-graph updates an existing memory,
972    // clear its prior entity/relationship bindings BEFORE re-linking the supplied
973    // set. With an empty `entities`/`relationships` payload this zeroes the graph
974    // for that memory without a `forget`. New bindings (if any) are linked by the
975    // block further below.
976    if args.replace_graph && action == "updated" {
977        let (e_removed, r_removed) = entities::clear_memory_graph_bindings(&tx, memory_id)?;
978        if e_removed + r_removed > 0 {
979            warnings.push(format!(
980                "--replace-graph cleared {e_removed} entity binding(s) and {r_removed} relationship binding(s) before re-linking"
981            ));
982        }
983    }
984
985    if chunks_info.len() > 1 && !skip_reindex {
986        storage_chunks::insert_chunk_slices(&tx, memory_id, &new_memory.body, &chunks_info)?;
987
988        if let Some(chunk_embeddings) = chunk_embeddings_cache.take() {
989            for (i, emb) in chunk_embeddings.iter().enumerate() {
990                storage_chunks::upsert_chunk_vec(&tx, i as i64, memory_id, i as i32, emb)?;
991            }
992        }
993        output::emit_progress_i18n(
994            &format!(
995                "Remember stage: persisted chunk vectors; process RSS {} MB",
996                crate::memory_guard::current_process_memory_mb().unwrap_or(0)
997            ),
998            &format!(
999                "Etapa remember: vetores de chunks persistidos; RSS do processo {} MB",
1000                crate::memory_guard::current_process_memory_mb().unwrap_or(0)
1001            ),
1002        );
1003    }
1004
1005    if !graph.entities.is_empty() || !graph.relationships.is_empty() {
1006        for entity in &graph.entities {
1007            let entity_id = entities::upsert_entity(&tx, &namespace, entity)?;
1008            let entity_embedding = &graph_entity_embeddings[entities_persisted];
1009            entities::upsert_entity_vec(
1010                &tx,
1011                entity_id,
1012                &namespace,
1013                entity.entity_type,
1014                entity_embedding,
1015                &entity.name,
1016            )?;
1017            entities::link_memory_entity(&tx, memory_id, entity_id)?;
1018            entities_persisted += 1;
1019        }
1020        let entity_types: std::collections::HashMap<&str, EntityType> = graph
1021            .entities
1022            .iter()
1023            .map(|entity| (entity.name.as_str(), entity.entity_type))
1024            .collect();
1025
1026        let mut affected_entity_ids: std::collections::HashSet<i64> =
1027            std::collections::HashSet::new();
1028        for entity in &graph.entities {
1029            if let Some(eid) = entities::find_entity_id(&tx, &namespace, &entity.name)? {
1030                affected_entity_ids.insert(eid);
1031            }
1032        }
1033
1034        for rel in &graph.relationships {
1035            let source_entity = NewEntity {
1036                name: rel.source.clone(),
1037                entity_type: entity_types
1038                    .get(rel.source.as_str())
1039                    .copied()
1040                    .unwrap_or(EntityType::Concept),
1041                description: None,
1042            };
1043            let target_entity = NewEntity {
1044                name: rel.target.clone(),
1045                entity_type: entity_types
1046                    .get(rel.target.as_str())
1047                    .copied()
1048                    .unwrap_or(EntityType::Concept),
1049                description: None,
1050            };
1051            let source_id = entities::upsert_entity(&tx, &namespace, &source_entity)?;
1052            let target_id = entities::upsert_entity(&tx, &namespace, &target_entity)?;
1053            let rel_id = entities::upsert_relationship(&tx, &namespace, source_id, target_id, rel)?;
1054            entities::link_memory_relationship(&tx, memory_id, rel_id)?;
1055            affected_entity_ids.insert(source_id);
1056            affected_entity_ids.insert(target_id);
1057            relationships_persisted += 1;
1058        }
1059
1060        for &eid in &affected_entity_ids {
1061            entities::recalculate_degree(&tx, eid)?;
1062        }
1063    }
1064    tx.commit()?;
1065
1066    // GAP-SG-40: read back the real chunk-row count now that the write is
1067    // durable, so `chunks_persisted` reflects observable state (0 for inline
1068    // single-chunk bodies, the exact row count for multi-chunk bodies).
1069    let chunks_persisted = storage_chunks::count_for_memory(&conn, memory_id)?;
1070
1071    // GAP-SG-44: confirm the memory has a persisted embedding vector. A missing
1072    // vector (embedding step failed/skipped) makes the memory unsearchable
1073    // silently; surface it as a warning recommending re-embed instead of leaving
1074    // `health` to report `vec_memories_missing` later.
1075    if !new_memory.body.trim().is_empty() {
1076        let has_vec: bool = conn
1077            .query_row(
1078                "SELECT EXISTS(SELECT 1 FROM memory_embeddings WHERE memory_id = ?1)",
1079                rusqlite::params![memory_id],
1080                |r| r.get::<_, i64>(0).map(|v| v > 0),
1081            )
1082            .unwrap_or(false);
1083        if !has_vec {
1084            tracing::warn!(target: "remember",
1085                memory_id,
1086                name = %normalized_name,
1087                "memory persisted without an embedding vector; recall will be degraded until re-embedded"
1088            );
1089            warnings.push(
1090                "memory persisted without an embedding vector; run `enrich --operation re-embed` to make it searchable"
1091                    .to_string(),
1092            );
1093        }
1094    }
1095
1096    // GAP-SG-13: when --force-merge UPDATES an existing memory its body/graph may
1097    // have changed, so drop any stale enrich-queue sidecar entry keyed to it. The
1098    // next enrich run re-scans it cleanly. Best-effort; no-op when the queue file
1099    // is absent.
1100    if action == "updated" {
1101        crate::commands::enrich::cleanup_queue_entry(&paths.db, memory_id, &normalized_name);
1102    }
1103
1104    // v1.0.24 P0-2: persist URLs in a dedicated table, outside the main transaction.
1105    // Failures do not propagate — non-critical path with graceful degradation.
1106    let urls_persisted = if !extracted_urls.is_empty() {
1107        let url_entries: Vec<storage_urls::MemoryUrl> = extracted_urls
1108            .into_iter()
1109            .map(|u| storage_urls::MemoryUrl {
1110                url: u.url,
1111                offset: Some(u.start as i64),
1112            })
1113            .collect();
1114        storage_urls::insert_urls(&conn, memory_id, &url_entries)
1115    } else {
1116        0
1117    };
1118
1119    conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
1120
1121    let created_at_epoch = chrono::Utc::now().timestamp();
1122    let created_at_iso = crate::tz::format_iso(chrono::Utc::now());
1123
1124    // GAP-CLI-PRIO-01: hot-set entity names for priority entity-descriptions.
1125    let entities_created: Vec<String> = graph
1126        .entities
1127        .iter()
1128        .map(|e| e.name.clone())
1129        .collect();
1130
1131    // GAP-CLI-PRIO-01 / G-T-ONESHOT-02: recommend entity-descriptions when
1132    // entities were linked without descriptions (typical after graph-stdin).
1133    let mut enrich_recommended: Vec<String> = Vec::new();
1134    if entities_persisted > 0 {
1135        enrich_recommended.push("entity-descriptions".to_string());
1136    }
1137
1138    // GAP-CLI-PRIO-02: optional priority enqueue into the enrich sidecar.
1139    if args.enqueue_enrich && !entities_created.is_empty() {
1140        match crate::commands::enrich::enqueue_priority_entity_descriptions(
1141            &paths,
1142            &namespace,
1143            &entities_created,
1144        ) {
1145            Ok(n) => {
1146                tracing::info!(
1147                    target: "remember",
1148                    enqueued = n,
1149                    "priority entity-descriptions enqueued"
1150                );
1151            }
1152            Err(e) => {
1153                warnings.push(format!(
1154                    "enqueue_enrich failed (entities still listed in entities_created): {e}"
1155                ));
1156            }
1157        }
1158    }
1159
1160    output::emit_json(&RememberResponse {
1161        memory_id,
1162        // Persist the normalized (kebab-case) slug as `name` since that is the
1163        // storage key. The original input is exposed via `original_name` only
1164        // when normalization actually changed something (B_4 in v1.0.32).
1165        name: normalized_name.clone(),
1166        namespace,
1167        action: action.clone(),
1168        operation: action,
1169        version,
1170        entities_persisted,
1171        relationships_persisted,
1172        relationships_truncated,
1173        chunks_created,
1174        chunks_persisted,
1175        urls_persisted,
1176        extraction_method,
1177        merged_into_memory_id: None,
1178        warnings,
1179        created_at: created_at_epoch,
1180        created_at_iso,
1181        elapsed_ms: inicio.elapsed().as_millis() as u64,
1182        name_was_normalized,
1183        original_name: name_was_normalized.then_some(original_name),
1184        backend_invoked: backend_invoked_passage,
1185        entities_created,
1186        enrich_recommended,
1187    })?;
1188
1189    Ok(())
1190}
1191
1192#[cfg(test)]
1193mod tests {
1194    use crate::output::RememberResponse;
1195
1196    /// GAP-SG-37: replicates the `--strict-name` guard predicate so the
1197    /// reject-on-normalization decision is unit-testable without a DB.
1198    fn strict_name_rejects(strict: bool, name_was_normalized: bool) -> bool {
1199        strict && name_was_normalized
1200    }
1201
1202    #[test]
1203    fn strict_name_rejects_only_when_name_would_change() {
1204        assert!(
1205            strict_name_rejects(true, true),
1206            "strict + changed must reject"
1207        );
1208        assert!(
1209            !strict_name_rejects(true, false),
1210            "strict + canonical passes"
1211        );
1212        assert!(
1213            !strict_name_rejects(false, true),
1214            "non-strict always passes"
1215        );
1216        assert!(!strict_name_rejects(false, false));
1217    }
1218
1219    // GAP-SG-37/SG-51: --strict-name and --replace-graph must parse on remember.
1220    #[test]
1221    fn remember_parses_strict_name_and_replace_graph_flags() {
1222        use crate::cli::{Cli, Commands};
1223        use clap::Parser;
1224        let cli = Cli::try_parse_from([
1225            "sqlite-graphrag",
1226            "remember",
1227            "--name",
1228            "my-mem",
1229            "--type",
1230            "note",
1231            "--description",
1232            "d",
1233            "--body",
1234            "b",
1235            "--strict-name",
1236            "--replace-graph",
1237            "--force-merge",
1238        ])
1239        .expect("parse");
1240        match cli.command {
1241            Some(Commands::Remember(a)) => {
1242                assert!(a.strict_name);
1243                assert!(a.replace_graph);
1244                assert!(a.force_merge);
1245            }
1246            other => panic!("expected remember, got {other:?}"),
1247        }
1248    }
1249
1250    #[test]
1251    fn remember_response_serializes_required_fields() {
1252        let resp = RememberResponse {
1253            memory_id: 42,
1254            name: "minha-mem".to_string(),
1255            namespace: "global".to_string(),
1256            action: "created".to_string(),
1257            operation: "created".to_string(),
1258            version: 1,
1259            entities_persisted: 0,
1260            relationships_persisted: 0,
1261            relationships_truncated: false,
1262            chunks_created: 1,
1263            chunks_persisted: 0,
1264            urls_persisted: 0,
1265            extraction_method: None,
1266            merged_into_memory_id: None,
1267            warnings: vec![],
1268            created_at: 1_705_320_000,
1269            created_at_iso: "2024-01-15T12:00:00Z".to_string(),
1270            elapsed_ms: 55,
1271            name_was_normalized: false,
1272            original_name: None,
1273            backend_invoked: None,
1274            entities_created: vec![],
1275            enrich_recommended: vec![],
1276        };
1277
1278        let json = serde_json::to_value(&resp).expect("serialization failed");
1279        assert_eq!(json["memory_id"], 42);
1280        assert_eq!(json["action"], "created");
1281        assert_eq!(json["operation"], "created");
1282        assert_eq!(json["version"], 1);
1283        assert_eq!(json["elapsed_ms"], 55u64);
1284        assert!(json["warnings"].is_array());
1285        assert!(json["merged_into_memory_id"].is_null());
1286    }
1287
1288    #[test]
1289    fn remember_response_action_e_operation_sao_aliases() {
1290        let resp = RememberResponse {
1291            memory_id: 1,
1292            name: "mem".to_string(),
1293            namespace: "global".to_string(),
1294            action: "updated".to_string(),
1295            operation: "updated".to_string(),
1296            version: 2,
1297            entities_persisted: 3,
1298            relationships_persisted: 1,
1299            relationships_truncated: false,
1300            extraction_method: None,
1301            chunks_created: 2,
1302            chunks_persisted: 2,
1303            urls_persisted: 0,
1304            merged_into_memory_id: None,
1305            warnings: vec![],
1306            created_at: 0,
1307            created_at_iso: "1970-01-01T00:00:00Z".to_string(),
1308            elapsed_ms: 0,
1309            name_was_normalized: false,
1310            original_name: None,
1311            backend_invoked: None,
1312            entities_created: vec![],
1313            enrich_recommended: vec![],
1314        };
1315
1316        let json = serde_json::to_value(&resp).expect("serialization failed");
1317        assert_eq!(
1318            json["action"], json["operation"],
1319            "action e operation devem ser iguais"
1320        );
1321        assert_eq!(json["entities_persisted"], 3);
1322        assert_eq!(json["relationships_persisted"], 1);
1323        assert_eq!(json["chunks_created"], 2);
1324    }
1325
1326    #[test]
1327    fn remember_response_warnings_lista_mensagens() {
1328        let resp = RememberResponse {
1329            memory_id: 5,
1330            name: "dup-mem".to_string(),
1331            namespace: "global".to_string(),
1332            action: "created".to_string(),
1333            operation: "created".to_string(),
1334            version: 1,
1335            entities_persisted: 0,
1336            extraction_method: None,
1337            relationships_persisted: 0,
1338            relationships_truncated: false,
1339            chunks_created: 1,
1340            chunks_persisted: 0,
1341            urls_persisted: 0,
1342            merged_into_memory_id: None,
1343            warnings: vec!["identical body already exists as memory id 3".to_string()],
1344            created_at: 0,
1345            created_at_iso: "1970-01-01T00:00:00Z".to_string(),
1346            elapsed_ms: 10,
1347            name_was_normalized: false,
1348            original_name: None,
1349            backend_invoked: None,
1350            entities_created: vec![],
1351            enrich_recommended: vec![],
1352        };
1353
1354        let json = serde_json::to_value(&resp).expect("serialization failed");
1355        let warnings = json["warnings"]
1356            .as_array()
1357            .expect("warnings deve ser array");
1358        assert_eq!(warnings.len(), 1);
1359        assert!(warnings[0].as_str().unwrap().contains("identical body"));
1360    }
1361
1362    #[test]
1363    fn invalid_name_reserved_prefix_returns_validation_error() {
1364        use crate::errors::AppError;
1365        // Validates the rejection logic for names with the "__" prefix directly
1366        let nome = "__reservado";
1367        let resultado: Result<(), AppError> = if nome.starts_with("__") {
1368            Err(AppError::Validation(
1369                crate::i18n::validation::reserved_name(),
1370            ))
1371        } else {
1372            Ok(())
1373        };
1374        assert!(resultado.is_err());
1375        if let Err(AppError::Validation(msg)) = resultado {
1376            assert!(!msg.is_empty());
1377        }
1378    }
1379
1380    #[test]
1381    fn name_too_long_returns_validation_error() {
1382        use crate::errors::AppError;
1383        let nome_longo = "a".repeat(crate::constants::MAX_MEMORY_NAME_LEN + 1);
1384        let resultado: Result<(), AppError> =
1385            if nome_longo.is_empty() || nome_longo.len() > crate::constants::MAX_MEMORY_NAME_LEN {
1386                Err(AppError::Validation(crate::i18n::validation::name_length(
1387                    crate::constants::MAX_MEMORY_NAME_LEN,
1388                )))
1389            } else {
1390                Ok(())
1391            };
1392        assert!(resultado.is_err());
1393    }
1394
1395    #[test]
1396    fn remember_response_merged_into_memory_id_some_serializes_integer() {
1397        let resp = RememberResponse {
1398            memory_id: 10,
1399            name: "mem-mergeada".to_string(),
1400            namespace: "global".to_string(),
1401            action: "updated".to_string(),
1402            operation: "updated".to_string(),
1403            version: 3,
1404            extraction_method: None,
1405            entities_persisted: 0,
1406            relationships_persisted: 0,
1407            relationships_truncated: false,
1408            chunks_created: 1,
1409            chunks_persisted: 0,
1410            urls_persisted: 0,
1411            merged_into_memory_id: Some(7),
1412            warnings: vec![],
1413            created_at: 0,
1414            created_at_iso: "1970-01-01T00:00:00Z".to_string(),
1415            elapsed_ms: 0,
1416            name_was_normalized: false,
1417            original_name: None,
1418            backend_invoked: None,
1419            entities_created: vec![],
1420            enrich_recommended: vec![],
1421        };
1422
1423        let json = serde_json::to_value(&resp).expect("serialization failed");
1424        assert_eq!(json["merged_into_memory_id"], 7);
1425    }
1426
1427    #[test]
1428    fn remember_response_urls_persisted_serializes_field() {
1429        // v1.0.24 P0-2: garante que urls_persisted aparece no JSON e aceita valor > 0.
1430        let resp = RememberResponse {
1431            memory_id: 3,
1432            name: "mem-com-urls".to_string(),
1433            namespace: "global".to_string(),
1434            action: "created".to_string(),
1435            operation: "created".to_string(),
1436            version: 1,
1437            entities_persisted: 0,
1438            relationships_persisted: 0,
1439            relationships_truncated: false,
1440            chunks_created: 1,
1441            chunks_persisted: 0,
1442            urls_persisted: 3,
1443            extraction_method: Some("regex-only".to_string()),
1444            merged_into_memory_id: None,
1445            warnings: vec![],
1446            created_at: 0,
1447            created_at_iso: "1970-01-01T00:00:00Z".to_string(),
1448            elapsed_ms: 0,
1449            name_was_normalized: false,
1450            original_name: None,
1451            backend_invoked: None,
1452            entities_created: vec![],
1453            enrich_recommended: vec![],
1454        };
1455        let json = serde_json::to_value(&resp).expect("serialization failed");
1456        assert_eq!(json["urls_persisted"], 3);
1457    }
1458
1459    #[test]
1460    fn empty_name_after_normalization_returns_specific_message() {
1461        // P0-4 regression: name consisting only of hyphens normalizes to empty string;
1462        // must produce a distinct error message, not the "too long" message.
1463        use crate::errors::AppError;
1464        let normalized = "---".to_lowercase().replace(['_', ' '], "-");
1465        let normalized = normalized.trim_matches('-').to_string();
1466        let resultado: Result<(), AppError> = if normalized.is_empty() {
1467            Err(AppError::Validation(
1468                "name cannot be empty after normalization (input was blank or contained only hyphens/underscores/spaces)".to_string(),
1469            ))
1470        } else {
1471            Ok(())
1472        };
1473        assert!(resultado.is_err());
1474        if let Err(AppError::Validation(msg)) = resultado {
1475            assert!(
1476                msg.contains("empty after normalization"),
1477                "mensagem deve mencionar 'empty after normalization', obteve: {msg}"
1478            );
1479        }
1480    }
1481
1482    #[test]
1483    fn name_only_underscores_after_normalization_returns_specific_message() {
1484        // P0-4 regression: name consisting only of underscores normalizes to empty string.
1485        use crate::errors::AppError;
1486        let normalized = "___".to_lowercase().replace(['_', ' '], "-");
1487        let normalized = normalized.trim_matches('-').to_string();
1488        assert!(
1489            normalized.is_empty(),
1490            "underscores devem normalizar para string vazia"
1491        );
1492        let resultado: Result<(), AppError> = if normalized.is_empty() {
1493            Err(AppError::Validation(
1494                "name cannot be empty after normalization (input was blank or contained only hyphens/underscores/spaces)".to_string(),
1495            ))
1496        } else {
1497            Ok(())
1498        };
1499        assert!(resultado.is_err());
1500        if let Err(AppError::Validation(msg)) = resultado {
1501            assert!(
1502                msg.contains("empty after normalization"),
1503                "mensagem deve mencionar 'empty after normalization', obteve: {msg}"
1504            );
1505        }
1506    }
1507
1508    #[test]
1509    fn remember_response_relationships_truncated_serializes_field() {
1510        // P1-D: garante que relationships_truncated aparece no JSON como bool.
1511        let resp_false = RememberResponse {
1512            memory_id: 1,
1513            name: "test".to_string(),
1514            namespace: "global".to_string(),
1515            action: "created".to_string(),
1516            operation: "created".to_string(),
1517            version: 1,
1518            entities_persisted: 2,
1519            relationships_persisted: 1,
1520            relationships_truncated: false,
1521            chunks_created: 1,
1522            chunks_persisted: 0,
1523            urls_persisted: 0,
1524            extraction_method: None,
1525            merged_into_memory_id: None,
1526            warnings: vec![],
1527            created_at: 0,
1528            created_at_iso: "1970-01-01T00:00:00Z".to_string(),
1529            elapsed_ms: 0,
1530            name_was_normalized: false,
1531            original_name: None,
1532            backend_invoked: None,
1533            entities_created: vec![],
1534            enrich_recommended: vec![],
1535        };
1536        let json_false = serde_json::to_value(&resp_false).expect("serialization failed");
1537        assert_eq!(json_false["relationships_truncated"], false);
1538
1539        let resp_true = RememberResponse {
1540            relationships_truncated: true,
1541            ..resp_false
1542        };
1543        let json_true = serde_json::to_value(&resp_true).expect("serialization failed");
1544        assert_eq!(json_true["relationships_truncated"], true);
1545    }
1546
1547    // GAP-08: body-preservation predicate tests.
1548    // Verifies the decision logic that determines whether an existing body should
1549    // be kept instead of overwritten with an empty incoming body during --force-merge.
1550
1551    /// Returns `true` when the existing body should be preserved.
1552    ///
1553    /// Mirrors the `body_will_be_preserved` expression in `run()` so the logic
1554    /// is testable without a real database connection.
1555    fn should_preserve_body(force_merge: bool, raw_body_is_empty: bool, clear_body: bool) -> bool {
1556        force_merge && raw_body_is_empty && !clear_body
1557    }
1558
1559    #[test]
1560    fn gap08_empty_body_force_merge_no_clear_body_preserves() {
1561        // Caller passes no body with --force-merge but without --clear-body.
1562        // The existing body in the DB must be kept.
1563        assert!(
1564            should_preserve_body(true, true, false),
1565            "empty body + force-merge + no clear-body should trigger preservation"
1566        );
1567    }
1568
1569    #[test]
1570    fn gap08_empty_body_force_merge_with_clear_body_does_not_preserve() {
1571        // Caller explicitly passes --clear-body; intentional wipe is honoured.
1572        assert!(
1573            !should_preserve_body(true, true, true),
1574            "--clear-body must bypass preservation"
1575        );
1576    }
1577
1578    #[test]
1579    fn gap08_non_empty_body_force_merge_does_not_preserve() {
1580        // Caller provides a real body; it must overwrite the existing one.
1581        assert!(
1582            !should_preserve_body(true, false, false),
1583            "non-empty body must overwrite, not preserve"
1584        );
1585    }
1586
1587    #[test]
1588    fn gap08_empty_body_no_force_merge_does_not_preserve() {
1589        // Without --force-merge the path is a fresh create; no preservation needed.
1590        assert!(
1591            !should_preserve_body(false, true, false),
1592            "no --force-merge means no preservation logic applies"
1593        );
1594    }
1595}