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