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