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 {
24 #[arg(long)]
26 pub transaction: bool,
27 #[arg(long)]
29 pub fail_fast: bool,
30 #[arg(long)]
32 pub force_merge: bool,
33 #[arg(long)]
35 pub dry_run: bool,
36 #[arg(long)]
38 pub namespace: Option<String>,
39 #[arg(long)]
41 pub json: bool,
42 #[arg(long)]
45 pub enqueue_enrich: bool,
46 #[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 #[arg(long)]
60 pub db: Option<String>,
61 #[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 #[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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
116 entities_created: Vec<String>,
117 #[serde(default, skip_serializing_if = "Vec::is_empty")]
119 enrich_recommended: Vec<String>,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
122 enqueued_entity_descriptions: Option<usize>,
123}
124
125pub 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 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 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 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#[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 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 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 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 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 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 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 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 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}