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(long)]
48 pub db: Option<String>,
49 #[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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
98 entities_created: Vec<String>,
99 #[serde(default, skip_serializing_if = "Vec::is_empty")]
101 enrich_recommended: Vec<String>,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
104 enqueued_entity_descriptions: Option<usize>,
105}
106
107pub 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 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).map_err(|e| {
317 AppError::Validation(crate::i18n::validation::batch_line_invalid_json(index, &e))
318 })?;
319
320 let normalized_name = crate::parsers::normalize_entity_name(&input.name);
321 if normalized_name.is_empty() {
322 return Err(AppError::Validation(
323 crate::i18n::validation::batch_line_name_empty(index),
324 ));
325 }
326
327 crate::memory_guard::check_embedding_input_size(&input.body)?;
331
332 let body_hash = blake3::hash(input.body.as_bytes()).to_hex().to_string();
333
334 let existing = memories::find_by_name(tx, namespace, &normalized_name)?;
335
336 if existing.is_none() && input.description.trim().is_empty() {
338 return Err(AppError::Validation(
339 crate::i18n::validation::batch_line_type_description_required(index),
340 ));
341 }
342
343 let (memory_id, batch_action) = if let Some((existing_id, _updated_at, _version)) = existing {
344 if !force_merge {
345 return Err(AppError::Duplicate(format!(
346 "memory '{normalized_name}' already exists; use --force-merge to update"
347 )));
348 }
349 let snippet: String = input.body.chars().take(200).collect();
350 let (old_fts_name, old_fts_desc, old_fts_body): (String, String, String) = tx.query_row(
353 "SELECT name, description, body FROM memories WHERE id = ?1",
354 rusqlite::params![existing_id],
355 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
356 )?;
357 memories::update(
358 tx,
359 existing_id,
360 &memories::NewMemory {
361 namespace: namespace.to_string(),
362 name: normalized_name.clone(),
363 memory_type: input.r#type.clone(),
364 description: input.description.clone(),
365 body: input.body.clone(),
366 body_hash,
367 session_id: None,
368 source: "agent".to_string(),
369 metadata: serde_json::json!({}),
370 },
371 None,
372 )?;
373 memories::sync_fts_after_update(
374 tx,
375 existing_id,
376 &old_fts_name,
377 &old_fts_desc,
378 &old_fts_body,
379 &normalized_name,
380 &input.description,
381 &input.body,
382 )?;
383 let next_v = versions::next_version(tx, existing_id)?;
384 versions::insert_version(
385 tx,
386 existing_id,
387 next_v,
388 &normalized_name,
389 &input.r#type,
390 &input.description,
391 &input.body,
392 "{}",
393 None,
394 "edit",
395 )?;
396
397 let skip_embed = crate::embedder::should_skip_embedding_on_failure();
398 match crate::embedder::embed_passage_with_embedding_choice(
399 &paths.models,
400 &input.body,
401 embedding_backend,
402 llm_backend,
403 ) {
404 Ok((embedding, _backend)) => {
405 memories::upsert_vec(
406 tx,
407 existing_id,
408 namespace,
409 &input.r#type,
410 &embedding,
411 &normalized_name,
412 &snippet,
413 )?;
414 }
415 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 (existing_id, "updated")
428 } else {
429 let new_mem = memories::NewMemory {
430 namespace: namespace.to_string(),
431 name: normalized_name.clone(),
432 memory_type: input.r#type.clone(),
433 description: input.description.clone(),
434 body: input.body.clone(),
435 body_hash,
436 session_id: None,
437 source: "agent".to_string(),
438 metadata: serde_json::json!({}),
439 };
440 let id = memories::insert(tx, &new_mem)?;
441 versions::insert_version(
442 tx,
443 id,
444 1,
445 &normalized_name,
446 &input.r#type,
447 &input.description,
448 &input.body,
449 "{}",
450 None,
451 "create",
452 )?;
453
454 let snippet: String = input.body.chars().take(200).collect();
455 let skip_embed = crate::embedder::should_skip_embedding_on_failure();
456 match crate::embedder::embed_passage_with_embedding_choice(
457 &paths.models,
458 &input.body,
459 embedding_backend,
460 llm_backend,
461 ) {
462 Ok((embedding, _backend)) => {
463 memories::upsert_vec(
464 tx,
465 id,
466 namespace,
467 &input.r#type,
468 &embedding,
469 &normalized_name,
470 &snippet,
471 )?;
472 }
473 Err(
474 e @ (AppError::Validation(_)
475 | AppError::BodyTooLarge { .. }
476 | AppError::TooManyTokens { .. }),
477 ) => return Err(e),
478 Err(e) if skip_embed => {
479 tracing::warn!(error = %e, "remember-batch: embedding failed; --skip-embedding-on-failure active, persisting without embedding");
480 }
481 Err(e) => return Err(e),
482 }
483 (id, "created")
484 };
485
486 for entity in &input.entities {
488 let entity_id = entities::upsert_entity(tx, namespace, entity)?;
489 let entity_text = match &entity.description {
490 Some(desc) => format!("{} {}", entity.name, desc),
491 None => entity.name.clone(),
492 };
493 let skip_embed = crate::embedder::should_skip_embedding_on_failure();
494 match crate::embedder::embed_entity_texts_cached(
495 &paths.models,
496 std::slice::from_ref(&entity_text),
497 1,
498 embedding_backend,
499 llm_backend,
500 ) {
501 Ok((entity_embedding_vec, _stats)) => {
502 if let Some(entity_embedding) = entity_embedding_vec.into_iter().next() {
503 entities::upsert_entity_vec(
504 tx,
505 entity_id,
506 namespace,
507 entity.entity_type,
508 &entity_embedding,
509 &entity.name,
510 )?;
511 }
512 }
513 Err(e) if skip_embed => {
514 tracing::warn!(error = %e, "remember-batch: entity embedding failed; --skip-embedding-on-failure active");
515 }
516 Err(e) => return Err(e),
517 }
518 entities::link_memory_entity(tx, memory_id, entity_id)?;
519 }
520
521 for rel in &input.relationships {
522 let src_name = crate::parsers::normalize_entity_name(&rel.source);
523 let tgt_name = crate::parsers::normalize_entity_name(&rel.target);
524 if let (Some(src_id), Some(tgt_id)) = (
525 entities::find_entity_id(tx, namespace, &src_name)?,
526 entities::find_entity_id(tx, namespace, &tgt_name)?,
527 ) {
528 entities::create_or_fetch_relationship(
529 tx,
530 namespace,
531 src_id,
532 tgt_id,
533 &rel.relation,
534 rel.strength,
535 rel.description.as_deref(),
536 )?;
537 }
538 }
539
540 let mut created_entity_names: Vec<String> = Vec::with_capacity(input.entities.len());
541 for entity in &input.entities {
542 created_entity_names.push(crate::parsers::normalize_entity_name(&entity.name));
543 }
544 Ok((
545 BatchItemEvent {
546 name: normalized_name,
547 status: batch_action.to_string(),
548 memory_id: Some(memory_id),
549 error: None,
550 index,
551 },
552 created_entity_names,
553 ))
554}