1use 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 #[arg(long)]
25 pub transaction: bool,
26 #[arg(long)]
28 pub fail_fast: bool,
29 #[arg(long)]
31 pub force_merge: bool,
32 #[arg(long)]
34 pub dry_run: bool,
35 #[arg(long, env = "SQLITE_GRAPHRAG_NAMESPACE")]
37 pub namespace: Option<String>,
38 #[arg(long)]
40 pub json: bool,
41 #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
43 pub db: Option<String>,
44 #[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 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 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 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 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}