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)]
37 pub namespace: Option<String>,
38 #[arg(long)]
40 pub json: bool,
41 #[arg(long)]
44 pub enqueue_enrich: bool,
45 #[arg(long)]
47 pub db: Option<String>,
48 #[arg(long, default_value_t = 4, value_name = "N",
54 value_parser = clap::value_parser!(u64).range(1..=32))]
55 pub llm_parallelism: u64,
56}
57
58#[derive(Deserialize)]
59struct BatchInputLine {
60 name: String,
61 #[serde(default = "default_type")]
62 r#type: String,
63 #[serde(default)]
64 description: String,
65 #[serde(default)]
66 body: String,
67 #[serde(default)]
68 entities: Vec<crate::storage::entities::NewEntity>,
69 #[serde(default)]
70 relationships: Vec<crate::storage::entities::NewRelationship>,
71}
72
73fn default_type() -> String {
74 "note".to_string()
75}
76
77#[derive(Serialize)]
78struct BatchItemEvent {
79 name: String,
80 status: String,
81 #[serde(skip_serializing_if = "Option::is_none")]
82 memory_id: Option<i64>,
83 #[serde(skip_serializing_if = "Option::is_none")]
84 error: Option<String>,
85 index: usize,
86}
87
88#[derive(Serialize)]
89struct BatchSummary {
90 summary: bool,
91 total: usize,
92 succeeded: usize,
93 failed: usize,
94 elapsed_ms: u64,
95 #[serde(default, skip_serializing_if = "Vec::is_empty")]
97 entities_created: Vec<String>,
98 #[serde(default, skip_serializing_if = "Vec::is_empty")]
100 enrich_recommended: Vec<String>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
103 enqueued_entity_descriptions: Option<usize>,
104}
105
106pub fn run(
107 args: RememberBatchArgs,
108 llm_backend: crate::cli::LlmBackendChoice,
109 embedding_backend: crate::cli::EmbeddingBackendChoice,
110) -> Result<(), AppError> {
111 let start = std::time::Instant::now();
112 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
113 let paths = AppPaths::resolve(args.db.as_deref())?;
114 paths.ensure_dirs()?;
115 crate::storage::connection::ensure_db_ready(&paths)?;
116 let mut conn = open_rw(&paths.db)?;
117
118 let stdin = std::io::stdin();
119 let lines: Vec<String> = stdin
120 .lock()
121 .lines()
122 .map_while(Result::ok)
123 .filter(|l| !l.trim().is_empty())
124 .collect();
125
126 let total = lines.len();
127 let mut succeeded = 0usize;
128 let mut failed = 0usize;
129
130 if args.dry_run {
131 for (idx, line) in lines.iter().enumerate() {
132 match serde_json::from_str::<BatchInputLine>(line) {
133 Ok(input) => {
134 let normalized_name = crate::parsers::normalize_entity_name(&input.name);
135 if normalized_name.is_empty() {
136 failed += 1;
137 output::emit_json(&BatchItemEvent {
138 name: String::new(),
139 status: "failed".to_string(),
140 memory_id: None,
141 error: Some(format!("line {idx}: name normalizes to empty string")),
142 index: idx,
143 })?;
144 continue;
145 }
146 let existing = memories::find_by_name(&conn, &namespace, &normalized_name)?;
147 let action = if existing.is_some() {
148 if args.force_merge {
149 "would_update"
150 } else {
151 "would_fail_duplicate"
152 }
153 } else {
154 "would_create"
155 };
156 succeeded += 1;
157 output::emit_json(&BatchItemEvent {
158 name: normalized_name,
159 status: action.to_string(),
160 memory_id: existing.map(|(id, _, _)| id),
161 error: None,
162 index: idx,
163 })?;
164 }
165 Err(e) => {
166 failed += 1;
167 output::emit_json(&BatchItemEvent {
168 name: String::new(),
169 status: "failed".to_string(),
170 memory_id: None,
171 error: Some(format!("line {idx}: invalid JSON: {e}")),
172 index: idx,
173 })?;
174 }
175 }
176 }
177
178 output::emit_json(&BatchSummary {
179 summary: true,
180 total,
181 succeeded,
182 failed,
183 elapsed_ms: start.elapsed().as_millis() as u64,
184 entities_created: vec![],
185 enrich_recommended: vec![],
186 enqueued_entity_descriptions: None,
187 })?;
188 return Ok(());
189 }
190
191 let mut all_entities: Vec<String> = Vec::new();
192 if args.transaction {
193 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
194 for (idx, line) in lines.iter().enumerate() {
195 match process_line(
196 &tx,
197 &namespace,
198 line,
199 idx,
200 args.force_merge,
201 &paths,
202 llm_backend,
203 embedding_backend,
204 ) {
205 Ok((event, ent_names)) => {
206 output::emit_json(&event)?;
207 all_entities.extend(ent_names);
208 succeeded += 1;
209 }
210 Err(e) => {
211 failed += 1;
212 output::emit_json(&BatchItemEvent {
213 name: String::new(),
214 status: "failed".to_string(),
215 memory_id: None,
216 error: Some(format!("{e}")),
217 index: idx,
218 })?;
219 if args.fail_fast {
220 break;
221 }
222 }
223 }
224 }
225 if failed == 0 || !args.fail_fast {
226 tx.commit()?;
227 }
228 } else {
229 for (idx, line) in lines.iter().enumerate() {
230 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
231 match process_line(
232 &tx,
233 &namespace,
234 line,
235 idx,
236 args.force_merge,
237 &paths,
238 llm_backend,
239 embedding_backend,
240 ) {
241 Ok((event, ent_names)) => {
242 tx.commit()?;
243 output::emit_json(&event)?;
244 all_entities.extend(ent_names);
245 succeeded += 1;
246 }
247 Err(e) => {
248 drop(tx);
249 failed += 1;
250 output::emit_json(&BatchItemEvent {
251 name: String::new(),
252 status: "failed".to_string(),
253 memory_id: None,
254 error: Some(format!("{e}")),
255 index: idx,
256 })?;
257 if args.fail_fast {
258 break;
259 }
260 }
261 }
262 }
263 }
264
265 all_entities.sort();
267 all_entities.dedup();
268 let mut enrich_recommended = Vec::new();
269 if !all_entities.is_empty() {
270 enrich_recommended.push("entity-descriptions".to_string());
271 }
272 let mut enqueued_entity_descriptions = None;
273 if args.enqueue_enrich && !all_entities.is_empty() {
274 match crate::commands::enrich::enqueue_priority_entity_descriptions(
275 &paths,
276 &namespace,
277 &all_entities,
278 ) {
279 Ok(n) => enqueued_entity_descriptions = Some(n),
280 Err(e) => {
281 tracing::warn!(
282 error = %e,
283 "remember-batch: enqueue_enrich failed (entities still listed in entities_created)"
284 );
285 }
286 }
287 }
288
289 output::emit_json(&BatchSummary {
290 summary: true,
291 total,
292 succeeded,
293 failed,
294 elapsed_ms: start.elapsed().as_millis() as u64,
295 entities_created: all_entities,
296 enrich_recommended,
297 enqueued_entity_descriptions,
298 })?;
299
300 Ok(())
301}
302
303#[allow(clippy::too_many_arguments)]
304fn process_line(
305 tx: &rusqlite::Transaction<'_>,
306 namespace: &str,
307 line: &str,
308 index: usize,
309 force_merge: bool,
310 paths: &AppPaths,
311 llm_backend: crate::cli::LlmBackendChoice,
312 embedding_backend: crate::cli::EmbeddingBackendChoice,
313) -> Result<(BatchItemEvent, Vec<String>), AppError> {
314 let input: BatchInputLine = serde_json::from_str(line)
315 .map_err(|e| AppError::Validation(format!("line {index}: invalid JSON: {e}")))?;
316
317 let normalized_name = crate::parsers::normalize_entity_name(&input.name);
318 if normalized_name.is_empty() {
319 return Err(AppError::Validation(format!(
320 "line {index}: name normalizes to empty string"
321 )));
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(format!(
336 "line {index}: --type and --description are required when creating a new memory"
337 )));
338 }
339
340 let (memory_id, batch_action) = if let Some((existing_id, _updated_at, _version)) = existing {
341 if !force_merge {
342 return Err(AppError::Duplicate(format!(
343 "memory '{normalized_name}' already exists; use --force-merge to update"
344 )));
345 }
346 let snippet: String = input.body.chars().take(200).collect();
347 let (old_fts_name, old_fts_desc, old_fts_body): (String, String, String) = tx.query_row(
350 "SELECT name, description, body FROM memories WHERE id = ?1",
351 rusqlite::params![existing_id],
352 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
353 )?;
354 memories::update(
355 tx,
356 existing_id,
357 &memories::NewMemory {
358 namespace: namespace.to_string(),
359 name: normalized_name.clone(),
360 memory_type: input.r#type.clone(),
361 description: input.description.clone(),
362 body: input.body.clone(),
363 body_hash,
364 session_id: None,
365 source: "agent".to_string(),
366 metadata: serde_json::json!({}),
367 },
368 None,
369 )?;
370 memories::sync_fts_after_update(
371 tx,
372 existing_id,
373 &old_fts_name,
374 &old_fts_desc,
375 &old_fts_body,
376 &normalized_name,
377 &input.description,
378 &input.body,
379 )?;
380 let next_v = versions::next_version(tx, existing_id)?;
381 versions::insert_version(
382 tx,
383 existing_id,
384 next_v,
385 &normalized_name,
386 &input.r#type,
387 &input.description,
388 &input.body,
389 "{}",
390 None,
391 "edit",
392 )?;
393
394 let skip_embed = crate::embedder::should_skip_embedding_on_failure();
395 match crate::embedder::embed_passage_with_embedding_choice(
396 &paths.models,
397 &input.body,
398 embedding_backend,
399 llm_backend,
400 ) {
401 Ok((embedding, _backend)) => {
402 memories::upsert_vec(
403 tx,
404 existing_id,
405 namespace,
406 &input.r#type,
407 &embedding,
408 &normalized_name,
409 &snippet,
410 )?;
411 }
412 Err(
415 e @ (AppError::Validation(_)
416 | AppError::BodyTooLarge { .. }
417 | AppError::TooManyTokens { .. }),
418 ) => return Err(e),
419 Err(e) if skip_embed => {
420 tracing::warn!(error = %e, "remember-batch: embedding failed; --skip-embedding-on-failure active, persisting without embedding");
421 }
422 Err(e) => return Err(e),
423 }
424 (existing_id, "updated")
425 } else {
426 let new_mem = memories::NewMemory {
427 namespace: namespace.to_string(),
428 name: normalized_name.clone(),
429 memory_type: input.r#type.clone(),
430 description: input.description.clone(),
431 body: input.body.clone(),
432 body_hash,
433 session_id: None,
434 source: "agent".to_string(),
435 metadata: serde_json::json!({}),
436 };
437 let id = memories::insert(tx, &new_mem)?;
438 versions::insert_version(
439 tx,
440 id,
441 1,
442 &normalized_name,
443 &input.r#type,
444 &input.description,
445 &input.body,
446 "{}",
447 None,
448 "create",
449 )?;
450
451 let snippet: String = input.body.chars().take(200).collect();
452 let skip_embed = crate::embedder::should_skip_embedding_on_failure();
453 match crate::embedder::embed_passage_with_embedding_choice(
454 &paths.models,
455 &input.body,
456 embedding_backend,
457 llm_backend,
458 ) {
459 Ok((embedding, _backend)) => {
460 memories::upsert_vec(
461 tx,
462 id,
463 namespace,
464 &input.r#type,
465 &embedding,
466 &normalized_name,
467 &snippet,
468 )?;
469 }
470 Err(
471 e @ (AppError::Validation(_)
472 | AppError::BodyTooLarge { .. }
473 | AppError::TooManyTokens { .. }),
474 ) => return Err(e),
475 Err(e) if skip_embed => {
476 tracing::warn!(error = %e, "remember-batch: embedding failed; --skip-embedding-on-failure active, persisting without embedding");
477 }
478 Err(e) => return Err(e),
479 }
480 (id, "created")
481 };
482
483 for entity in &input.entities {
485 let entity_id = entities::upsert_entity(tx, namespace, entity)?;
486 let entity_text = match &entity.description {
487 Some(desc) => format!("{} {}", entity.name, desc),
488 None => entity.name.clone(),
489 };
490 let skip_embed = crate::embedder::should_skip_embedding_on_failure();
491 match crate::embedder::embed_entity_texts_cached(
492 &paths.models,
493 std::slice::from_ref(&entity_text),
494 1,
495 embedding_backend,
496 llm_backend,
497 ) {
498 Ok((entity_embedding_vec, _stats)) => {
499 if let Some(entity_embedding) = entity_embedding_vec.into_iter().next() {
500 entities::upsert_entity_vec(
501 tx,
502 entity_id,
503 namespace,
504 entity.entity_type,
505 &entity_embedding,
506 &entity.name,
507 )?;
508 }
509 }
510 Err(e) if skip_embed => {
511 tracing::warn!(error = %e, "remember-batch: entity embedding failed; --skip-embedding-on-failure active");
512 }
513 Err(e) => return Err(e),
514 }
515 entities::link_memory_entity(tx, memory_id, entity_id)?;
516 }
517
518 for rel in &input.relationships {
519 let src_name = crate::parsers::normalize_entity_name(&rel.source);
520 let tgt_name = crate::parsers::normalize_entity_name(&rel.target);
521 if let (Some(src_id), Some(tgt_id)) = (
522 entities::find_entity_id(tx, namespace, &src_name)?,
523 entities::find_entity_id(tx, namespace, &tgt_name)?,
524 ) {
525 entities::create_or_fetch_relationship(
526 tx,
527 namespace,
528 src_id,
529 tgt_id,
530 &rel.relation,
531 rel.strength,
532 rel.description.as_deref(),
533 )?;
534 }
535 }
536
537 let mut created_entity_names: Vec<String> = Vec::with_capacity(input.entities.len());
538 for entity in &input.entities {
539 created_entity_names.push(crate::parsers::normalize_entity_name(&entity.name));
540 }
541 Ok((
542 BatchItemEvent {
543 name: normalized_name,
544 status: batch_action.to_string(),
545 memory_id: Some(memory_id),
546 error: None,
547 index,
548 },
549 created_entity_names,
550 ))
551}