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