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    /// Database path override.
47    #[arg(long)]
48    pub db: Option<String>,
49    /// GAP-SG-35: maximum simultaneous LLM embedding subprocesses, accepted for
50    /// parity with `remember`/`edit`/`ingest`/`enrich` so agents that append
51    /// `--llm-parallelism` to every invocation never hit a clap error. The
52    /// batch loop embeds one passage per item serially; this value bounds the
53    /// embedding fan-out width where the backend supports it (clamp [1, 32]).
54    #[arg(long, default_value_t = 4, value_name = "N",
55          value_parser = clap::value_parser!(u64).range(1..=32))]
56    pub llm_parallelism: u64,
57}
58
59#[derive(Deserialize)]
60struct BatchInputLine {
61    name: String,
62    #[serde(default = "default_type")]
63    r#type: String,
64    #[serde(default)]
65    description: String,
66    #[serde(default)]
67    body: String,
68    #[serde(default)]
69    entities: Vec<crate::storage::entities::NewEntity>,
70    #[serde(default)]
71    relationships: Vec<crate::storage::entities::NewRelationship>,
72}
73
74fn default_type() -> String {
75    "note".to_string()
76}
77
78#[derive(Serialize)]
79struct BatchItemEvent {
80    name: String,
81    status: String,
82    #[serde(skip_serializing_if = "Option::is_none")]
83    memory_id: Option<i64>,
84    #[serde(skip_serializing_if = "Option::is_none")]
85    error: Option<String>,
86    index: usize,
87}
88
89#[derive(Serialize)]
90struct BatchSummary {
91    summary: bool,
92    total: usize,
93    succeeded: usize,
94    failed: usize,
95    elapsed_ms: u64,
96    /// Entity names linked/created across the batch (GAP-CLI-PRIO-batch).
97    #[serde(default, skip_serializing_if = "Vec::is_empty")]
98    entities_created: Vec<String>,
99    /// Recommended enrich ops for automation (GAP-CLI-PRIO-batch).
100    #[serde(default, skip_serializing_if = "Vec::is_empty")]
101    enrich_recommended: Vec<String>,
102    /// How many entity-descriptions were hot-enqueued when --enqueue-enrich.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    enqueued_entity_descriptions: Option<usize>,
105}
106
107/// Run.
108pub fn run(
109    args: RememberBatchArgs,
110    llm_backend: crate::cli::LlmBackendChoice,
111    embedding_backend: crate::cli::EmbeddingBackendChoice,
112) -> Result<(), AppError> {
113    let start = std::time::Instant::now();
114    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
115    let paths = AppPaths::resolve(args.db.as_deref())?;
116    paths.ensure_dirs()?;
117    crate::storage::connection::ensure_db_ready(&paths)?;
118    let mut conn = open_rw(&paths.db)?;
119
120    let stdin = std::io::stdin();
121    let lines: Vec<String> = stdin
122        .lock()
123        .lines()
124        .map_while(Result::ok)
125        .filter(|l| !l.trim().is_empty())
126        .collect();
127
128    let total = lines.len();
129    let mut succeeded = 0usize;
130    let mut failed = 0usize;
131
132    if args.dry_run {
133        for (idx, line) in lines.iter().enumerate() {
134            match serde_json::from_str::<BatchInputLine>(line) {
135                Ok(input) => {
136                    let normalized_name = crate::parsers::normalize_entity_name(&input.name);
137                    if normalized_name.is_empty() {
138                        failed += 1;
139                        output::emit_json(&BatchItemEvent {
140                            name: String::new(),
141                            status: "failed".to_string(),
142                            memory_id: None,
143                            error: Some(format!("line {idx}: name normalizes to empty string")),
144                            index: idx,
145                        })?;
146                        continue;
147                    }
148                    let existing = memories::find_by_name(&conn, &namespace, &normalized_name)?;
149                    let action = if existing.is_some() {
150                        if args.force_merge {
151                            "would_update"
152                        } else {
153                            "would_fail_duplicate"
154                        }
155                    } else {
156                        "would_create"
157                    };
158                    succeeded += 1;
159                    output::emit_json(&BatchItemEvent {
160                        name: normalized_name,
161                        status: action.to_string(),
162                        memory_id: existing.map(|(id, _, _)| id),
163                        error: None,
164                        index: idx,
165                    })?;
166                }
167                Err(e) => {
168                    failed += 1;
169                    output::emit_json(&BatchItemEvent {
170                        name: String::new(),
171                        status: "failed".to_string(),
172                        memory_id: None,
173                        error: Some(format!("line {idx}: invalid JSON: {e}")),
174                        index: idx,
175                    })?;
176                }
177            }
178        }
179
180        output::emit_json(&BatchSummary {
181            summary: true,
182            total,
183            succeeded,
184            failed,
185            elapsed_ms: start.elapsed().as_millis() as u64,
186            entities_created: vec![],
187            enrich_recommended: vec![],
188            enqueued_entity_descriptions: None,
189        })?;
190        return Ok(());
191    }
192
193    let mut all_entities: Vec<String> = Vec::new();
194    if args.transaction {
195        let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
196        for (idx, line) in lines.iter().enumerate() {
197            match process_line(
198                &tx,
199                &namespace,
200                line,
201                idx,
202                args.force_merge,
203                &paths,
204                llm_backend,
205                embedding_backend,
206            ) {
207                Ok((event, ent_names)) => {
208                    output::emit_json(&event)?;
209                    all_entities.extend(ent_names);
210                    succeeded += 1;
211                }
212                Err(e) => {
213                    failed += 1;
214                    output::emit_json(&BatchItemEvent {
215                        name: String::new(),
216                        status: "failed".to_string(),
217                        memory_id: None,
218                        error: Some(format!("{e}")),
219                        index: idx,
220                    })?;
221                    if args.fail_fast {
222                        break;
223                    }
224                }
225            }
226        }
227        if failed == 0 || !args.fail_fast {
228            tx.commit()?;
229        }
230    } else {
231        for (idx, line) in lines.iter().enumerate() {
232            let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
233            match process_line(
234                &tx,
235                &namespace,
236                line,
237                idx,
238                args.force_merge,
239                &paths,
240                llm_backend,
241                embedding_backend,
242            ) {
243                Ok((event, ent_names)) => {
244                    tx.commit()?;
245                    output::emit_json(&event)?;
246                    all_entities.extend(ent_names);
247                    succeeded += 1;
248                }
249                Err(e) => {
250                    drop(tx);
251                    failed += 1;
252                    output::emit_json(&BatchItemEvent {
253                        name: String::new(),
254                        status: "failed".to_string(),
255                        memory_id: None,
256                        error: Some(format!("{e}")),
257                        index: idx,
258                    })?;
259                    if args.fail_fast {
260                        break;
261                    }
262                }
263            }
264        }
265    }
266
267    // Dedup entity names for hot-set enqueue (GAP-CLI-PRIO-batch).
268    all_entities.sort();
269    all_entities.dedup();
270    let mut enrich_recommended = Vec::new();
271    if !all_entities.is_empty() {
272        enrich_recommended.push("entity-descriptions".to_string());
273    }
274    let mut enqueued_entity_descriptions = None;
275    if args.enqueue_enrich && !all_entities.is_empty() {
276        match crate::commands::enrich::enqueue_priority_entity_descriptions(
277            &paths,
278            &namespace,
279            &all_entities,
280        ) {
281            Ok(n) => enqueued_entity_descriptions = Some(n),
282            Err(e) => {
283                tracing::warn!(
284                    error = %e,
285                    "remember-batch: enqueue_enrich failed (entities still listed in entities_created)"
286                );
287            }
288        }
289    }
290
291    output::emit_json(&BatchSummary {
292        summary: true,
293        total,
294        succeeded,
295        failed,
296        elapsed_ms: start.elapsed().as_millis() as u64,
297        entities_created: all_entities,
298        enrich_recommended,
299        enqueued_entity_descriptions,
300    })?;
301
302    Ok(())
303}
304
305#[allow(clippy::too_many_arguments)]
306fn process_line(
307    tx: &rusqlite::Transaction<'_>,
308    namespace: &str,
309    line: &str,
310    index: usize,
311    force_merge: bool,
312    paths: &AppPaths,
313    llm_backend: crate::cli::LlmBackendChoice,
314    embedding_backend: crate::cli::EmbeddingBackendChoice,
315) -> Result<(BatchItemEvent, Vec<String>), AppError> {
316    let input: BatchInputLine = serde_json::from_str(line)
317        .map_err(|e| AppError::Validation(crate::i18n::validation::batch_line_invalid_json(index, &e)))?;
318
319    let normalized_name = crate::parsers::normalize_entity_name(&input.name);
320    if normalized_name.is_empty() {
321        return Err(AppError::Validation(crate::i18n::validation::batch_line_name_empty(index)));
322    }
323
324    // v1.1.2 (Gap 2): boundary validation of BOTH payload ceilings per NDJSON
325    // line — bytes (BodyTooLarge) and estimated tokens (TooManyTokens), exit 6 —
326    // so an oversized item fails typed BEFORE any row is written.
327    crate::memory_guard::check_embedding_input_size(&input.body)?;
328
329    let body_hash = blake3::hash(input.body.as_bytes()).to_hex().to_string();
330
331    let existing = memories::find_by_name(tx, namespace, &normalized_name)?;
332
333    // GAP-E2E-05: parity with `remember` — description required when creating.
334    if existing.is_none() && input.description.trim().is_empty() {
335        return Err(AppError::Validation(crate::i18n::validation::batch_line_type_description_required(index)));
336    }
337
338    let (memory_id, batch_action) = if let Some((existing_id, _updated_at, _version)) = existing {
339        if !force_merge {
340            return Err(AppError::Duplicate(format!(
341                "memory '{normalized_name}' already exists; use --force-merge to update"
342            )));
343        }
344        let snippet: String = input.body.chars().take(200).collect();
345        // Capture old FTS values BEFORE the UPDATE for sync_fts_after_update
346        // (trg_fts_au trigger is absent by design due to sqlite-vec conflict).
347        let (old_fts_name, old_fts_desc, old_fts_body): (String, String, String) = tx.query_row(
348            "SELECT name, description, body FROM memories WHERE id = ?1",
349            rusqlite::params![existing_id],
350            |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
351        )?;
352        memories::update(
353            tx,
354            existing_id,
355            &memories::NewMemory {
356                namespace: namespace.to_string(),
357                name: normalized_name.clone(),
358                memory_type: input.r#type.clone(),
359                description: input.description.clone(),
360                body: input.body.clone(),
361                body_hash,
362                session_id: None,
363                source: "agent".to_string(),
364                metadata: serde_json::json!({}),
365            },
366            None,
367        )?;
368        memories::sync_fts_after_update(
369            tx,
370            existing_id,
371            &old_fts_name,
372            &old_fts_desc,
373            &old_fts_body,
374            &normalized_name,
375            &input.description,
376            &input.body,
377        )?;
378        let next_v = versions::next_version(tx, existing_id)?;
379        versions::insert_version(
380            tx,
381            existing_id,
382            next_v,
383            &normalized_name,
384            &input.r#type,
385            &input.description,
386            &input.body,
387            "{}",
388            None,
389            "edit",
390        )?;
391
392        let skip_embed = crate::embedder::should_skip_embedding_on_failure();
393        match crate::embedder::embed_passage_with_embedding_choice(
394            &paths.models,
395            &input.body,
396            embedding_backend,
397            llm_backend,
398        ) {
399            Ok((embedding, _backend)) => {
400                memories::upsert_vec(
401                    tx,
402                    existing_id,
403                    namespace,
404                    &input.r#type,
405                    &embedding,
406                    &normalized_name,
407                    &snippet,
408                )?;
409            }
410            // v1.1.2 (Gap 2): typed payload rejections are permanent and
411            // must not be swallowed by --skip-embedding-on-failure.
412            Err(
413                e @ (AppError::Validation(_)
414                | AppError::BodyTooLarge { .. }
415                | AppError::TooManyTokens { .. }),
416            ) => return Err(e),
417            Err(e) if skip_embed => {
418                tracing::warn!(error = %e, "remember-batch: embedding failed; --skip-embedding-on-failure active, persisting without embedding");
419            }
420            Err(e) => return Err(e),
421        }
422        (existing_id, "updated")
423    } else {
424        let new_mem = memories::NewMemory {
425            namespace: namespace.to_string(),
426            name: normalized_name.clone(),
427            memory_type: input.r#type.clone(),
428            description: input.description.clone(),
429            body: input.body.clone(),
430            body_hash,
431            session_id: None,
432            source: "agent".to_string(),
433            metadata: serde_json::json!({}),
434        };
435        let id = memories::insert(tx, &new_mem)?;
436        versions::insert_version(
437            tx,
438            id,
439            1,
440            &normalized_name,
441            &input.r#type,
442            &input.description,
443            &input.body,
444            "{}",
445            None,
446            "create",
447        )?;
448
449        let snippet: String = input.body.chars().take(200).collect();
450        let skip_embed = crate::embedder::should_skip_embedding_on_failure();
451        match crate::embedder::embed_passage_with_embedding_choice(
452            &paths.models,
453            &input.body,
454            embedding_backend,
455            llm_backend,
456        ) {
457            Ok((embedding, _backend)) => {
458                memories::upsert_vec(
459                    tx,
460                    id,
461                    namespace,
462                    &input.r#type,
463                    &embedding,
464                    &normalized_name,
465                    &snippet,
466                )?;
467            }
468            Err(
469                e @ (AppError::Validation(_)
470                | AppError::BodyTooLarge { .. }
471                | AppError::TooManyTokens { .. }),
472            ) => return Err(e),
473            Err(e) if skip_embed => {
474                tracing::warn!(error = %e, "remember-batch: embedding failed; --skip-embedding-on-failure active, persisting without embedding");
475            }
476            Err(e) => return Err(e),
477        }
478        (id, "created")
479    };
480
481    // Persist graph entities and relationships if provided
482    for entity in &input.entities {
483        let entity_id = entities::upsert_entity(tx, namespace, entity)?;
484        let entity_text = match &entity.description {
485            Some(desc) => format!("{} {}", entity.name, desc),
486            None => entity.name.clone(),
487        };
488        let skip_embed = crate::embedder::should_skip_embedding_on_failure();
489        match crate::embedder::embed_entity_texts_cached(
490            &paths.models,
491            std::slice::from_ref(&entity_text),
492            1,
493            embedding_backend,
494            llm_backend,
495        ) {
496            Ok((entity_embedding_vec, _stats)) => {
497                if let Some(entity_embedding) = entity_embedding_vec.into_iter().next() {
498                    entities::upsert_entity_vec(
499                        tx,
500                        entity_id,
501                        namespace,
502                        entity.entity_type,
503                        &entity_embedding,
504                        &entity.name,
505                    )?;
506                }
507            }
508            Err(e) if skip_embed => {
509                tracing::warn!(error = %e, "remember-batch: entity embedding failed; --skip-embedding-on-failure active");
510            }
511            Err(e) => return Err(e),
512        }
513        entities::link_memory_entity(tx, memory_id, entity_id)?;
514    }
515
516    for rel in &input.relationships {
517        let src_name = crate::parsers::normalize_entity_name(&rel.source);
518        let tgt_name = crate::parsers::normalize_entity_name(&rel.target);
519        if let (Some(src_id), Some(tgt_id)) = (
520            entities::find_entity_id(tx, namespace, &src_name)?,
521            entities::find_entity_id(tx, namespace, &tgt_name)?,
522        ) {
523            entities::create_or_fetch_relationship(
524                tx,
525                namespace,
526                src_id,
527                tgt_id,
528                &rel.relation,
529                rel.strength,
530                rel.description.as_deref(),
531            )?;
532        }
533    }
534
535    let mut created_entity_names: Vec<String> = Vec::with_capacity(input.entities.len());
536    for entity in &input.entities {
537        created_entity_names.push(crate::parsers::normalize_entity_name(&entity.name));
538    }
539    Ok((
540        BatchItemEvent {
541            name: normalized_name,
542            status: batch_action.to_string(),
543            memory_id: Some(memory_id),
544            error: None,
545            index,
546        },
547        created_entity_names,
548    ))
549}