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