Skip to main content

sqlite_graphrag/commands/
split_body.rs

1//! Handler for the `split-body` CLI subcommand (v1.1.03, GAP-V8).
2//!
3//! Splits memories whose body exceeds a character threshold into N child
4//! memories, reusing the lossless section-based chunker
5//! [`crate::chunking::split_body_by_sections`]. The original memory is NOT
6//! soft-deleted: its `metadata` is annotated with `superseded_by_split` so
7//! history and searchability are preserved. Each child memory gets a
8//! canonical `replaces` graph relationship pointing back to the original.
9
10use 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
22/// Character threshold below which a memory is left untouched.
23pub 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)]
43/// Split body args.
44pub struct SplitBodyArgs {
45    /// Memory name as a positional argument. Alternative to `--name`.
46    ///
47    /// GAP-SG-272: `read` and `related` have accepted the name positionally for
48    /// releases, and this verb did not, so switching between them cost a trip to
49    /// `--help`. Conflicts are declared here rather than on both sides because
50    /// clap's conflicts are symmetric; stating them once keeps the two spellings
51    /// from drifting apart.
52    #[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    /// Memory name to split (single mode). Mutually exclusive with `--batch`.
59    #[arg(long, value_name = "NAME", conflicts_with = "batch")]
60    pub name: Option<String>,
61    /// Batch mode: split every active memory whose `LENGTH(body) > threshold`.
62    #[arg(long, default_value_t = false, conflicts_with = "name")]
63    pub batch: bool,
64    /// Only memories with `LENGTH(body) > threshold` are split. Default 25000 chars.
65    #[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    /// Namespace scope.
69    pub namespace: Option<String>,
70    /// Preview the split(s) without writing. Emits the planned child names and
71    /// body lengths.
72    #[arg(long, default_value_t = false)]
73    pub dry_run: bool,
74    /// Output format.
75    #[arg(long, value_enum, default_value_t = JsonOutputFormat::Json)]
76    pub format: JsonOutputFormat,
77    /// Emit machine-readable JSON on stdout.
78    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
79    pub json: bool,
80    /// Path to the SQLite database file.
81    #[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    /// Total execution time in milliseconds.
100    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    /// Total execution time in milliseconds.
111    elapsed_ms: u64,
112}
113
114/// Validates the parsed args and dispatches to single or batch mode.
115pub fn run(args: SplitBodyArgs) -> Result<(), AppError> {
116    let started = std::time::Instant::now();
117    let _ = args.format;
118
119    // GAP-SG-272: resolved once, so the two spellings cannot disagree between the
120    // guard below and the branch that uses the value. `or` is safe here precisely
121    // because clap already refused the case where both were supplied.
122    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        // Unreachable: validated at the top of run().
147        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                // Race: memory was removed between scan and split. Skip quietly.
179                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
196/// Splits a single memory by name into N child memories.
197///
198/// Reuses the lossless [`split_body_by_sections`] chunker so concatenating
199/// every child body reproduces the original body. The original memory is kept
200/// active and its `metadata` is annotated with `superseded_by_split` and the
201/// list of child names. Each child is inserted as a new memory (auto-embedded
202/// downstream by the remember path or `enrich --operation re-embed`) and linked
203/// to the original entity via a canonical `replaces` relationship.
204fn 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        // Body crosses the threshold but the chunker kept it as a single
231        // partition (e.g. under byte/chunk/token budgets). Nothing to split.
232        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    // Plan child names first so we can surface them in dry-run and metadata.
243    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    // Parse existing metadata (stored as TEXT JSON) and merge our markers.
272    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    // Insert each child as a new memory of type `document` (auto-embedded by
292    // downstream paths). We do NOT call the embedding pipeline here to keep
293    // the split atomic and side-effect-free; re-embed can be run separately.
294    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    // Annotate the original memory metadata (no soft-delete: history preserved).
311    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    // Record a version snapshot of the metadata annotation.
322    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    // Graph: each child entity `replaces` the original. Auto-create both
337    // endpoints so split-body is self-contained (no dependency on prior NER).
338    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
397/// Computes the BLAKE3 hex digest of `body`, matching the project convention.
398fn 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    /// `split_single` needs a `db_path` on disk so it can reopen a connection
413    /// for the transaction. This helper inserts the oversized memory and returns
414    /// the temp dir + db path.
415    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    /// Builds a Markdown body with many ATX sections so the section splitter
442    /// produces multiple partitions well above the 80 KiB byte budget.
443    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        // Verify each child memory was actually persisted.
466        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        // read_by_name filters deleted_at IS NULL: original must remain visible.
550        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        // No child should have been persisted.
565        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        // A parent name so long that even "{name}-part-0" exceeds MAX_MEMORY_NAME_LEN.
594        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}