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