1use crate::chunking::split_body_by_sections;
11use crate::constants::{DEFAULT_RELATION_WEIGHT, MAX_MEMORY_NAME_LEN};
12use crate::errors::AppError;
13use crate::i18n::errors_msg;
14use crate::output::{self, JsonOutputFormat};
15use crate::paths::AppPaths;
16use crate::storage::connection::open_rw;
17use crate::storage::entities::{self, NewEntity};
18use crate::storage::{memories, versions};
19use rusqlite::params;
20use serde::Serialize;
21
22pub const DEFAULT_SPLIT_THRESHOLD: usize = 25_000;
24
25#[derive(clap::Args)]
26#[command(
27 about = "Split oversized memories into N child memories at Markdown section boundaries",
28 after_long_help = "EXAMPLES:\n \
29 # Split a single memory by name using the default 25k char threshold\n \
30 sqlite-graphrag split-body --name big-doc\n\n \
31 # Preview the split without writing\n \
32 sqlite-graphrag split-body --name big-doc --dry-run\n\n \
33 # Batch-split every oversized memory in the current namespace\n \
34 sqlite-graphrag split-body --batch\n\n \
35 # Batch-split with a custom threshold and namespace\n \
36 sqlite-graphrag split-body --batch --threshold 50000 --namespace my-project\n\n \
37 NOTE:\n \
38 The original memory is NEVER soft-deleted. It is annotated with\n \
39 metadata.superseded_by_split=true and metadata.split_into=[child names]\n \
40 so its history and searchability are preserved. Each child memory\n \
41 gets a canonical `replaces` graph relationship to the original."
42)]
43pub struct SplitBodyArgs {
45 #[arg(
53 value_name = "NAME",
54 conflicts_with_all = ["name", "batch"],
55 help = "Memory name (kebab-case slug); alternative to --name"
56 )]
57 pub name_positional: Option<String>,
58 #[arg(long, value_name = "NAME", conflicts_with = "batch")]
60 pub name: Option<String>,
61 #[arg(long, default_value_t = false, conflicts_with = "name")]
63 pub batch: bool,
64 #[arg(long, value_name = "N", default_value_t = DEFAULT_SPLIT_THRESHOLD)]
66 pub threshold: usize,
67 #[arg(long, help = "Namespace (flag / XDG namespace.default / global)")]
68 pub namespace: Option<String>,
70 #[arg(long, default_value_t = false)]
73 pub dry_run: bool,
74 #[arg(long, value_enum, default_value_t = JsonOutputFormat::Json)]
76 pub format: JsonOutputFormat,
77 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
79 pub json: bool,
80 #[arg(long)]
82 pub db: Option<String>,
83}
84
85#[derive(Serialize)]
86struct ChildPlan {
87 name: String,
88 body_len: usize,
89}
90
91#[derive(Serialize)]
92struct SplitResult {
93 original: String,
94 #[serde(skip_serializing_if = "Option::is_none")]
95 original_id: Option<i64>,
96 children: Vec<ChildPlan>,
97 threshold: usize,
98 dry_run: bool,
99 elapsed_ms: u64,
101}
102
103#[derive(Serialize)]
104struct BatchResult {
105 namespace: String,
106 threshold: usize,
107 dry_run: bool,
108 split: Vec<SplitResult>,
109 skipped: usize,
110 elapsed_ms: u64,
112}
113
114pub fn run(args: SplitBodyArgs) -> Result<(), AppError> {
116 let started = std::time::Instant::now();
117 let _ = args.format;
118
119 let designated_name = args.name_positional.as_deref().or(args.name.as_deref());
123
124 if !args.batch && designated_name.is_none() {
125 return Err(AppError::Validation(
126 crate::i18n::validation::split_body_needs_name_or_batch(),
127 ));
128 }
129 if args.threshold == 0 {
130 return Err(AppError::Validation(
131 "--threshold must be greater than zero".to_string(),
132 ));
133 }
134
135 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
136 let paths = AppPaths::resolve(args.db.as_deref())?;
137 crate::storage::connection::ensure_db_ready(&paths)?;
138
139 if args.batch {
140 run_batch(&paths.db, &namespace, args.threshold, args.dry_run, started)
141 } else if let Some(name) = designated_name {
142 let result = split_single(&paths.db, &namespace, name, args.threshold, args.dry_run)?;
143 output::emit_json(&result)?;
144 Ok(())
145 } else {
146 Err(AppError::Validation(
148 "either --name <NAME> or --batch is required".to_string(),
149 ))
150 }
151}
152
153fn run_batch(
154 db_path: &std::path::Path,
155 namespace: &str,
156 threshold: usize,
157 dry_run: bool,
158 started: std::time::Instant,
159) -> Result<(), AppError> {
160 let conn = open_rw(db_path)?;
161 let mut stmt =
162 conn.prepare("SELECT name FROM memories WHERE namespace = ?1 AND deleted_at IS NULL AND LENGTH(body) > ?2")?;
163 let mut rows = stmt.query(params![namespace, threshold as i64])?;
164 let mut names: Vec<String> = Vec::new();
165 while let Some(row) = rows.next()? {
166 names.push(row.get::<_, String>(0)?);
167 }
168 drop(rows);
169 drop(stmt);
170 drop(conn);
171
172 let mut split: Vec<SplitResult> = Vec::new();
173 let mut skipped = 0usize;
174 for name in &names {
175 match split_single(db_path, namespace, name, threshold, dry_run) {
176 Ok(r) => split.push(r),
177 Err(AppError::NotFound(_)) => {
178 skipped += 1;
180 }
181 Err(e) => return Err(e),
182 }
183 }
184
185 output::emit_json(&BatchResult {
186 namespace: namespace.to_string(),
187 threshold,
188 dry_run,
189 split,
190 skipped,
191 elapsed_ms: started.elapsed().as_millis() as u64,
192 })?;
193 Ok(())
194}
195
196fn split_single(
205 db_path: &std::path::Path,
206 namespace: &str,
207 name: &str,
208 threshold: usize,
209 dry_run: bool,
210) -> Result<SplitResult, AppError> {
211 let start = std::time::Instant::now();
212
213 let mut conn = open_rw(db_path)?;
214 let row = memories::read_by_name(&conn, namespace, name)?
215 .ok_or_else(|| AppError::NotFound(errors_msg::memory_not_found(name, namespace)))?;
216
217 if row.body.len() <= threshold {
218 return Ok(SplitResult {
219 original: name.to_string(),
220 original_id: Some(row.id),
221 children: Vec::new(),
222 threshold,
223 dry_run,
224 elapsed_ms: start.elapsed().as_millis() as u64,
225 });
226 }
227
228 let partitions = split_body_by_sections(&row.body);
229 if partitions.len() < 2 {
230 return Ok(SplitResult {
233 original: name.to_string(),
234 original_id: Some(row.id),
235 children: Vec::new(),
236 threshold,
237 dry_run,
238 elapsed_ms: start.elapsed().as_millis() as u64,
239 });
240 }
241
242 let child_names: Vec<String> = (0..partitions.len())
244 .map(|i| format!("{name}-part-{i}"))
245 .collect();
246
247 for child in &child_names {
248 validate_child_name(child, name)?;
249 }
250
251 let children_plan: Vec<ChildPlan> = partitions
252 .iter()
253 .zip(child_names.iter())
254 .map(|(body, n)| ChildPlan {
255 name: n.clone(),
256 body_len: body.len(),
257 })
258 .collect();
259
260 if dry_run {
261 return Ok(SplitResult {
262 original: name.to_string(),
263 original_id: Some(row.id),
264 children: children_plan,
265 threshold,
266 dry_run: true,
267 elapsed_ms: start.elapsed().as_millis() as u64,
268 });
269 }
270
271 let mut metadata: serde_json::Value =
273 serde_json::from_str(&row.metadata).unwrap_or_else(|_| serde_json::json!({}));
274 let map = match metadata.as_object_mut() {
275 Some(m) => m,
276 None => {
277 return Err(AppError::Internal(anyhow::anyhow!(
278 "metadata for memory '{name}' is not a JSON object"
279 )));
280 }
281 };
282 map.insert("superseded_by_split".to_string(), serde_json::json!(true));
283 map.insert(
284 "split_into".to_string(),
285 serde_json::to_value(&child_names)?,
286 );
287 let metadata_str = serde_json::to_string(&metadata)?;
288
289 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
290
291 for (body_part, child_name) in partitions.iter().zip(child_names.iter()) {
295 let child_hash = blake3_hash(body_part);
296 let new_memory = memories::NewMemory {
297 namespace: namespace.to_string(),
298 name: child_name.clone(),
299 memory_type: "document".to_string(),
300 description: format!("Partição de '{name}' (split-body)"),
301 body: body_part.clone(),
302 body_hash: child_hash,
303 session_id: row.session_id.clone(),
304 source: "system".to_string(),
305 metadata: serde_json::json!({ "split_from": name }),
306 };
307 memories::insert(&tx, &new_memory)?;
308 }
309
310 let affected = tx.execute(
312 "UPDATE memories SET metadata = ?2 WHERE id = ?1 AND deleted_at IS NULL",
313 params![row.id, metadata_str],
314 )?;
315 if affected == 0 {
316 return Err(AppError::Conflict(
317 crate::i18n::errors_ops::memory_modified_concurrently(name),
318 ));
319 }
320
321 let next_v = versions::next_version(&tx, row.id)?;
323 versions::insert_version(
324 &tx,
325 row.id,
326 next_v,
327 &row.name,
328 &row.memory_type,
329 &row.description,
330 &row.body,
331 &metadata_str,
332 None,
333 "edit",
334 )?;
335
336 let original_entity_name = crate::parsers::normalize_entity_name(name);
339 let original_entity = NewEntity {
340 name: original_entity_name.clone(),
341 entity_type: "memory".to_string(),
342 description: None,
343 };
344 let original_entity_id = entities::upsert_entity(&tx, namespace, &original_entity)?;
345
346 for child_name in &child_names {
347 let child_entity_name = crate::parsers::normalize_entity_name(child_name);
348 let child_entity = NewEntity {
349 name: child_entity_name.clone(),
350 entity_type: "memory".to_string(),
351 description: None,
352 };
353 let child_entity_id = entities::upsert_entity(&tx, namespace, &child_entity)?;
354 let (_, was_created) = entities::create_or_fetch_relationship(
355 &tx,
356 namespace,
357 child_entity_id,
358 original_entity_id,
359 "replaces",
360 DEFAULT_RELATION_WEIGHT,
361 None,
362 )?;
363 if was_created {
364 entities::recalculate_degree(&tx, child_entity_id)?;
365 entities::recalculate_degree(&tx, original_entity_id)?;
366 }
367 }
368
369 tx.commit()?;
370 conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
371
372 Ok(SplitResult {
373 original: name.to_string(),
374 original_id: Some(row.id),
375 children: children_plan,
376 threshold,
377 dry_run: false,
378 elapsed_ms: start.elapsed().as_millis() as u64,
379 })
380}
381
382fn validate_child_name(child: &str, parent: &str) -> Result<(), AppError> {
383 if child.is_empty() || child.len() > MAX_MEMORY_NAME_LEN {
384 return Err(AppError::Validation(
385 crate::i18n::validation::child_name_exceeds_max(child, parent, MAX_MEMORY_NAME_LEN),
386 ));
387 }
388 let slug_re = crate::constants::name_slug_regex();
389 if !slug_re.is_match(child) {
390 return Err(AppError::Validation(
391 crate::i18n::validation::child_name_not_kebab(child),
392 ));
393 }
394 Ok(())
395}
396
397fn blake3_hash(body: &str) -> String {
399 use blake3::Hasher;
400 let mut hasher = Hasher::new();
401 hasher.update(body.as_bytes());
402 hasher.finalize().to_hex().to_string()
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408 use crate::storage::memories::{insert, NewMemory};
409 use rusqlite::Connection;
410 use tempfile::TempDir;
411
412 fn setup_with_memory(name: &str, body_len: usize) -> (TempDir, std::path::PathBuf) {
416 crate::storage::connection::register_vec_extension();
417 let dir = TempDir::new().unwrap();
418 let db_path = dir.path().join("test.db");
419 let mut conn = Connection::open(&db_path).unwrap();
420 crate::migrations::runner().run(&mut conn).unwrap();
421 let body = body_with_headers(body_len);
422 insert(
423 &conn,
424 &NewMemory {
425 namespace: "global".to_string(),
426 name: name.to_string(),
427 memory_type: "document".to_string(),
428 description: "desc".to_string(),
429 body,
430 body_hash: format!("hash-{name}"),
431 session_id: None,
432 source: "agent".to_string(),
433 metadata: serde_json::json!({}),
434 },
435 )
436 .unwrap();
437 drop(conn);
438 (dir, db_path)
439 }
440
441 fn body_with_headers(total_bytes: usize) -> String {
444 let mut body = String::new();
445 let mut i = 0;
446 while body.len() < total_bytes {
447 body.push_str(&format!(
448 "# Section {i}\n\n{}\n\n",
449 "body content text here. ".repeat(200)
450 ));
451 i += 1;
452 }
453 body
454 }
455
456 #[test]
457 fn split_body_divides_long_memory_into_parts() {
458 let (_dir, db_path) = setup_with_memory("big-doc", 100_000);
459 let result = split_single(&db_path, "global", "big-doc", 25_000, false).unwrap();
460 assert!(
461 result.children.len() >= 2,
462 "expected 2+ children, got {}",
463 result.children.len()
464 );
465 let conn = Connection::open(&db_path).unwrap();
467 for child in &result.children {
468 let row = crate::storage::memories::read_by_name(&conn, "global", &child.name)
469 .unwrap()
470 .expect("child memory should exist");
471 assert_eq!(row.memory_type, "document");
472 assert!(
473 row.body.len() <= crate::constants::AUTOSPLIT_PARTITION_MAX_BYTES,
474 "child body {} exceeds partition budget",
475 row.body.len()
476 );
477 }
478 }
479
480 #[test]
481 fn split_body_marks_original_as_superseded() {
482 let (_dir, db_path) = setup_with_memory("super-test", 100_000);
483 split_single(&db_path, "global", "super-test", 25_000, false).unwrap();
484
485 let conn = Connection::open(&db_path).unwrap();
486 let row = crate::storage::memories::read_by_name(&conn, "global", "super-test")
487 .unwrap()
488 .expect("original memory must remain");
489 let metadata: serde_json::Value = serde_json::from_str(&row.metadata).unwrap();
490 assert_eq!(
491 metadata
492 .get("superseded_by_split")
493 .and_then(|v| v.as_bool()),
494 Some(true),
495 "metadata.superseded_by_split must be true after split"
496 );
497 let split_into = metadata
498 .get("split_into")
499 .and_then(|v| v.as_array())
500 .expect("metadata.split_into must be an array");
501 assert!(
502 split_into.len() >= 2,
503 "metadata.split_into must list 2+ children"
504 );
505 }
506
507 #[test]
508 fn split_body_creates_replaces_relations() {
509 let (_dir, db_path) = setup_with_memory("rel-source", 100_000);
510 let result = split_single(&db_path, "global", "rel-source", 25_000, false).unwrap();
511
512 let conn = Connection::open(&db_path).unwrap();
513 let original_entity = crate::parsers::normalize_entity_name("rel-source");
514 let original_id =
515 crate::storage::entities::find_entity_id(&conn, "global", &original_entity)
516 .unwrap()
517 .expect("original entity must exist");
518
519 let mut count = 0;
520 for child in &result.children {
521 let child_entity = crate::parsers::normalize_entity_name(&child.name);
522 let child_id = crate::storage::entities::find_entity_id(&conn, "global", &child_entity)
523 .unwrap()
524 .expect("child entity must exist");
525 let exists: bool = conn
526 .query_row(
527 "SELECT EXISTS(SELECT 1 FROM relationships
528 WHERE source_id = ?1 AND target_id = ?2 AND relation = 'replaces')",
529 rusqlite::params![child_id, original_id],
530 |r| r.get(0),
531 )
532 .unwrap();
533 assert!(
534 exists,
535 "child '{}' must `replaces` the original",
536 child.name
537 );
538 count += 1;
539 }
540 assert!(count >= 2, "expected 2+ replaces relations, got {count}");
541 }
542
543 #[test]
544 fn split_body_preserves_history() {
545 let (_dir, db_path) = setup_with_memory("hist-keep", 100_000);
546 split_single(&db_path, "global", "hist-keep", 25_000, false).unwrap();
547
548 let conn = Connection::open(&db_path).unwrap();
549 let row = crate::storage::memories::read_by_name(&conn, "global", "hist-keep")
551 .unwrap()
552 .expect("original must remain active (not soft-deleted)");
553 assert!(row.deleted_at.is_none(), "deleted_at must stay NULL");
554 }
555
556 #[test]
557 fn dry_run_writes_nothing() {
558 let (_dir, db_path) = setup_with_memory("dry-only", 100_000);
559 let result = split_single(&db_path, "global", "dry-only", 25_000, true).unwrap();
560 assert!(result.dry_run);
561 assert!(result.children.len() >= 2);
562
563 let conn = Connection::open(&db_path).unwrap();
564 for child in &result.children {
566 assert!(
567 crate::storage::memories::read_by_name(&conn, "global", &child.name)
568 .unwrap()
569 .is_none(),
570 "dry-run must not persist child '{}'",
571 child.name
572 );
573 }
574 let row = crate::storage::memories::read_by_name(&conn, "global", "dry-only")
575 .unwrap()
576 .expect("original must exist");
577 let metadata: serde_json::Value = serde_json::from_str(&row.metadata).unwrap();
578 assert!(
579 metadata.get("superseded_by_split").is_none(),
580 "dry-run must not annotate metadata"
581 );
582 }
583
584 #[test]
585 fn below_threshold_returns_empty_children() {
586 let (_dir, db_path) = setup_with_memory("small-doc", 1_000);
587 let result = split_single(&db_path, "global", "small-doc", 25_000, false).unwrap();
588 assert!(result.children.is_empty());
589 }
590
591 #[test]
592 fn validate_child_name_rejects_too_long_parent() {
593 let long_parent = "a".repeat(MAX_MEMORY_NAME_LEN);
595 let child = format!("{long_parent}-part-0");
596 let err = validate_child_name(&child, &long_parent).unwrap_err();
597 assert!(matches!(err, AppError::Validation(_)));
598 }
599}