Skip to main content

sqlite_graphrag/commands/remember/
run.rs

1//! Orchestration entry point for the `remember` command.
2
3use super::args::RememberArgs;
4use super::graph_input::{normalize_and_validate_graph_input, GraphInput};
5use crate::chunking;
6use crate::entity_type::EntityType;
7use crate::errors::AppError;
8use crate::i18n::errors_msg;
9use crate::output;
10use crate::paths::AppPaths;
11use crate::storage::chunks as storage_chunks;
12use crate::storage::connection::{ensure_schema, open_rw};
13use crate::storage::entities::{self as entities, NewEntity};
14use crate::storage::memories::{self as memories, NewMemory};
15use crate::storage::versions;
16
17/// Run the `remember` command: validate, embed, persist memory + graph.
18pub fn run(
19    args: RememberArgs,
20    llm_backend: crate::cli::LlmBackendChoice,
21    embedding_backend: crate::cli::EmbeddingBackendChoice,
22) -> Result<(), AppError> {
23    use crate::constants::*;
24
25    let inicio = std::time::Instant::now();
26    let _ = args.format;
27    tracing::debug!(target: "remember", name = %args.name, "persisting memory");
28    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
29
30    // Capture the original `--name` before normalization so the JSON response can
31    // surface `name_was_normalized` + `original_name` (B_4 in v1.0.32). Stored as
32    // an owned String because `args.name` is moved into the response below.
33    let original_name = args.name.clone();
34
35    // Auto-normalize to kebab-case before validation (P2-H).
36    // v1.0.20: also trims hyphens at the boundary (including trailing) to avoid rejection
37    // after truncation by a long filename ending in a hyphen.
38    let normalized_name = {
39        let lower = args.name.to_lowercase().replace(['_', ' '], "-");
40        let trimmed = lower.trim_matches('-').to_string();
41        if trimmed != args.name {
42            tracing::warn!(target: "remember",
43                original = %args.name,
44                normalized = %trimmed,
45                "name auto-normalized to kebab-case"
46            );
47        }
48        trimmed
49    };
50    let name_was_normalized = normalized_name != original_name;
51
52    // GAP-SG-37: when --strict-name is set, refuse to silently rewrite the name.
53    // The operator gets the canonical form so they can re-submit it explicitly.
54    if args.strict_name && name_was_normalized {
55        return Err(AppError::Validation(
56            crate::i18n::validation::strict_name_not_canonical(&original_name, &normalized_name),
57        ));
58    }
59
60    if normalized_name.is_empty() {
61        return Err(AppError::Validation(
62            crate::i18n::validation::name_empty_after_normalization(),
63        ));
64    }
65    if normalized_name.len() > MAX_MEMORY_NAME_LEN {
66        return Err(AppError::LimitExceeded(
67            crate::i18n::validation::name_length(MAX_MEMORY_NAME_LEN),
68        ));
69    }
70
71    if normalized_name.starts_with("__") {
72        return Err(AppError::Validation(
73            crate::i18n::validation::reserved_name(),
74        ));
75    }
76
77    {
78        let slug_re = crate::constants::name_slug_regex();
79        if !slug_re.is_match(&normalized_name) {
80            return Err(AppError::Validation(crate::i18n::validation::name_kebab(
81                &normalized_name,
82            )));
83        }
84    }
85
86    if let Some(ref desc) = args.description {
87        if desc.len() > MAX_MEMORY_DESCRIPTION_LEN {
88            return Err(AppError::Validation(
89                crate::i18n::validation::description_exceeds(MAX_MEMORY_DESCRIPTION_LEN),
90            ));
91        }
92    }
93
94    // GAP-SG-30: capture whether the body comes from an explicit source before
95    // `args.body` is moved below; --graph-file only adopts the file's body when
96    // no explicit body source was supplied.
97    let body_explicitly_provided =
98        args.body.is_some() || args.body_file.is_some() || args.body_stdin;
99
100    let mut raw_body = if let Some(ref b) = args.body {
101        b.clone()
102    } else if let Some(ref path) = args.body_file {
103        let file_size = std::fs::metadata(path).map_err(AppError::Io)?.len();
104        if file_size > MAX_MEMORY_BODY_LEN as u64 {
105            return Err(AppError::BodyTooLarge {
106                bytes: file_size,
107                limit: MAX_MEMORY_BODY_LEN as u64,
108            });
109        }
110        match std::fs::read_to_string(path) {
111            Ok(s) => s,
112            Err(e) if e.kind() == std::io::ErrorKind::InvalidData => {
113                let bytes = std::fs::read(path).map_err(AppError::Io)?;
114                tracing::warn!(target: "remember", "body file contains invalid UTF-8; replacing invalid sequences");
115                String::from_utf8_lossy(&bytes).into_owned()
116            }
117            Err(e) => return Err(AppError::Io(e)),
118        }
119    } else if args.body_stdin || args.graph_stdin {
120        crate::stdin_helper::read_stdin_with_timeout(60)?
121    } else {
122        String::new()
123    };
124
125    let mut entities_provided_externally =
126        args.entities_file.is_some() || args.relationships_file.is_some();
127
128    let mut graph = GraphInput::default();
129    if let Some(ref path) = args.entities_file {
130        let file_size = std::fs::metadata(path).map_err(AppError::Io)?.len();
131        if file_size > MAX_MEMORY_BODY_LEN as u64 {
132            return Err(AppError::BodyTooLarge {
133                bytes: file_size,
134                limit: MAX_MEMORY_BODY_LEN as u64,
135            });
136        }
137        let content = std::fs::read_to_string(path).map_err(AppError::Io)?;
138        // v1.1.1 (P7): boundary validation with context — an invalid
139        // entity_type surfaces the FromStr message (13 valid values + hints)
140        // as a Validation error instead of a bare Json error (exit 20).
141        graph.entities = serde_json::from_str(&content).map_err(|e| {
142            AppError::Validation(crate::i18n::validation::invalid_json_in_flag(
143                "--entities-file",
144                &e,
145            ))
146        })?;
147    }
148    if let Some(ref path) = args.relationships_file {
149        let file_size = std::fs::metadata(path).map_err(AppError::Io)?.len();
150        if file_size > MAX_MEMORY_BODY_LEN as u64 {
151            return Err(AppError::BodyTooLarge {
152                bytes: file_size,
153                limit: MAX_MEMORY_BODY_LEN as u64,
154            });
155        }
156        let content = std::fs::read_to_string(path).map_err(AppError::Io)?;
157        graph.relationships = serde_json::from_str(&content).map_err(|e| {
158            AppError::Validation(crate::i18n::validation::invalid_json_in_flag(
159                "--relationships-file",
160                &e,
161            ))
162        })?;
163    }
164    if args.graph_stdin {
165        graph = serde_json::from_str::<GraphInput>(&raw_body).map_err(|e| {
166            AppError::Validation(crate::i18n::validation::invalid_json_payload_on_flag(
167                "--graph-stdin",
168                &e,
169            ))
170        })?;
171        raw_body = graph.body.take().unwrap_or_default();
172    }
173    if args.graph_stdin && !graph.entities.is_empty() {
174        entities_provided_externally = true;
175    }
176    // GAP-SG-30: graph from a file, combinable with any body source. Conflicts
177    // with --graph-stdin/--entities-file/--relationships-file (enforced by clap),
178    // so `graph` is still empty here when --graph-file is set.
179    if let Some(ref path) = args.graph_file {
180        let file_size = std::fs::metadata(path).map_err(AppError::Io)?.len();
181        if file_size > MAX_MEMORY_BODY_LEN as u64 {
182            return Err(AppError::BodyTooLarge {
183                bytes: file_size,
184                limit: MAX_MEMORY_BODY_LEN as u64,
185            });
186        }
187        let content = std::fs::read_to_string(path).map_err(AppError::Io)?;
188        let mut gf = serde_json::from_str::<GraphInput>(&content).map_err(|e| {
189            AppError::Validation(crate::i18n::validation::invalid_json_in_flag(
190                "--graph-file",
191                &e,
192            ))
193        })?;
194        graph.entities = gf.entities;
195        graph.relationships = gf.relationships;
196        if !body_explicitly_provided {
197            raw_body = gf.body.take().unwrap_or_default();
198        }
199        if !graph.entities.is_empty() {
200            entities_provided_externally = true;
201        }
202    }
203
204    if graph.entities.len() > max_entities_per_memory() {
205        return Err(AppError::LimitExceeded(errors_msg::entity_limit_exceeded(
206            max_entities_per_memory(),
207        )));
208    }
209    let mut relationships_updated = false;
210    let rel_cap = max_relationships_per_memory();
211    if graph.relationships.len() > rel_cap {
212        tracing::warn!(target: "remember",
213            count = graph.relationships.len(),
214            cap = rel_cap,
215            "truncating relationships to cap"
216        );
217        graph.relationships.truncate(rel_cap);
218        relationships_updated = true;
219    }
220    normalize_and_validate_graph_input(&mut graph)?;
221
222    // v1.1.2 (Gap 2): boundary validation of BOTH payload ceilings — bytes
223    // (BodyTooLarge) and estimated tokens (TooManyTokens), exit 6 — reusing
224    // the same guard the REST embedding client keeps as defence in depth.
225    // The token cap used to fire only deep inside the embedding call.
226    crate::memory_guard::check_embedding_input_size(&raw_body)?;
227
228    // v1.0.22 P1: reject empty or whitespace-only body when no external graph is provided.
229    // Without this check, empty embeddings would be persisted, breaking recall semantics.
230    // GAP-08: skip this guard when --force-merge without --clear-body; the existing body
231    // will be preserved from the database, so the effective body will not be empty.
232    let body_will_be_preserved = args.force_merge && raw_body.trim().is_empty() && !args.clear_body;
233    if !entities_provided_externally
234        && graph.entities.is_empty()
235        && raw_body.trim().is_empty()
236        && !body_will_be_preserved
237        && !args.clear_body
238    {
239        return Err(AppError::Validation(crate::i18n::validation::empty_body()));
240    }
241
242    let metadata: serde_json::Value = if let Some(ref m) = args.metadata {
243        serde_json::from_str(m)?
244    } else if let Some(ref path) = args.metadata_file {
245        let file_size = std::fs::metadata(path).map_err(AppError::Io)?.len();
246        if file_size > MAX_MEMORY_BODY_LEN as u64 {
247            return Err(AppError::BodyTooLarge {
248                bytes: file_size,
249                limit: MAX_MEMORY_BODY_LEN as u64,
250            });
251        }
252        let content = std::fs::read_to_string(path).map_err(AppError::Io)?;
253        serde_json::from_str(&content)?
254    } else {
255        serde_json::json!({})
256    };
257
258    let mut body_hash = blake3::hash(raw_body.as_bytes()).to_hex().to_string();
259    let mut snippet: String = raw_body.chars().take(200).collect();
260
261    let paths = AppPaths::resolve(args.db.as_deref())?;
262    paths.ensure_dirs()?;
263
264    // v1.0.20: use .trim().is_empty() to reject bodies that are only whitespace.
265    let mut extraction_method: Option<String> = None;
266    let mut extracted_urls: Vec<crate::extraction::ExtractedUrl> = Vec::with_capacity(4);
267    if args.enable_ner && args.skip_extraction {
268        return Err(AppError::Validation(
269            crate::i18n::validation::enable_ner_skip_extraction_exclusive(),
270        ));
271    }
272    if args.skip_extraction && !args.enable_ner {
273        // v1.0.74: revert to v1.0.45 hidden no-op behavior. The v1.0.67
274        // commit (9ddb17b) promoted this to a hard validation error, which
275        // broke the "kept as a hidden no-op for backwards compatibility"
276        // promise documented in CHANGELOG v1.0.45 and started failing
277        // 5+ CI jobs whose E2E tests use this flag to skip the
278        // (since-removed) GLiNER-ONNX model download in CI environments.
279        tracing::warn!(
280            "--skip-extraction is deprecated since v1.0.45 and has no effect (NER is disabled by default); remove this flag to silence the warning"
281        );
282    }
283    if args.enable_ner && graph.entities.is_empty() && !raw_body.trim().is_empty() {
284        match crate::extraction::extract_graph_auto(&raw_body, &paths) {
285            Ok(extracted) => {
286                // v1.0.76: ExtractionResult is URL + entity + elapsed_ms;
287                // the LLM ExtractionBackend returns typed relationships
288                // separately. The default build is URL-only extraction.
289                extraction_method = Some("url-regex".to_string());
290                extracted_urls = extracted.urls;
291                // Convert ExtractedEntity → NewEntity (no offsets,
292                // type defaults to Concept).
293                graph.entities = extracted
294                    .entities
295                    .into_iter()
296                    .map(|e| NewEntity {
297                        name: e.name,
298                        entity_type: crate::entity_type::EntityType::Concept,
299                        description: None,
300                    })
301                    .collect();
302                graph.relationships.clear();
303                relationships_updated = false;
304
305                if graph.entities.len() > max_entities_per_memory() {
306                    graph.entities.truncate(max_entities_per_memory());
307                }
308                if graph.relationships.len() > max_relationships_per_memory() {
309                    relationships_updated = true;
310                    graph.relationships.truncate(max_relationships_per_memory());
311                }
312                normalize_and_validate_graph_input(&mut graph)?;
313            }
314            Err(e) => {
315                tracing::warn!(target: "remember", error = %e, "auto-extraction failed, graceful degradation");
316                extraction_method = Some("none:extraction-failed".to_string());
317            }
318        }
319    }
320
321    let mut conn = open_rw(&paths.db)?;
322    ensure_schema(&mut conn)?;
323
324    // --dry-run: emit planned action without any DB writes and return.
325    if args.dry_run {
326        let existing = memories::find_by_name(&conn, &namespace, &normalized_name)?;
327        let planned_action = if existing.is_some() && args.force_merge {
328            "would_update"
329        } else {
330            "would_create"
331        };
332        output::emit_json(&serde_json::json!({
333            "dry_run": true,
334            "name": normalized_name,
335            "namespace": namespace,
336            "planned_action": planned_action,
337        }))?;
338        return Ok(());
339    }
340
341    {
342        use crate::constants::MAX_NAMESPACES_ACTIVE;
343        let active_count: u32 = conn.query_row(
344            "SELECT COUNT(DISTINCT namespace) FROM memories WHERE deleted_at IS NULL",
345            [],
346            |r| r.get::<_, i64>(0).map(|v| v as u32),
347        )?;
348        let ns_exists: bool = conn.query_row(
349            "SELECT EXISTS(SELECT 1 FROM memories WHERE namespace = ?1 AND deleted_at IS NULL)",
350            rusqlite::params![namespace],
351            |r| r.get::<_, i64>(0).map(|v| v > 0),
352        )?;
353        if !ns_exists && active_count >= MAX_NAMESPACES_ACTIVE {
354            return Err(AppError::NamespaceError(format!(
355                "active namespace limit of {MAX_NAMESPACES_ACTIVE} reached while trying to create '{namespace}'"
356            )));
357        }
358    }
359
360    // M7: detect soft-deleted memory before the standard duplicate check.
361    if let Some((sd_id, true)) =
362        memories::find_by_name_any_state(&conn, &namespace, &normalized_name)?
363    {
364        if args.force_merge {
365            memories::clear_deleted_at(&conn, sd_id)?;
366        } else {
367            return Err(AppError::Duplicate(
368                errors_msg::duplicate_memory_soft_deleted(&normalized_name, &namespace),
369            ));
370        }
371    }
372
373    let existing_memory = memories::find_by_name(&conn, &namespace, &normalized_name)?;
374    if existing_memory.is_some() && !args.force_merge {
375        return Err(AppError::Duplicate(errors_msg::duplicate_memory(
376            &normalized_name,
377            &namespace,
378        )));
379    }
380
381    // GAP-10: resolve type and description.
382    // For CREATE path (new memory): both are required.
383    // For UPDATE path (--force-merge on existing memory): inherit from existing row when omitted.
384    let (resolved_type, resolved_description) = if existing_memory.is_none() {
385        // CREATE path — both fields are mandatory.
386        let t = args.r#type.ok_or_else(|| {
387            AppError::Validation(crate::i18n::validation::type_and_description_required())
388        })?;
389        let d = args.description.clone().ok_or_else(|| {
390            AppError::Validation(crate::i18n::validation::type_and_description_required())
391        })?;
392        (t.as_str().to_string(), d)
393    } else {
394        // UPDATE path (--force-merge) — inherit missing fields from stored row.
395        let existing_row = memories::read_by_name(&conn, &namespace, &normalized_name)?
396            .ok_or_else(|| {
397                AppError::NotFound(format!(
398                    "memory '{normalized_name}' not found in namespace '{namespace}'"
399                ))
400            })?;
401        let t = args
402            .r#type
403            .map(|v| v.as_str().to_string())
404            .unwrap_or_else(|| existing_row.memory_type.clone());
405        let d = args
406            .description
407            .clone()
408            .unwrap_or_else(|| existing_row.description.clone());
409        (t, d)
410    };
411
412    // GAP-08/GAP-09: protect existing body from accidental destruction during --force-merge.
413    // When the caller omits a body (or passes an empty one) without --clear-body, silently
414    // preserve the existing body from the database.  This prevents a common scripting mistake
415    // where a cron job updates metadata fields and inadvertently wipes the stored content.
416    if body_will_be_preserved {
417        if let Some(existing_row) = memories::read_by_name(&conn, &namespace, &normalized_name)? {
418            if !existing_row.body.is_empty() {
419                tracing::debug!(target: "remember",
420                    name = %normalized_name,
421                    "GAP-08: empty body with --force-merge and no --clear-body; preserving existing body"
422                );
423                raw_body = existing_row.body;
424                body_hash = blake3::hash(raw_body.as_bytes()).to_hex().to_string();
425                snippet = raw_body.chars().take(200).collect();
426            }
427        }
428    }
429
430    let duplicate_hash_id = memories::find_by_hash(&conn, &namespace, &body_hash)?;
431
432    output::emit_progress_i18n(
433        &format!(
434            "Remember stage: validated input; available memory {} MB",
435            crate::memory_guard::available_memory_mb()
436        ),
437        &format!(
438            "Stage remember: input validated; available memory {} MB",
439            crate::memory_guard::available_memory_mb()
440        ),
441    );
442
443    let model_max_length = crate::tokenizer::get_model_max_length();
444    let total_passage_tokens = crate::tokenizer::count_passage_tokens(&raw_body)?;
445    let chunks_info = chunking::split_into_chunks_hierarchical(&raw_body);
446    let chunks_created = chunks_info.len();
447    // GAP-SG-40: `chunks_persisted` is no longer a pre-commit estimate. It is
448    // read back from `memory_chunks` AFTER the transaction commits (see below)
449    // so the reported count matches the observable database state. Single-chunk
450    // bodies store inline in the memories row and append no chunk rows.
451
452    output::emit_progress_i18n(
453        &format!(
454            "Remember stage: tokenizer counted {total_passage_tokens} passage tokens (model max {model_max_length}); chunking produced {} chunks; process RSS {} MB",
455            chunks_created,
456            crate::memory_guard::current_process_memory_mb().unwrap_or(0)
457        ),
458        &format!(
459            "Stage remember: tokenizer counted {total_passage_tokens} passage tokens (model max {model_max_length}); chunking produced {} chunks; process RSS {} MB",
460            chunks_created,
461            crate::memory_guard::current_process_memory_mb().unwrap_or(0)
462        ),
463    );
464
465    if chunks_created > crate::constants::REMEMBER_MAX_SAFE_MULTI_CHUNKS {
466        return Err(AppError::TooManyChunks {
467            chunks: chunks_created,
468            limit: crate::constants::REMEMBER_MAX_SAFE_MULTI_CHUNKS,
469        });
470    }
471
472    let embed_out = super::embed_phase::run_embed_phase(
473        &paths,
474        &raw_body,
475        &chunks_info,
476        &graph,
477        &args,
478        embedding_backend,
479        llm_backend,
480    )?;
481    let embedding = embed_out.embedding;
482    let backend_invoked_passage = embed_out.backend_invoked_passage;
483    let mut chunk_embeddings_cache = embed_out.chunk_embeddings_cache;
484    let graph_entity_embeddings = embed_out.graph_entity_embeddings;
485    let _skip_embed = embed_out.skip_embed;
486
487    let body_for_storage = raw_body;
488    let memory_type = resolved_type.as_str();
489    let new_memory = NewMemory {
490        namespace: namespace.clone(),
491        name: normalized_name.clone(),
492        memory_type: memory_type.to_string(),
493        description: resolved_description.clone(),
494        body: body_for_storage,
495        body_hash: body_hash.clone(),
496        session_id: args.session_id.clone(),
497        source: "agent".to_string(),
498        metadata,
499    };
500
501    let mut warnings = Vec::with_capacity(4);
502    let mut entities_persisted = 0usize;
503    let mut relationships_persisted = 0usize;
504
505    let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
506
507    let mut skip_reindex = false;
508    let (memory_id, action, version) = match existing_memory {
509        Some((existing_id, _updated_at, _current_version)) => {
510            if let Some(hash_id) = duplicate_hash_id {
511                if hash_id != existing_id {
512                    warnings.push(format!(
513                        "identical body already exists as memory id {hash_id}"
514                    ));
515                }
516            }
517
518            // C1 fix: capture old values for FTS5 sync before update
519            let (old_fts_name, old_fts_desc, old_fts_body): (String, String, String) = tx
520                .query_row(
521                    "SELECT name, description, body FROM memories WHERE id = ?1",
522                    rusqlite::params![existing_id],
523                    |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
524                )?;
525
526            // G15: skip re-indexing when body hash matches (common in --force-merge loops)
527            let existing_body_hash: Option<String> = tx
528                .query_row(
529                    "SELECT body_hash FROM memories WHERE id = ?1",
530                    rusqlite::params![existing_id],
531                    |r| r.get(0),
532                )
533                .ok();
534            let body_unchanged = existing_body_hash.as_deref() == Some(&body_hash);
535            skip_reindex = body_unchanged;
536            if !body_unchanged {
537                storage_chunks::delete_chunks(&tx, existing_id)?;
538            }
539
540            let next_v = versions::next_version(&tx, existing_id)?;
541            memories::update(&tx, existing_id, &new_memory, args.expected_updated_at)?;
542
543            // C1 fix: sync FTS5 external-content index after update
544            // (trg_fts_au trigger is absent by design due to sqlite-vec conflict)
545            memories::sync_fts_after_update(
546                &tx,
547                existing_id,
548                &old_fts_name,
549                &old_fts_desc,
550                &old_fts_body,
551                &normalized_name,
552                &resolved_description,
553                &new_memory.body,
554            )?;
555
556            versions::insert_version(
557                &tx,
558                existing_id,
559                next_v,
560                &normalized_name,
561                memory_type,
562                &resolved_description,
563                &new_memory.body,
564                &serde_json::to_string(&new_memory.metadata)?,
565                None,
566                "edit",
567            )?;
568            if !body_unchanged {
569                if let Some(ref emb) = embedding {
570                    memories::upsert_vec(
571                        &tx,
572                        existing_id,
573                        &namespace,
574                        memory_type,
575                        emb,
576                        &normalized_name,
577                        &snippet,
578                    )?;
579                }
580            }
581            (existing_id, "updated".to_string(), next_v)
582        }
583        None => {
584            if let Some(hash_id) = duplicate_hash_id {
585                warnings.push(format!(
586                    "identical body already exists as memory id {hash_id}"
587                ));
588            }
589            let id = memories::insert(&tx, &new_memory)?;
590            versions::insert_version(
591                &tx,
592                id,
593                1,
594                &normalized_name,
595                memory_type,
596                &resolved_description,
597                &new_memory.body,
598                &serde_json::to_string(&new_memory.metadata)?,
599                None,
600                "create",
601            )?;
602            if let Some(ref emb) = embedding {
603                memories::upsert_vec(
604                    &tx,
605                    id,
606                    &namespace,
607                    memory_type,
608                    emb,
609                    &normalized_name,
610                    &snippet,
611                )?;
612            }
613            (id, "created".to_string(), 1)
614        }
615    };
616
617    // GAP-SG-51: when --force-merge --replace-graph updates an existing memory,
618    // clear its prior entity/relationship bindings BEFORE re-linking the supplied
619    // set. With an empty `entities`/`relationships` payload this zeroes the graph
620    // for that memory without a `forget`. New bindings (if any) are linked by the
621    // block further below.
622    if args.replace_graph && action == "updated" {
623        let (e_removed, r_removed) = entities::clear_memory_graph_bindings(&tx, memory_id)?;
624        if e_removed + r_removed > 0 {
625            warnings.push(format!(
626                "--replace-graph cleared {e_removed} entity binding(s) and {r_removed} relationship binding(s) before re-linking"
627            ));
628        }
629    }
630
631    if chunks_info.len() > 1 && !skip_reindex {
632        storage_chunks::insert_chunk_slices(&tx, memory_id, &new_memory.body, &chunks_info)?;
633
634        if let Some(chunk_embeddings) = chunk_embeddings_cache.take() {
635            for (i, emb) in chunk_embeddings.iter().enumerate() {
636                storage_chunks::upsert_chunk_vec(&tx, i as i64, memory_id, i as i32, emb)?;
637            }
638        }
639        output::emit_progress_i18n(
640            &format!(
641                "Remember stage: persisted chunk vectors; process RSS {} MB",
642                crate::memory_guard::current_process_memory_mb().unwrap_or(0)
643            ),
644            &format!(
645                "Etapa remember: vetores de chunks persistidos; RSS do processo {} MB",
646                crate::memory_guard::current_process_memory_mb().unwrap_or(0)
647            ),
648        );
649    }
650
651    if !graph.entities.is_empty() || !graph.relationships.is_empty() {
652        for entity in &graph.entities {
653            let entity_id = entities::upsert_entity(&tx, &namespace, entity)?;
654            let entity_embedding = &graph_entity_embeddings[entities_persisted];
655            entities::upsert_entity_vec(
656                &tx,
657                entity_id,
658                &namespace,
659                entity.entity_type,
660                entity_embedding,
661                &entity.name,
662            )?;
663            entities::link_memory_entity(&tx, memory_id, entity_id)?;
664            entities_persisted += 1;
665        }
666        let entity_types: std::collections::HashMap<&str, EntityType> = graph
667            .entities
668            .iter()
669            .map(|entity| (entity.name.as_str(), entity.entity_type))
670            .collect();
671
672        let mut affected_entity_ids: std::collections::HashSet<i64> =
673            std::collections::HashSet::new();
674        for entity in &graph.entities {
675            if let Some(eid) = entities::find_entity_id(&tx, &namespace, &entity.name)? {
676                affected_entity_ids.insert(eid);
677            }
678        }
679
680        for rel in &graph.relationships {
681            let source_entity = NewEntity {
682                name: rel.source.clone(),
683                entity_type: entity_types
684                    .get(rel.source.as_str())
685                    .copied()
686                    .unwrap_or(EntityType::Concept),
687                description: None,
688            };
689            let target_entity = NewEntity {
690                name: rel.target.clone(),
691                entity_type: entity_types
692                    .get(rel.target.as_str())
693                    .copied()
694                    .unwrap_or(EntityType::Concept),
695                description: None,
696            };
697            let source_id = entities::upsert_entity(&tx, &namespace, &source_entity)?;
698            let target_id = entities::upsert_entity(&tx, &namespace, &target_entity)?;
699            let rel_id = entities::upsert_relationship(&tx, &namespace, source_id, target_id, rel)?;
700            entities::link_memory_relationship(&tx, memory_id, rel_id)?;
701            affected_entity_ids.insert(source_id);
702            affected_entity_ids.insert(target_id);
703            relationships_persisted += 1;
704        }
705
706        for &eid in &affected_entity_ids {
707            entities::recalculate_degree(&tx, eid)?;
708        }
709    }
710    tx.commit()?;
711
712    super::finish::emit_remember_result(
713        &conn,
714        &paths,
715        &args,
716        &graph,
717        &new_memory,
718        memory_id,
719        action,
720        version,
721        namespace,
722        normalized_name,
723        original_name,
724        name_was_normalized,
725        entities_persisted,
726        relationships_persisted,
727        relationships_updated,
728        chunks_created,
729        warnings,
730        extracted_urls,
731        extraction_method,
732        backend_invoked_passage,
733        inicio,
734    )?;
735
736    Ok(())
737}