Skip to main content

sqlite_graphrag/commands/
remember_batch.rs

1//! Handler for the `remember-batch` CLI subcommand (G08).
2//!
3//! Accepts NDJSON via stdin where each line is a memory to persist.
4//! One CLI invocation, one slot, one DB connection — eliminates N-process
5//! contention from parallel `remember` calls.
6
7use crate::errors::AppError;
8use crate::output;
9use crate::paths::AppPaths;
10use crate::storage::connection::open_rw;
11use crate::storage::{entities, memories, versions};
12use serde::{Deserialize, Serialize};
13use std::io::BufRead;
14
15#[derive(clap::Args)]
16#[command(after_long_help = "EXAMPLES:\n  \
17    # Pipe NDJSON memories from stdin\n  \
18    echo '{\"name\":\"mem-a\",\"type\":\"note\",\"description\":\"a\",\"body\":\"content\"}' | \
19    sqlite-graphrag remember-batch --json\n\n  \
20    # Atomic batch with --transaction\n  \
21    cat memories.ndjson | sqlite-graphrag remember-batch --transaction --json")]
22/// Remember batch args.
23pub struct RememberBatchArgs {
24    /// Apply all memories in a single transaction (all-or-nothing).
25    #[arg(long)]
26    pub transaction: bool,
27    /// Stop processing on the first failure.
28    #[arg(long)]
29    pub fail_fast: bool,
30    /// Apply force-merge to all memories (update existing by name).
31    #[arg(long)]
32    pub force_merge: bool,
33    /// Validate inputs and emit preview events without persisting or embedding.
34    #[arg(long)]
35    pub dry_run: bool,
36    /// Namespace override for all memories.
37    #[arg(long)]
38    pub namespace: Option<String>,
39    /// Emit NDJSON output.
40    #[arg(long)]
41    pub json: bool,
42    /// GAP-CLI-PRIO-batch: after success, enqueue entity-descriptions for
43    /// entities created/linked in this batch (hot-set priority).
44    #[arg(long)]
45    pub enqueue_enrich: bool,
46    /// GAP-SG-216: parity with `remember --strict-entity-types`.
47    ///
48    /// The batch accepts the same [`crate::storage::entities::NewEntity`]
49    /// payload, so it accepts the same open vocabulary — and owes its caller
50    /// the same report. The visibility channel added in v1.2.8 was only ever
51    /// wired into `remember`, leaving the batch silent.
52    #[arg(
53        long,
54        default_value_t = false,
55        help = "Reject a line whose declared entity_type is outside the canonical vocabulary"
56    )]
57    pub strict_entity_types: bool,
58    /// Database path override.
59    #[arg(long)]
60    pub db: Option<String>,
61    /// GAP-SG-35: maximum simultaneous LLM embedding subprocesses, accepted for
62    /// parity with `remember`/`edit`/`ingest`/`enrich` so agents that append
63    /// `--llm-parallelism` to every invocation never hit a clap error. The
64    /// batch loop embeds one passage per item serially; this value bounds the
65    /// embedding fan-out width where the backend supports it (clamp [1, 32]).
66    #[arg(long, default_value_t = 4, value_name = "N",
67          value_parser = clap::value_parser!(u64).range(1..=32))]
68    pub llm_parallelism: u64,
69}
70
71#[derive(Deserialize)]
72struct BatchInputLine {
73    name: String,
74    #[serde(default = "default_type")]
75    r#type: String,
76    #[serde(default)]
77    description: String,
78    #[serde(default)]
79    body: String,
80    #[serde(default)]
81    entities: Vec<crate::storage::entities::NewEntity>,
82    #[serde(default)]
83    relationships: Vec<crate::storage::entities::NewRelationship>,
84}
85
86fn default_type() -> String {
87    "note".to_string()
88}
89
90#[derive(Serialize)]
91struct BatchItemEvent {
92    name: String,
93    status: String,
94    #[serde(skip_serializing_if = "Option::is_none")]
95    memory_id: Option<i64>,
96    #[serde(skip_serializing_if = "Option::is_none")]
97    error: Option<String>,
98    index: usize,
99    /// GAP-SG-216: declared `entity_type` labels outside the canonical set.
100    ///
101    /// Omitted when empty, exactly like `error` and `memory_id`, so a line with
102    /// nothing to report stays byte-identical to what v1.2.8 emitted.
103    #[serde(default, skip_serializing_if = "Vec::is_empty")]
104    warnings: Vec<String>,
105}
106
107#[derive(Serialize)]
108struct BatchSummary {
109    summary: bool,
110    total: usize,
111    succeeded: usize,
112    failed: usize,
113    elapsed_ms: u64,
114    /// Entity names linked/created across the batch (GAP-CLI-PRIO-batch).
115    #[serde(default, skip_serializing_if = "Vec::is_empty")]
116    entities_created: Vec<String>,
117    /// Recommended enrich ops for automation (GAP-CLI-PRIO-batch).
118    #[serde(default, skip_serializing_if = "Vec::is_empty")]
119    enrich_recommended: Vec<String>,
120    /// How many entity-descriptions were hot-enqueued when --enqueue-enrich.
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    enqueued_entity_descriptions: Option<usize>,
123}
124
125/// Run.
126pub fn run(args: RememberBatchArgs, backends: crate::cli::BackendChoice) -> Result<(), AppError> {
127    let crate::cli::BackendChoice {
128        llm: llm_backend,
129        embedding: embedding_backend,
130    } = backends;
131    let start = std::time::Instant::now();
132    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
133    let paths = AppPaths::resolve(args.db.as_deref())?;
134    paths.ensure_dirs()?;
135    crate::storage::connection::ensure_db_ready(&paths)?;
136    let mut conn = open_rw(&paths.db)?;
137
138    // Declarative refusal (`--no-input`): fail before touching stdin, even when
139    // a pipe is attached and would have supplied NDJSON.
140    if crate::stdin_helper::no_input() {
141        return Err(AppError::Validation(
142            crate::i18n::validation::no_input_blocks_stdin(),
143        ));
144    }
145    let stdin = std::io::stdin();
146    let lines: Vec<String> = stdin
147        .lock()
148        .lines()
149        .map_while(Result::ok)
150        .filter(|l| !l.trim().is_empty())
151        .collect();
152
153    let total = lines.len();
154    let mut succeeded = 0usize;
155    let mut failed = 0usize;
156
157    if args.dry_run {
158        for (idx, line) in lines.iter().enumerate() {
159            match serde_json::from_str::<BatchInputLine>(line) {
160                Ok(input) => {
161                    let normalized_name = crate::parsers::normalize_entity_name(&input.name);
162                    if normalized_name.is_empty() {
163                        failed += 1;
164                        output::emit_json(&BatchItemEvent {
165                            name: String::new(),
166                            status: "failed".to_string(),
167                            memory_id: None,
168                            error: Some(format!("line {idx}: name normalizes to empty string")),
169                            index: idx,
170                            warnings: Vec::new(),
171                        })?;
172                        continue;
173                    }
174                    // GAP-SG-216: a dry run that cannot predict the refusal is
175                    // not a dry run. The labels are read off the raw line, so
176                    // the preview says exactly what the real pass would.
177                    let type_warnings =
178                        crate::commands::remember::collect_noncanonical_entity_types(line);
179                    if args.strict_entity_types && !type_warnings.is_empty() {
180                        failed += 1;
181                        output::emit_json(&BatchItemEvent {
182                            name: normalized_name,
183                            status: "would_fail_strict_entity_types".to_string(),
184                            memory_id: None,
185                            error: Some(crate::i18n::validation::strict_entity_type_folded(
186                                &type_warnings,
187                            )),
188                            index: idx,
189                            warnings: type_warnings,
190                        })?;
191                        continue;
192                    }
193                    let existing = memories::find_by_name(&conn, &namespace, &normalized_name)?;
194                    let action = if existing.is_some() {
195                        if args.force_merge {
196                            "would_update"
197                        } else {
198                            "would_fail_duplicate"
199                        }
200                    } else {
201                        "would_create"
202                    };
203                    succeeded += 1;
204                    output::emit_json(&BatchItemEvent {
205                        name: normalized_name,
206                        status: action.to_string(),
207                        memory_id: existing.map(|(id, _, _)| id),
208                        error: None,
209                        index: idx,
210                        warnings: type_warnings,
211                    })?;
212                }
213                Err(e) => {
214                    failed += 1;
215                    output::emit_json(&BatchItemEvent {
216                        name: String::new(),
217                        status: "failed".to_string(),
218                        memory_id: None,
219                        error: Some(format!("line {idx}: invalid JSON: {e}")),
220                        index: idx,
221                        warnings: Vec::new(),
222                    })?;
223                }
224            }
225        }
226
227        output::emit_json(&BatchSummary {
228            summary: true,
229            total,
230            succeeded,
231            failed,
232            elapsed_ms: start.elapsed().as_millis() as u64,
233            entities_created: vec![],
234            enrich_recommended: vec![],
235            enqueued_entity_descriptions: None,
236        })?;
237        return Ok(());
238    }
239
240    let mut all_entities: Vec<String> = Vec::new();
241    if args.transaction {
242        let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
243        for (idx, line) in lines.iter().enumerate() {
244            match process_line(
245                &tx,
246                &namespace,
247                line,
248                idx,
249                args.force_merge,
250                &paths,
251                crate::cli::BackendChoice::new(llm_backend, embedding_backend),
252                args.strict_entity_types,
253            ) {
254                Ok((event, ent_names)) => {
255                    output::emit_json(&event)?;
256                    all_entities.extend(ent_names);
257                    succeeded += 1;
258                }
259                Err(e) => {
260                    failed += 1;
261                    output::emit_json(&BatchItemEvent {
262                        name: String::new(),
263                        status: "failed".to_string(),
264                        memory_id: None,
265                        error: Some(format!("{e}")),
266                        index: idx,
267                        warnings: Vec::new(),
268                    })?;
269                    if args.fail_fast {
270                        break;
271                    }
272                }
273            }
274        }
275        if failed == 0 || !args.fail_fast {
276            tx.commit()?;
277        }
278    } else {
279        for (idx, line) in lines.iter().enumerate() {
280            let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
281            match process_line(
282                &tx,
283                &namespace,
284                line,
285                idx,
286                args.force_merge,
287                &paths,
288                crate::cli::BackendChoice::new(llm_backend, embedding_backend),
289                args.strict_entity_types,
290            ) {
291                Ok((event, ent_names)) => {
292                    tx.commit()?;
293                    output::emit_json(&event)?;
294                    all_entities.extend(ent_names);
295                    succeeded += 1;
296                }
297                Err(e) => {
298                    drop(tx);
299                    failed += 1;
300                    output::emit_json(&BatchItemEvent {
301                        name: String::new(),
302                        status: "failed".to_string(),
303                        memory_id: None,
304                        error: Some(format!("{e}")),
305                        index: idx,
306                        warnings: Vec::new(),
307                    })?;
308                    if args.fail_fast {
309                        break;
310                    }
311                }
312            }
313        }
314    }
315
316    // Dedup entity names for hot-set enqueue (GAP-CLI-PRIO-batch).
317    all_entities.sort();
318    all_entities.dedup();
319    let mut enrich_recommended = Vec::new();
320    if !all_entities.is_empty() {
321        enrich_recommended.push("entity-descriptions".to_string());
322    }
323    let mut enqueued_entity_descriptions = None;
324    if args.enqueue_enrich && !all_entities.is_empty() {
325        match crate::commands::enrich::enqueue_priority_entity_descriptions(
326            &paths,
327            &namespace,
328            &all_entities,
329        ) {
330            Ok(n) => enqueued_entity_descriptions = Some(n),
331            Err(e) => {
332                tracing::warn!(
333                    error = %e,
334                    "remember-batch: enqueue_enrich failed (entities still listed in entities_created)"
335                );
336            }
337        }
338    }
339
340    output::emit_json(&BatchSummary {
341        summary: true,
342        total,
343        succeeded,
344        failed,
345        elapsed_ms: start.elapsed().as_millis() as u64,
346        entities_created: all_entities,
347        enrich_recommended,
348        enqueued_entity_descriptions,
349    })?;
350
351    Ok(())
352}
353
354// Still one over after folding the backend pair: the remaining seven are the
355// transaction, the line and its index, and the three per-run flags the caller
356// resolved from argv — none of which travel together anywhere else.
357// One parameter per decision a single NDJSON line depends on. The backend pair
358// is already aggregated as `BackendChoice`; what remains does not form a second
359// group with a name.
360#[allow(clippy::too_many_arguments)]
361fn process_line(
362    tx: &rusqlite::Transaction<'_>,
363    namespace: &str,
364    line: &str,
365    index: usize,
366    force_merge: bool,
367    paths: &AppPaths,
368    backends: crate::cli::BackendChoice,
369    strict_entity_types: bool,
370) -> Result<(BatchItemEvent, Vec<String>), AppError> {
371    let mut input: BatchInputLine = serde_json::from_str(line).map_err(|e| {
372        AppError::Validation(crate::i18n::validation::batch_line_invalid_json(index, &e))
373    })?;
374
375    // GAP-SG-216: read the labels off the RAW line, from the same function the
376    // `remember` path uses, so one definition of "outside the vocabulary"
377    // serves both the warning and the refusal.
378    let type_warnings = crate::commands::remember::collect_noncanonical_entity_types(line);
379    if strict_entity_types && !type_warnings.is_empty() {
380        return Err(AppError::Validation(
381            crate::i18n::validation::strict_entity_type_folded(&type_warnings),
382        ));
383    }
384
385    // Normalise the shape of every label before anything is written, so an
386    // unusable one is a validation exit code instead of a stored value.
387    for entity in &mut input.entities {
388        entity.entity_type = crate::entity_type::normalize_entity_type(&entity.entity_type)?;
389    }
390
391    let normalized_name = crate::parsers::normalize_entity_name(&input.name);
392    if normalized_name.is_empty() {
393        return Err(AppError::Validation(
394            crate::i18n::validation::batch_line_name_empty(index),
395        ));
396    }
397
398    // v1.1.2 (Gap 2): boundary validation of BOTH payload ceilings per NDJSON
399    // line — bytes (BodyTooLarge) and estimated tokens (TooManyTokens), exit 6 —
400    // so an oversized item fails typed BEFORE any row is written.
401    crate::memory_guard::check_embedding_input_size(&input.body)?;
402
403    let body_hash = blake3::hash(input.body.as_bytes()).to_hex().to_string();
404
405    let existing = memories::find_by_name(tx, namespace, &normalized_name)?;
406
407    // GAP-E2E-05: parity with `remember` — description required when creating.
408    if existing.is_none() && input.description.trim().is_empty() {
409        return Err(AppError::Validation(
410            crate::i18n::validation::batch_line_type_description_required(index),
411        ));
412    }
413
414    let (memory_id, batch_action) = if let Some((existing_id, _updated_at, _version)) = existing {
415        if !force_merge {
416            // DRY: the other three duplicate call sites already route through
417            // this builder; this one had drifted to its own wording.
418            return Err(AppError::Duplicate(
419                crate::i18n::errors_msg::duplicate_memory(&normalized_name, namespace),
420            ));
421        }
422        let snippet: String = input.body.chars().take(200).collect();
423        // Capture old FTS values BEFORE the UPDATE for sync_fts_after_update
424        // (trg_fts_au trigger is absent by design due to sqlite-vec conflict).
425        let (old_fts_name, old_fts_desc, old_fts_body): (String, String, String) = tx.query_row(
426            "SELECT name, description, body FROM memories WHERE id = ?1",
427            rusqlite::params![existing_id],
428            |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
429        )?;
430        memories::update(
431            tx,
432            existing_id,
433            &memories::NewMemory {
434                namespace: namespace.to_string(),
435                name: normalized_name.clone(),
436                memory_type: input.r#type.clone(),
437                description: input.description.clone(),
438                body: input.body.clone(),
439                body_hash,
440                session_id: None,
441                source: "agent".to_string(),
442                metadata: serde_json::json!({}),
443            },
444            None,
445        )?;
446        memories::sync_fts_after_update(
447            tx,
448            existing_id,
449            &old_fts_name,
450            &old_fts_desc,
451            &old_fts_body,
452            &normalized_name,
453            &input.description,
454            &input.body,
455        )?;
456        let next_v = versions::next_version(tx, existing_id)?;
457        versions::insert_version(
458            tx,
459            existing_id,
460            next_v,
461            &normalized_name,
462            &input.r#type,
463            &input.description,
464            &input.body,
465            "{}",
466            None,
467            "edit",
468        )?;
469
470        let skip_embed = crate::embedder::should_skip_embedding_on_failure();
471        match crate::embedder::embed_passage_with_embedding_choice(
472            &paths.models,
473            &input.body,
474            backends,
475        ) {
476            Ok((embedding, _backend)) => {
477                memories::upsert_vec(
478                    tx,
479                    existing_id,
480                    namespace,
481                    &input.r#type,
482                    &embedding,
483                    &normalized_name,
484                    &snippet,
485                )?;
486            }
487            // v1.1.2 (Gap 2): typed payload rejections are permanent and
488            // must not be swallowed by --skip-embedding-on-failure.
489            Err(
490                e @ (AppError::Validation(_)
491                | AppError::BodyTooLarge { .. }
492                | AppError::TooManyTokens { .. }),
493            ) => return Err(e),
494            Err(e) if skip_embed => {
495                tracing::warn!(error = %e, "remember-batch: embedding failed; --skip-embedding-on-failure active, persisting without embedding");
496            }
497            Err(e) => return Err(e),
498        }
499        (existing_id, "updated")
500    } else {
501        let new_mem = memories::NewMemory {
502            namespace: namespace.to_string(),
503            name: normalized_name.clone(),
504            memory_type: input.r#type.clone(),
505            description: input.description.clone(),
506            body: input.body.clone(),
507            body_hash,
508            session_id: None,
509            source: "agent".to_string(),
510            metadata: serde_json::json!({}),
511        };
512        let id = memories::insert(tx, &new_mem)?;
513        versions::insert_version(
514            tx,
515            id,
516            1,
517            &normalized_name,
518            &input.r#type,
519            &input.description,
520            &input.body,
521            "{}",
522            None,
523            "create",
524        )?;
525
526        let snippet: String = input.body.chars().take(200).collect();
527        let skip_embed = crate::embedder::should_skip_embedding_on_failure();
528        match crate::embedder::embed_passage_with_embedding_choice(
529            &paths.models,
530            &input.body,
531            backends,
532        ) {
533            Ok((embedding, _backend)) => {
534                memories::upsert_vec(
535                    tx,
536                    id,
537                    namespace,
538                    &input.r#type,
539                    &embedding,
540                    &normalized_name,
541                    &snippet,
542                )?;
543            }
544            Err(
545                e @ (AppError::Validation(_)
546                | AppError::BodyTooLarge { .. }
547                | AppError::TooManyTokens { .. }),
548            ) => return Err(e),
549            Err(e) if skip_embed => {
550                tracing::warn!(error = %e, "remember-batch: embedding failed; --skip-embedding-on-failure active, persisting without embedding");
551            }
552            Err(e) => return Err(e),
553        }
554        (id, "created")
555    };
556
557    // Persist graph entities and relationships if provided
558    for entity in &input.entities {
559        let entity_id = entities::upsert_entity(tx, namespace, entity)?;
560        let entity_text = match &entity.description {
561            Some(desc) => format!("{} {}", entity.name, desc),
562            None => entity.name.clone(),
563        };
564        let skip_embed = crate::embedder::should_skip_embedding_on_failure();
565        match crate::embedder::embed_entity_texts_cached(
566            &paths.models,
567            std::slice::from_ref(&entity_text),
568            1,
569            backends,
570        ) {
571            Ok((entity_embedding_vec, _stats)) => {
572                if let Some(entity_embedding) = entity_embedding_vec.into_iter().next() {
573                    entities::upsert_entity_vec(
574                        tx,
575                        entity_id,
576                        namespace,
577                        &entity.entity_type,
578                        &entity_embedding,
579                        &entity.name,
580                    )?;
581                }
582            }
583            Err(e) if skip_embed => {
584                tracing::warn!(error = %e, "remember-batch: entity embedding failed; --skip-embedding-on-failure active");
585            }
586            Err(e) => return Err(e),
587        }
588        entities::link_memory_entity(tx, memory_id, entity_id)?;
589    }
590
591    for rel in &input.relationships {
592        let src_name = crate::parsers::normalize_entity_name(&rel.source);
593        let tgt_name = crate::parsers::normalize_entity_name(&rel.target);
594        if let (Some(src_id), Some(tgt_id)) = (
595            entities::find_entity_id(tx, namespace, &src_name)?,
596            entities::find_entity_id(tx, namespace, &tgt_name)?,
597        ) {
598            entities::create_or_fetch_relationship(
599                tx,
600                namespace,
601                src_id,
602                tgt_id,
603                &rel.relation,
604                rel.strength,
605                rel.description.as_deref(),
606            )?;
607        }
608    }
609
610    let mut created_entity_names: Vec<String> = Vec::with_capacity(input.entities.len());
611    for entity in &input.entities {
612        created_entity_names.push(crate::parsers::normalize_entity_name(&entity.name));
613    }
614    Ok((
615        BatchItemEvent {
616            name: normalized_name,
617            status: batch_action.to_string(),
618            memory_id: Some(memory_id),
619            error: None,
620            index,
621            warnings: type_warnings,
622        },
623        created_entity_names,
624    ))
625}