1use crate::chunking;
35use crate::cli::MemoryType;
36use crate::entity_type::EntityType;
37use crate::errors::AppError;
38use crate::i18n::errors_msg;
39use crate::output::{self, JsonOutputFormat};
40use crate::paths::AppPaths;
41use crate::storage::chunks as storage_chunks;
42use crate::storage::connection::{ensure_db_ready, open_rw};
43use crate::storage::entities::{NewEntity, NewRelationship};
44use crate::storage::memories::NewMemory;
45use crate::storage::{entities, memories, urls as storage_urls, versions};
46use rayon::prelude::*;
47use rusqlite::Connection;
48use serde::Serialize;
49use std::collections::BTreeSet;
50use std::path::{Path, PathBuf};
51use std::sync::mpsc;
52use unicode_normalization::UnicodeNormalization;
53
54use crate::constants::DERIVED_NAME_MAX_LEN;
55
56const MAX_NAME_COLLISION_SUFFIX: usize = 1000;
59
60#[derive(clap::Args)]
61#[command(after_long_help = "EXAMPLES:\n \
62 # Ingest every Markdown file under ./docs as `document` memories\n \
63 sqlite-graphrag ingest ./docs --type document\n\n \
64 # Ingest .txt files recursively under ./notes\n \
65 sqlite-graphrag ingest ./notes --type note --pattern '*.txt' --recursive\n\n \
66 # Namespace derived names with a kebab-case prefix (projx-<derived>)\n \
67 sqlite-graphrag ingest ./docs --name-prefix projx- --dry-run\n\n \
68 # Enable automatic URL extraction (URL-regex only since v1.0.79)\n \
69 sqlite-graphrag ingest ./big-corpus --type reference --enable-ner\n\n \
70 # Preview file-to-name mapping without ingesting\n \
71 sqlite-graphrag ingest ./docs --dry-run\n\n \
72 # LLM-curated extraction via Claude Code CLI\n \
73 sqlite-graphrag ingest ./docs --mode claude-code --recursive --json\n\n \
74 # Resume interrupted claude-code ingest\n \
75 sqlite-graphrag ingest ./docs --mode claude-code --resume --json\n\n \
76 # Claude Code with budget cap and custom timeout\n \
77 sqlite-graphrag ingest ./docs --mode claude-code --max-cost-usd 5.00 --claude-timeout 600 --json\n\n \
78AUTHENTICATION:\n \
79 --mode claude-code: Uses existing Claude Code authentication.\n \
80 OAuth (Pro/Max/Team): works automatically from ~/.claude/.credentials.json\n \
81 API key: set ANTHROPIC_API_KEY for faster startup (optional)\n\n \
82 --mode codex: Uses existing Codex CLI authentication.\n \
83 Device auth: run `codex auth login` first\n \
84 API key: set OPENAI_API_KEY (optional)\n\n \
85NOTES:\n \
86 Each file becomes a separate memory. Names derive from file basenames\n \
87 (kebab-case, lowercase, ASCII). Output is NDJSON: one JSON object per file,\n \
88 followed by a final summary line with counts. Per-file errors are reported\n \
89 inline and processing continues unless --fail-fast is set.")]
90pub struct IngestArgs {
91 #[arg(
93 value_name = "DIR",
94 help = "Directory to ingest recursively (each matching file becomes a memory)"
95 )]
96 pub dir: PathBuf,
97
98 #[arg(long, value_enum, default_value_t = MemoryType::Document)]
100 pub r#type: MemoryType,
101
102 #[arg(long, default_value = "*.md")]
105 pub pattern: String,
106
107 #[arg(long, default_value_t = false)]
109 pub recursive: bool,
110
111 #[arg(
112 long,
113 env = "SQLITE_GRAPHRAG_ENABLE_NER",
114 value_parser = crate::parsers::parse_bool_flexible,
115 action = clap::ArgAction::Set,
116 num_args = 0..=1,
117 default_missing_value = "true",
118 default_value = "false",
119 help = "Enable automatic URL-regex extraction (URL-regex only since v1.0.79)"
120 )]
121 pub enable_ner: bool,
122
123 #[arg(
127 long,
128 default_value_t = true,
129 overrides_with = "no_auto_describe",
130 help = "Derive memory description from the first meaningful body line instead of the legacy `ingested from <path>` placeholder."
131 )]
132 pub auto_describe: bool,
133 #[arg(
134 long = "no-auto-describe",
135 default_value_t = false,
136 help = "Disable `--auto-describe` and fall back to the legacy `ingested from <path>` description placeholder."
137 )]
138 pub no_auto_describe: bool,
139
140 #[arg(long, default_value_t = false, hide = true)]
142 pub skip_extraction: bool,
143
144 #[arg(long, default_value_t = false)]
146 pub fail_fast: bool,
147
148 #[arg(long, default_value_t = false)]
150 pub dry_run: bool,
151
152 #[arg(long, default_value_t = 10_000)]
154 pub max_files: usize,
155
156 #[arg(long)]
158 pub namespace: Option<String>,
159
160 #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
162 pub db: Option<String>,
163
164 #[arg(long, value_enum, default_value_t = JsonOutputFormat::Json)]
165 pub format: JsonOutputFormat,
166
167 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
168 pub json: bool,
169
170 #[arg(
172 long,
173 help = "Number of files to extract+embed in parallel; default = max(1, cpus/2).min(4)"
174 )]
175 pub ingest_parallelism: Option<usize>,
176
177 #[arg(
185 long,
186 default_value_t = false,
187 help = "Forces single-threaded ingest (--ingest-parallelism 1) to reduce RSS pressure. \
188 Recommended for environments with <4 GB available RAM or container/cgroup \
189 constraints. Trade-off: 3-4x longer wall time. Also honored via \
190 SQLITE_GRAPHRAG_LOW_MEMORY=1 env var."
191 )]
192 pub low_memory: bool,
193
194 #[arg(long, default_value_t = crate::constants::DEFAULT_MAX_RSS_MB,
196 help = "Maximum process RSS in MiB; abort if exceeded during embedding (default: 8192)")]
197 pub max_rss_mb: u64,
198
199 #[arg(long, default_value_t = 2, value_name = "N",
204 value_parser = clap::value_parser!(u64).range(1..=32),
205 help = "Maximum simultaneous LLM embedding subprocesses per file (default: 2, clamp [1,32])")]
206 pub llm_parallelism: u64,
207
208 #[arg(long, default_value_t = crate::constants::DERIVED_NAME_MAX_LEN,
213 help = "Maximum length for derived memory names (default: 60)")]
214 pub max_name_length: usize,
215
216 #[arg(
222 long,
223 value_name = "PREFIX",
224 help = "Kebab-case prefix applied to every derived memory name (e.g. 'projx-')"
225 )]
226 pub name_prefix: Option<String>,
227
228 #[arg(long, value_enum, default_value_t = IngestMode::None)]
230 pub mode: IngestMode,
231
232 #[arg(long, env = "SQLITE_GRAPHRAG_CLAUDE_BINARY")]
234 pub claude_binary: Option<std::path::PathBuf>,
235
236 #[arg(long)]
238 pub claude_model: Option<String>,
239
240 #[arg(long, default_value_t = false)]
242 pub resume: bool,
243
244 #[arg(long, default_value_t = false)]
246 pub retry_failed: bool,
247
248 #[arg(long, default_value_t = false)]
250 pub keep_queue: bool,
251
252 #[arg(long)]
254 pub queue_db: Option<String>,
255
256 #[arg(long, default_value_t = 60)]
258 pub rate_limit_wait: u64,
259
260 #[arg(long)]
262 pub max_cost_usd: Option<f64>,
263
264 #[arg(
266 long,
267 default_value_t = 300,
268 help = "Timeout in seconds for each claude -p invocation (default: 300)"
269 )]
270 pub claude_timeout: u64,
271
272 #[arg(
274 long,
275 env = "SQLITE_GRAPHRAG_CODEX_BINARY",
276 help = "Explicit path to the Codex CLI binary (only with --mode codex)"
277 )]
278 pub codex_binary: Option<PathBuf>,
279
280 #[arg(
282 long,
283 help = "Model override for Codex extraction (e.g. o4-mini, gpt-5.1-codex)"
284 )]
285 pub codex_model: Option<String>,
286
287 #[arg(
289 long,
290 default_value_t = 300,
291 help = "Timeout in seconds for each codex exec invocation (default: 300)"
292 )]
293 pub codex_timeout: u64,
294
295 #[arg(long, value_name = "PATH", env = "SQLITE_GRAPHRAG_OPENCODE_BINARY")]
297 pub opencode_binary: Option<PathBuf>,
298
299 #[arg(
301 long,
302 value_name = "MODEL",
303 env = "SQLITE_GRAPHRAG_OPENCODE_MODEL",
304 help = "Model override for OpenCode extraction"
305 )]
306 pub opencode_model: Option<String>,
307
308 #[arg(
310 long,
311 value_name = "SECONDS",
312 env = "SQLITE_GRAPHRAG_OPENCODE_TIMEOUT",
313 default_value_t = 300,
314 help = "Timeout in seconds for each opencode run invocation (default: 300)"
315 )]
316 pub opencode_timeout: u64,
317
318 #[arg(long, value_name = "SECONDS")]
321 pub wait_job_singleton: Option<u64>,
322
323 #[arg(long, default_value_t = false)]
326 pub force_job_singleton: bool,
327
328 #[arg(
331 long,
332 default_value_t = false,
333 help = "Run enrich --operation memory-bindings after all files are ingested"
334 )]
335 pub enrich_after: bool,
336
337 #[arg(
342 long,
343 default_value_t = false,
344 help = "Update existing memories on name collision instead of skipping (idempotent re-ingest)"
345 )]
346 pub force_merge: bool,
347}
348
349#[derive(Clone, Debug, PartialEq, Eq, clap::ValueEnum)]
351pub enum IngestMode {
352 None,
354 ClaudeCode,
356 Codex,
358 #[value(name = "opencode")]
360 Opencode,
361}
362
363fn env_low_memory_enabled() -> bool {
368 match std::env::var("SQLITE_GRAPHRAG_LOW_MEMORY") {
369 Ok(v) if v.is_empty() => false,
370 Ok(v) => match v.to_lowercase().as_str() {
371 "1" | "true" | "yes" | "on" => true,
372 "0" | "false" | "no" | "off" => false,
373 other => {
374 tracing::warn!(
375 target: "ingest",
376 value = %other,
377 "SQLITE_GRAPHRAG_LOW_MEMORY value not recognized; treating as disabled"
378 );
379 false
380 }
381 },
382 Err(_) => false,
383 }
384}
385
386fn resolve_parallelism(low_memory_flag: bool, ingest_parallelism: Option<usize>) -> usize {
398 let env_flag = env_low_memory_enabled();
399 let low_memory = low_memory_flag || env_flag;
400
401 if low_memory {
402 if let Some(n) = ingest_parallelism {
403 if n > 1 {
404 tracing::warn!(
405 target: "ingest",
406 requested = n,
407 "--ingest-parallelism overridden by --low-memory; using 1"
408 );
409 }
410 }
411 if low_memory_flag {
412 tracing::info!(
413 target: "ingest",
414 source = "flag",
415 "low-memory mode enabled: forcing --ingest-parallelism 1"
416 );
417 } else {
418 tracing::info!(
419 target: "ingest",
420 source = "env",
421 "low-memory mode enabled via SQLITE_GRAPHRAG_LOW_MEMORY: forcing --ingest-parallelism 1"
422 );
423 }
424 return 1;
425 }
426
427 ingest_parallelism
428 .unwrap_or_else(|| {
429 std::thread::available_parallelism()
430 .map(|v| v.get() / 2)
431 .unwrap_or(1)
432 .clamp(1, 4)
433 })
434 .max(1)
435}
436
437#[derive(Serialize)]
438struct IngestFileEvent<'a> {
439 file: &'a str,
440 name: &'a str,
441 status: &'a str,
442 truncated: bool,
444 #[serde(skip_serializing_if = "Option::is_none")]
446 original_name: Option<String>,
447 #[serde(skip_serializing_if = "Option::is_none")]
449 original_filename: Option<&'a str>,
450 #[serde(skip_serializing_if = "Option::is_none")]
451 error: Option<String>,
452 #[serde(skip_serializing_if = "Option::is_none")]
453 memory_id: Option<i64>,
454 #[serde(skip_serializing_if = "Option::is_none")]
455 action: Option<String>,
456 body_length: usize,
458 #[serde(skip_serializing_if = "Option::is_none")]
463 backend_invoked: Option<&'a str>,
464}
465
466#[derive(Serialize)]
470struct IngestDryRunBudget<'a> {
471 budget: bool,
472 file: &'a str,
473 name: &'a str,
474 bytes: usize,
475 chunk_count: usize,
476 token_count: usize,
477 partition_count: usize,
478 exceeds_limits: bool,
479}
480
481#[derive(Serialize)]
482struct IngestSummary {
483 summary: bool,
484 dir: String,
485 pattern: String,
486 recursive: bool,
487 files_total: usize,
488 files_succeeded: usize,
489 files_failed: usize,
490 files_skipped: usize,
491 elapsed_ms: u64,
492}
493
494#[derive(Debug)]
496struct FileSuccess {
497 memory_id: i64,
498 action: String,
499 body_length: usize,
500 backend_invoked: Option<&'static str>,
501}
502
503#[derive(Serialize)]
506struct StageProgressEvent<'a> {
507 schema_version: u8,
508 event: &'a str,
509 path: &'a str,
510 ms: u64,
511 entities: usize,
512 relationships: usize,
513}
514
515struct StagedFile {
518 body: String,
519 body_hash: String,
520 snippet: String,
521 name: String,
522 description: String,
523 embedding: Option<Vec<f32>>,
524 chunk_embeddings: Option<Vec<Vec<f32>>>,
525 chunks_info: Vec<crate::chunking::Chunk>,
526 entities: Vec<NewEntity>,
527 relationships: Vec<NewRelationship>,
528 entity_embeddings: Option<Vec<Vec<f32>>>,
529 urls: Vec<crate::extraction::ExtractedUrl>,
530 backend_invoked: Option<&'static str>,
535}
536
537#[allow(clippy::too_many_arguments)]
543fn stage_file(
544 _idx: usize,
545 path: &Path,
546 name: &str,
547 paths: &AppPaths,
548 enable_ner: bool,
549 max_rss_mb: u64,
550 llm_parallelism: usize,
551 llm_backend: crate::cli::LlmBackendChoice,
552 embedding_backend: crate::cli::EmbeddingBackendChoice,
553 auto_describe: bool,
554) -> Result<Vec<StagedFile>, AppError> {
555 use crate::constants::*;
556
557 if name.len() > MAX_MEMORY_NAME_LEN {
558 return Err(AppError::LimitExceeded(
559 crate::i18n::validation::name_length(MAX_MEMORY_NAME_LEN),
560 ));
561 }
562 if name.starts_with("__") {
563 return Err(AppError::Validation(
564 crate::i18n::validation::reserved_name(),
565 ));
566 }
567 {
568 let slug_re = crate::constants::name_slug_regex();
569 if !slug_re.is_match(name) {
570 return Err(AppError::Validation(crate::i18n::validation::name_kebab(
571 name,
572 )));
573 }
574 }
575
576 let file_size = std::fs::metadata(path).map_err(AppError::Io)?.len();
577 if file_size > MAX_MEMORY_BODY_LEN as u64 {
578 return Err(AppError::BodyTooLarge {
579 bytes: file_size,
580 limit: MAX_MEMORY_BODY_LEN as u64,
581 });
582 }
583 let raw_body = std::fs::read_to_string(path).map_err(AppError::Io)?;
584 if raw_body.len() > MAX_MEMORY_BODY_LEN {
585 return Err(AppError::BodyTooLarge {
586 bytes: raw_body.len() as u64,
587 limit: MAX_MEMORY_BODY_LEN as u64,
588 });
589 }
590 if raw_body.trim().is_empty() {
591 return Err(AppError::Validation(crate::i18n::validation::empty_body()));
592 }
593
594 let description = if auto_describe {
595 crate::commands::ingest_heuristics::extract_heuristic_description(
596 &raw_body,
597 Some(&path.display().to_string()),
598 )
599 } else {
600 format!("ingested from {}", path.display())
601 };
602 if description.len() > MAX_MEMORY_DESCRIPTION_LEN {
603 return Err(AppError::Validation(
604 crate::i18n::validation::description_exceeds(MAX_MEMORY_DESCRIPTION_LEN),
605 ));
606 }
607
608 let partitions = chunking::split_body_by_sections(&raw_body);
616 let total_parts = partitions.len();
617 let mut staged = Vec::with_capacity(total_parts);
618 for (part_idx, part_body) in partitions.into_iter().enumerate() {
619 let part_name = if total_parts == 1 {
620 name.to_string()
621 } else {
622 format!("{name}-part-{}", part_idx + 1)
623 };
624 if part_name.len() > MAX_MEMORY_NAME_LEN {
625 return Err(AppError::LimitExceeded(
626 crate::i18n::validation::name_length(MAX_MEMORY_NAME_LEN),
627 ));
628 }
629 let part_description = if total_parts == 1 {
630 description.clone()
631 } else {
632 partition_description(&description, part_idx + 1, total_parts)
633 };
634 staged.push(stage_one_body(
635 part_body,
636 part_name,
637 part_description,
638 paths,
639 enable_ner,
640 max_rss_mb,
641 llm_parallelism,
642 llm_backend,
643 embedding_backend,
644 )?);
645 }
646 Ok(staged)
647}
648
649fn partition_description(base: &str, part: usize, total: usize) -> String {
653 let suffix = format!(" (part {part}/{total})");
654 let max = crate::constants::MAX_MEMORY_DESCRIPTION_LEN;
655 if base.len() + suffix.len() <= max {
656 return format!("{base}{suffix}");
657 }
658 let mut cut = max.saturating_sub(suffix.len()).min(base.len());
659 while cut > 0 && !base.is_char_boundary(cut) {
660 cut -= 1;
661 }
662 format!("{}{}", &base[..cut], suffix)
663}
664
665#[allow(clippy::too_many_arguments)]
669fn stage_one_body(
670 raw_body: String,
671 name: String,
672 description: String,
673 paths: &AppPaths,
674 enable_ner: bool,
675 max_rss_mb: u64,
676 llm_parallelism: usize,
677 llm_backend: crate::cli::LlmBackendChoice,
678 embedding_backend: crate::cli::EmbeddingBackendChoice,
679) -> Result<StagedFile, AppError> {
680 use crate::constants::*;
681
682 let mut extracted_entities: Vec<NewEntity> = Vec::with_capacity(30);
683 let mut extracted_relationships: Vec<NewRelationship> = Vec::with_capacity(50);
684 let mut extracted_urls: Vec<crate::extraction::ExtractedUrl> = Vec::with_capacity(4);
685 if enable_ner {
686 match crate::extraction::extract_graph_auto(&raw_body, paths) {
687 Ok(extracted) => {
688 extracted_urls = extracted.urls;
689 extracted_entities = extracted
694 .entities
695 .into_iter()
696 .map(|e| NewEntity {
697 name: e.name,
698 entity_type: crate::entity_type::EntityType::Concept,
699 description: None,
700 })
701 .collect();
702 extracted_relationships.clear();
707
708 if extracted_entities.len() > max_entities_per_memory() {
709 extracted_entities.truncate(max_entities_per_memory());
710 }
711 if extracted_relationships.len() > max_relationships_per_memory() {
712 extracted_relationships.truncate(max_relationships_per_memory());
713 }
714 }
715 Err(e) => {
716 tracing::warn!(
717 target: "ingest",
718 file = %name,
719 "auto-extraction failed (graceful degradation): {e:#}"
720 );
721 }
722 }
723 }
724
725 for rel in &mut extracted_relationships {
726 rel.relation = crate::parsers::normalize_relation(&rel.relation);
727 if let Err(e) = crate::parsers::validate_relation_format(&rel.relation) {
728 return Err(AppError::Validation(format!(
729 "{e} for relationship '{}' -> '{}'",
730 rel.source, rel.target
731 )));
732 }
733 crate::parsers::warn_if_non_canonical(&rel.relation);
734 if !(0.0..=1.0).contains(&rel.strength) {
735 return Err(AppError::Validation(format!(
736 "invalid strength {} for relationship '{}' -> '{}'; expected value in [0.0, 1.0]",
737 rel.strength, rel.source, rel.target
738 )));
739 }
740 }
741
742 let body_hash = blake3::hash(raw_body.as_bytes()).to_hex().to_string();
743 let snippet: String = raw_body.chars().take(200).collect();
744
745 let chunks_info = chunking::split_into_chunks_hierarchical(&raw_body);
746 if chunks_info.len() > REMEMBER_MAX_SAFE_MULTI_CHUNKS {
747 return Err(AppError::TooManyChunks {
748 chunks: chunks_info.len(),
749 limit: REMEMBER_MAX_SAFE_MULTI_CHUNKS,
750 });
751 }
752
753 let mut chunk_embeddings_opt: Option<Vec<Vec<f32>>> = None;
754 let skip_embed = crate::embedder::should_skip_embedding_on_failure();
755 let (embedding, backend_invoked): (Option<Vec<f32>>, Option<&'static str>) = if chunks_info
759 .len()
760 == 1
761 {
762 match crate::embedder::embed_passage_with_embedding_choice(
763 &paths.models,
764 &raw_body,
765 embedding_backend,
766 llm_backend,
767 ) {
768 Ok((v, k)) => (Some(v), Some(k.as_str())),
769 Err(
772 e @ (AppError::Validation(_)
773 | AppError::BodyTooLarge { .. }
774 | AppError::TooManyTokens { .. }),
775 ) => return Err(e),
776 Err(e) if skip_embed => {
777 tracing::warn!(error = %e, file = %name, "ingest: embedding failed; --skip-embedding-on-failure active, persisting without embedding");
778 (None, None)
779 }
780 Err(e) => return Err(e),
781 }
782 } else {
783 let chunk_texts: Vec<String> = chunks_info
786 .iter()
787 .map(|c| chunking::chunk_text(&raw_body, c).to_string())
788 .collect();
789 if let Some(rss) = crate::memory_guard::current_process_memory_mb() {
790 if rss > max_rss_mb {
791 tracing::error!(
792 target: "ingest",
793 rss_mb = rss,
794 max_rss_mb = max_rss_mb,
795 file = %name,
796 "RSS exceeded --max-rss-mb threshold; aborting to prevent system instability"
797 );
798 return Err(AppError::LowMemory {
799 available_mb: crate::memory_guard::available_memory_mb(),
800 required_mb: max_rss_mb,
801 });
802 }
803 }
804 match crate::embedder::embed_passages_parallel_with_embedding_choice(
805 &paths.models,
806 &chunk_texts,
807 llm_parallelism,
808 crate::embedder::chunk_embed_batch_size(),
809 embedding_backend,
810 llm_backend,
811 ) {
812 Ok(chunk_embeddings) => {
813 let aggregated = chunking::aggregate_embeddings(&chunk_embeddings);
814 chunk_embeddings_opt = Some(chunk_embeddings);
815 (Some(aggregated), None)
818 }
819 Err(
820 e @ (AppError::Validation(_)
821 | AppError::BodyTooLarge { .. }
822 | AppError::TooManyTokens { .. }),
823 ) => return Err(e),
824 Err(e) if skip_embed => {
825 tracing::warn!(error = %e, file = %name, "ingest: chunk embedding failed; --skip-embedding-on-failure active, persisting without embedding");
826 (None, None)
827 }
828 Err(e) => return Err(e),
829 }
830 };
831
832 let entity_texts: Vec<String> = extracted_entities
834 .iter()
835 .map(|entity| match &entity.description {
836 Some(desc) => format!("{} {}", entity.name, desc),
837 None => entity.name.clone(),
838 })
839 .collect();
840 let entity_embeddings_opt = match crate::embedder::embed_entity_texts_cached(
844 &paths.models,
845 &entity_texts,
846 llm_parallelism,
847 embedding_backend,
848 llm_backend,
849 ) {
850 Ok((entity_embeddings, embed_cache_stats)) => {
851 if embed_cache_stats.hits > 0 {
852 tracing::debug!(
853 hits = embed_cache_stats.hits,
854 misses = embed_cache_stats.misses,
855 requested = embed_cache_stats.requested,
856 "G56: entity embed cache hit (ingest)"
857 );
858 }
859 Some(entity_embeddings)
860 }
861 Err(e) if skip_embed => {
862 tracing::warn!(error = %e, file = %name, "ingest: entity embedding failed; --skip-embedding-on-failure active");
863 None
864 }
865 Err(e) => return Err(e),
866 };
867
868 Ok(StagedFile {
869 body: raw_body,
870 body_hash,
871 snippet,
872 name,
873 description,
874 embedding,
875 chunk_embeddings: chunk_embeddings_opt,
876 chunks_info,
877 entities: extracted_entities,
878 relationships: extracted_relationships,
879 entity_embeddings: entity_embeddings_opt,
880 urls: extracted_urls,
881 backend_invoked,
882 })
883}
884
885fn link_staged_graph(
889 tx: &Connection,
890 namespace: &str,
891 memory_id: i64,
892 staged: &StagedFile,
893) -> Result<(), AppError> {
894 if staged.entities.is_empty() && staged.relationships.is_empty() {
895 return Ok(());
896 }
897 for (idx, entity) in staged.entities.iter().enumerate() {
898 let entity_id = entities::upsert_entity(tx, namespace, entity)?;
899 if let Some(ref entity_embeddings) = staged.entity_embeddings {
900 if let Some(entity_embedding) = entity_embeddings.get(idx) {
901 entities::upsert_entity_vec(
902 tx,
903 entity_id,
904 namespace,
905 entity.entity_type,
906 entity_embedding,
907 &entity.name,
908 )?;
909 }
910 }
911 entities::link_memory_entity(tx, memory_id, entity_id)?;
912 }
913 let entity_types: std::collections::HashMap<&str, EntityType> = staged
914 .entities
915 .iter()
916 .map(|entity| (entity.name.as_str(), entity.entity_type))
917 .collect();
918
919 let mut affected_entity_ids: std::collections::HashSet<i64> = std::collections::HashSet::new();
920 for entity in &staged.entities {
921 if let Some(eid) = entities::find_entity_id(tx, namespace, &entity.name)? {
922 affected_entity_ids.insert(eid);
923 }
924 }
925
926 for rel in &staged.relationships {
927 let source_entity = NewEntity {
928 name: rel.source.clone(),
929 entity_type: entity_types
930 .get(rel.source.as_str())
931 .copied()
932 .unwrap_or(EntityType::Concept),
933 description: None,
934 };
935 let target_entity = NewEntity {
936 name: rel.target.clone(),
937 entity_type: entity_types
938 .get(rel.target.as_str())
939 .copied()
940 .unwrap_or(EntityType::Concept),
941 description: None,
942 };
943 let source_id = entities::upsert_entity(tx, namespace, &source_entity)?;
944 let target_id = entities::upsert_entity(tx, namespace, &target_entity)?;
945 let rel_id = entities::upsert_relationship(tx, namespace, source_id, target_id, rel)?;
946 entities::link_memory_relationship(tx, memory_id, rel_id)?;
947 affected_entity_ids.insert(source_id);
948 affected_entity_ids.insert(target_id);
949 }
950
951 for &eid in &affected_entity_ids {
952 entities::recalculate_degree(tx, eid)?;
953 }
954 Ok(())
955}
956
957fn persist_staged(
965 conn: &mut Connection,
966 namespace: &str,
967 memory_type: &str,
968 staged: StagedFile,
969 force_merge: bool,
970) -> Result<FileSuccess, AppError> {
971 {
972 let active_count: u32 = conn.query_row(
973 "SELECT COUNT(DISTINCT namespace) FROM memories WHERE deleted_at IS NULL",
974 [],
975 |r| r.get::<_, i64>(0).map(|v| v as u32),
976 )?;
977 let ns_exists: bool = conn.query_row(
978 "SELECT EXISTS(SELECT 1 FROM memories WHERE namespace = ?1 AND deleted_at IS NULL)",
979 rusqlite::params![namespace],
980 |r| r.get::<_, i64>(0).map(|v| v > 0),
981 )?;
982 if !ns_exists && active_count >= crate::constants::MAX_NAMESPACES_ACTIVE {
983 return Err(AppError::NamespaceError(format!(
984 "active namespace limit of {} exceeded while creating '{namespace}'",
985 crate::constants::MAX_NAMESPACES_ACTIVE
986 )));
987 }
988 }
989
990 let existing_memory = memories::find_by_name(conn, namespace, &staged.name)?;
991 let duplicate_hash_id = memories::find_by_hash(conn, namespace, &staged.body_hash)?;
992
993 let new_memory = NewMemory {
994 namespace: namespace.to_string(),
995 name: staged.name.clone(),
996 memory_type: memory_type.to_string(),
997 description: staged.description.clone(),
998 body: staged.body.clone(),
999 body_hash: staged.body_hash.clone(),
1000 session_id: None,
1001 source: "agent".to_string(),
1002 metadata: serde_json::json!({}),
1003 };
1004 let body_length = new_memory.body.len();
1005 let metadata_json = serde_json::to_string(&new_memory.metadata)?;
1006
1007 match existing_memory {
1008 Some((existing_id, _updated_at, _version)) => {
1009 if !force_merge {
1010 return Err(AppError::Duplicate(errors_msg::duplicate_memory(
1011 &staged.name,
1012 namespace,
1013 )));
1014 }
1015
1016 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1019
1020 let (old_name, old_desc, old_body): (String, String, String) = tx.query_row(
1021 "SELECT name, description, body FROM memories WHERE id = ?1",
1022 rusqlite::params![existing_id],
1023 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
1024 )?;
1025
1026 let next_v = versions::next_version(&tx, existing_id)?;
1027 memories::update(&tx, existing_id, &new_memory, None)?;
1028 memories::sync_fts_after_update(
1029 &tx,
1030 existing_id,
1031 &old_name,
1032 &old_desc,
1033 &old_body,
1034 &staged.name,
1035 &staged.description,
1036 &new_memory.body,
1037 )?;
1038 versions::insert_version(
1039 &tx,
1040 existing_id,
1041 next_v,
1042 &staged.name,
1043 memory_type,
1044 &staged.description,
1045 &new_memory.body,
1046 &metadata_json,
1047 None,
1048 "edit",
1049 )?;
1050
1051 storage_chunks::delete_chunks(&tx, existing_id)?;
1053 if let Some(ref emb) = staged.embedding {
1054 memories::upsert_vec(
1055 &tx,
1056 existing_id,
1057 namespace,
1058 memory_type,
1059 emb,
1060 &staged.name,
1061 &staged.snippet,
1062 )?;
1063 }
1064 if staged.chunks_info.len() > 1 {
1065 storage_chunks::insert_chunk_slices(
1066 &tx,
1067 existing_id,
1068 &new_memory.body,
1069 &staged.chunks_info,
1070 )?;
1071 if let Some(ref chunk_embeddings) = staged.chunk_embeddings {
1072 for (i, emb) in chunk_embeddings.iter().enumerate() {
1073 storage_chunks::upsert_chunk_vec(
1074 &tx,
1075 i as i64,
1076 existing_id,
1077 i as i32,
1078 emb,
1079 )?;
1080 }
1081 }
1082 }
1083
1084 link_staged_graph(&tx, namespace, existing_id, &staged)?;
1085 tx.commit()?;
1086
1087 Ok(FileSuccess {
1088 memory_id: existing_id,
1089 action: "updated".to_string(),
1090 body_length,
1091 backend_invoked: staged.backend_invoked,
1092 })
1093 }
1094 None => {
1095 if let Some(hash_id) = duplicate_hash_id {
1098 return Err(AppError::Duplicate(format!(
1099 "identical body already stored as memory id {hash_id} (dedup by body_hash); skipping '{}'",
1100 staged.name
1101 )));
1102 }
1103
1104 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1105 let memory_id = memories::insert(&tx, &new_memory)?;
1106 versions::insert_version(
1107 &tx,
1108 memory_id,
1109 1,
1110 &staged.name,
1111 memory_type,
1112 &staged.description,
1113 &new_memory.body,
1114 &metadata_json,
1115 None,
1116 "create",
1117 )?;
1118 if let Some(ref emb) = staged.embedding {
1119 memories::upsert_vec(
1120 &tx,
1121 memory_id,
1122 namespace,
1123 memory_type,
1124 emb,
1125 &staged.name,
1126 &staged.snippet,
1127 )?;
1128 }
1129 if staged.chunks_info.len() > 1 {
1130 storage_chunks::insert_chunk_slices(
1131 &tx,
1132 memory_id,
1133 &new_memory.body,
1134 &staged.chunks_info,
1135 )?;
1136 if let Some(ref chunk_embeddings) = staged.chunk_embeddings {
1137 for (i, emb) in chunk_embeddings.iter().enumerate() {
1138 storage_chunks::upsert_chunk_vec(&tx, i as i64, memory_id, i as i32, emb)?;
1139 }
1140 }
1141 }
1142 link_staged_graph(&tx, namespace, memory_id, &staged)?;
1143 tx.commit()?;
1144
1145 if !staged.urls.is_empty() {
1146 let url_entries: Vec<storage_urls::MemoryUrl> = staged
1147 .urls
1148 .into_iter()
1149 .map(|u| storage_urls::MemoryUrl {
1150 url: u.url,
1151 offset: Some(u.start as i64),
1152 })
1153 .collect();
1154 let _ = storage_urls::insert_urls(conn, memory_id, &url_entries);
1155 }
1156
1157 Ok(FileSuccess {
1158 memory_id,
1159 action: "created".to_string(),
1160 body_length,
1161 backend_invoked: staged.backend_invoked,
1162 })
1163 }
1164 }
1165}
1166
1167fn is_at_default<T: PartialEq>(value: T, default: T) -> bool {
1175 value == default
1176}
1177
1178fn validate_mode_conditional_flags_ingest(args: &IngestArgs) -> Result<(), AppError> {
1192 const DEFAULT_TIMEOUT: u64 = 300;
1193 const DEFAULT_RATE_LIMIT_WAIT: u64 = 60;
1194
1195 let mut conflicts: Vec<String> = Vec::new();
1196
1197 let is_local_mode = args.mode == IngestMode::None;
1198
1199 if args.name_prefix.is_some() && !is_local_mode {
1202 return Err(AppError::Validation(
1203 "--name-prefix is not supported with --mode claude-code/codex/opencode; \
1204 use --mode none (default)"
1205 .to_string(),
1206 ));
1207 }
1208
1209 if is_local_mode {
1210 if args.claude_binary.is_some() {
1211 conflicts.push("--claude-binary is ignored when --mode is none".to_string());
1212 }
1213 if args.claude_model.is_some() {
1214 conflicts.push("--claude-model is ignored when --mode is none".to_string());
1215 }
1216 if !is_at_default(args.claude_timeout, DEFAULT_TIMEOUT) {
1217 conflicts.push(format!(
1218 "--claude-timeout={} is ignored when --mode is none (remove the flag to use the default 300s)",
1219 args.claude_timeout
1220 ));
1221 }
1222 if args.codex_binary.is_some() {
1223 conflicts.push("--codex-binary is ignored when --mode is none".to_string());
1224 }
1225 if args.codex_model.is_some() {
1226 conflicts.push("--codex-model is ignored when --mode is none".to_string());
1227 }
1228 if !is_at_default(args.codex_timeout, DEFAULT_TIMEOUT) {
1229 conflicts.push(format!(
1230 "--codex-timeout={} is ignored when --mode is none (remove the flag to use the default 300s)",
1231 args.codex_timeout
1232 ));
1233 }
1234 if args.opencode_binary.is_some() {
1235 conflicts.push("--opencode-binary is ignored when --mode is none".to_string());
1236 }
1237 if args.opencode_model.is_some() {
1238 conflicts.push("--opencode-model is ignored when --mode is none".to_string());
1239 }
1240 if !is_at_default(args.opencode_timeout, DEFAULT_TIMEOUT) {
1241 conflicts.push(format!(
1242 "--opencode-timeout={} is ignored when --mode is none (remove the flag to use the default 300s)",
1243 args.opencode_timeout
1244 ));
1245 }
1246 if args.max_cost_usd.is_some() {
1247 conflicts.push("--max-cost-usd is ignored when --mode is none (cost is only tracked for LLM-backed modes)".to_string());
1248 }
1249 if args.resume {
1250 conflicts.push("--resume is ignored when --mode is none (the queue DB is only used by LLM-backed modes)".to_string());
1251 }
1252 if args.retry_failed {
1253 conflicts.push("--retry-failed is ignored when --mode is none".to_string());
1254 }
1255 if args.keep_queue {
1256 conflicts.push("--keep-queue is ignored when --mode is none".to_string());
1257 }
1258 if !is_at_default(args.rate_limit_wait, DEFAULT_RATE_LIMIT_WAIT) {
1259 conflicts.push(format!(
1260 "--rate-limit-wait={} is ignored when --mode is none",
1261 args.rate_limit_wait
1262 ));
1263 }
1264 }
1265
1266 match args.mode {
1267 IngestMode::ClaudeCode => {
1268 if args.codex_binary.is_some() {
1269 conflicts.push("--codex-binary is ignored when --mode=claude-code".to_string());
1270 }
1271 if args.codex_model.is_some() {
1272 conflicts.push("--codex-model is ignored when --mode=claude-code".to_string());
1273 }
1274 if !is_at_default(args.codex_timeout, DEFAULT_TIMEOUT) {
1275 conflicts.push(format!(
1276 "--codex-timeout={} is ignored when --mode=claude-code (remove the flag to use the default 300s)",
1277 args.codex_timeout
1278 ));
1279 }
1280 if args.opencode_binary.is_some() {
1281 conflicts.push("--opencode-binary is ignored when --mode=claude-code".to_string());
1282 }
1283 if args.opencode_model.is_some() {
1284 conflicts.push("--opencode-model is ignored when --mode=claude-code".to_string());
1285 }
1286 if !is_at_default(args.opencode_timeout, DEFAULT_TIMEOUT) {
1287 conflicts.push(format!(
1288 "--opencode-timeout={} is ignored when --mode=claude-code (remove the flag to use the default 300s)",
1289 args.opencode_timeout
1290 ));
1291 }
1292 }
1293 IngestMode::Codex => {
1294 if args.claude_binary.is_some() {
1295 conflicts.push("--claude-binary is ignored when --mode=codex".to_string());
1296 }
1297 if args.claude_model.is_some() {
1298 conflicts.push("--claude-model is ignored when --mode=codex".to_string());
1299 }
1300 if !is_at_default(args.claude_timeout, DEFAULT_TIMEOUT) {
1301 conflicts.push(format!(
1302 "--claude-timeout={} is ignored when --mode=codex (remove the flag to use the default 300s)",
1303 args.claude_timeout
1304 ));
1305 }
1306 if args.max_cost_usd.is_some() {
1307 conflicts.push(
1308 "--max-cost-usd is ignored when --mode=codex (OAuth-first; cost is metered by your subscription)"
1309 .to_string(),
1310 );
1311 }
1312 if args.resume {
1313 conflicts.push("--resume is only valid for --mode=claude-code".to_string());
1314 }
1315 if args.retry_failed {
1316 conflicts.push("--retry-failed is only valid for --mode=claude-code".to_string());
1317 }
1318 if args.keep_queue {
1319 conflicts.push("--keep-queue is only valid for --mode=claude-code".to_string());
1320 }
1321 if args.opencode_binary.is_some() {
1322 conflicts.push("--opencode-binary is ignored when --mode=codex".to_string());
1323 }
1324 if args.opencode_model.is_some() {
1325 conflicts.push("--opencode-model is ignored when --mode=codex".to_string());
1326 }
1327 if !is_at_default(args.opencode_timeout, DEFAULT_TIMEOUT) {
1328 conflicts.push(format!(
1329 "--opencode-timeout={} is ignored when --mode=codex (remove the flag to use the default 300s)",
1330 args.opencode_timeout
1331 ));
1332 }
1333 }
1334 IngestMode::Opencode => {
1335 if args.claude_binary.is_some() {
1336 conflicts.push("--claude-binary is ignored when --mode=opencode".to_string());
1337 }
1338 if args.claude_model.is_some() {
1339 conflicts.push("--claude-model is ignored when --mode=opencode".to_string());
1340 }
1341 if !is_at_default(args.claude_timeout, DEFAULT_TIMEOUT) {
1342 conflicts.push(format!(
1343 "--claude-timeout={} is ignored when --mode=opencode (remove the flag to use the default 300s)",
1344 args.claude_timeout
1345 ));
1346 }
1347 if args.codex_binary.is_some() {
1348 conflicts.push("--codex-binary is ignored when --mode=opencode".to_string());
1349 }
1350 if args.codex_model.is_some() {
1351 conflicts.push("--codex-model is ignored when --mode=opencode".to_string());
1352 }
1353 if !is_at_default(args.codex_timeout, DEFAULT_TIMEOUT) {
1354 conflicts.push(format!(
1355 "--codex-timeout={} is ignored when --mode=opencode (remove the flag to use the default 300s)",
1356 args.codex_timeout
1357 ));
1358 }
1359 if args.max_cost_usd.is_some() {
1360 conflicts.push(
1361 "--max-cost-usd is ignored when --mode=opencode (OAuth-first; cost is metered by your subscription)"
1362 .to_string(),
1363 );
1364 }
1365 if args.resume {
1366 conflicts.push("--resume is only valid for --mode=claude-code".to_string());
1367 }
1368 if args.retry_failed {
1369 conflicts.push("--retry-failed is only valid for --mode=claude-code".to_string());
1370 }
1371 if args.keep_queue {
1372 conflicts.push("--keep-queue is only valid for --mode=claude-code".to_string());
1373 }
1374 }
1375 IngestMode::None => {}
1376 }
1377
1378 if !conflicts.is_empty() {
1379 return Err(AppError::Validation(format!(
1380 "G20: mode-conditional flag conflicts detected for --mode={:?}:\n - {}",
1381 args.mode,
1382 conflicts.join("\n - ")
1383 )));
1384 }
1385
1386 Ok(())
1387}
1388
1389#[tracing::instrument(skip_all, level = "debug", name = "ingest")]
1392pub fn run(
1393 args: IngestArgs,
1394 llm_backend: crate::cli::LlmBackendChoice,
1395 embedding_backend: crate::cli::EmbeddingBackendChoice,
1396) -> Result<(), AppError> {
1397 validate_mode_conditional_flags_ingest(&args)?;
1400 tracing::debug!(target: "ingest", dir = %args.dir.display(), mode = ?args.mode, "starting ingest");
1401 if args.mode == IngestMode::ClaudeCode {
1402 return super::ingest_claude::run_claude_ingest(&args, embedding_backend, llm_backend);
1403 }
1404 if args.mode == IngestMode::Codex {
1405 return super::ingest_codex::run_codex_ingest(&args);
1406 }
1407 if args.mode == IngestMode::Opencode {
1408 return super::ingest_opencode::run_opencode_ingest(&args);
1409 }
1410
1411 let started = std::time::Instant::now();
1412
1413 if !args.dir.exists() {
1414 return Err(AppError::Validation(format!(
1415 "directory not found: {}",
1416 args.dir.display()
1417 )));
1418 }
1419 if !args.dir.is_dir() {
1420 return Err(AppError::Validation(format!(
1421 "path is not a directory: {}",
1422 args.dir.display()
1423 )));
1424 }
1425
1426 let mut files: Vec<PathBuf> = Vec::with_capacity(128);
1427 collect_files(&args.dir, &args.pattern, args.recursive, &mut files)?;
1428 files.sort_unstable();
1429
1430 if files.len() > args.max_files {
1431 return Err(AppError::Validation(format!(
1432 "found {} files matching pattern, exceeds --max-files cap of {} (raise the cap or narrow the pattern)",
1433 files.len(),
1434 args.max_files
1435 )));
1436 }
1437
1438 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
1439 let memory_type_str = args.r#type.as_str().to_string();
1440
1441 let paths = AppPaths::resolve(args.db.as_deref())?;
1442 let mut conn_or_err = match init_storage(&paths) {
1443 Ok(c) => Ok(c),
1444 Err(e) => Err(format!("{e}")),
1445 };
1446
1447 let mut succeeded: usize = 0;
1448 let mut failed: usize = 0;
1449 let mut skipped: usize = 0;
1450 let total = files.len();
1451
1452 let mut taken_names: BTreeSet<String> = BTreeSet::new();
1455
1456 enum SlotMeta {
1462 Skip {
1463 file_str: String,
1464 derived_base: String,
1465 name_truncated: bool,
1466 original_name: Option<String>,
1467 original_filename: Option<String>,
1468 reason: String,
1469 },
1470 Process {
1471 file_str: String,
1472 derived_name: String,
1473 name_truncated: bool,
1474 original_name: Option<String>,
1475 original_filename: Option<String>,
1476 },
1477 }
1478
1479 struct ProcessItem {
1480 idx: usize,
1481 path: PathBuf,
1482 file_str: String,
1483 derived_name: String,
1484 }
1485
1486 let files_cap = files.len();
1487 let mut slots_meta: Vec<SlotMeta> = Vec::new();
1488 slots_meta.try_reserve(files_cap).map_err(|_| {
1489 AppError::LimitExceeded(format!(
1490 "allocation of {files_cap} slot metadata entries would exceed available memory"
1491 ))
1492 })?;
1493 let mut process_items: Vec<ProcessItem> = Vec::new();
1494 process_items.try_reserve(files_cap).map_err(|_| {
1495 AppError::LimitExceeded(format!(
1496 "allocation of {files_cap} process items would exceed available memory"
1497 ))
1498 })?;
1499 let mut truncations: Vec<(String, String)> = Vec::new();
1500 truncations.try_reserve(files_cap).map_err(|_| {
1501 AppError::LimitExceeded(format!(
1502 "allocation of {files_cap} truncation entries would exceed available memory"
1503 ))
1504 })?;
1505
1506 let max_name_length = match args.name_prefix.as_deref() {
1509 Some(prefix) => validate_name_prefix(prefix, args.max_name_length)?,
1510 None => args.max_name_length,
1511 };
1512 for path in &files {
1513 let file_str = path.to_string_lossy().into_owned();
1514 let (derived_base, name_truncated, original_name) =
1515 derive_kebab_name(path, max_name_length);
1516 let original_basename = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
1517
1518 if name_truncated {
1519 if let Some(ref orig) = original_name {
1520 truncations.push((orig.clone(), derived_base.clone()));
1521 }
1522 }
1523
1524 if derived_base.is_empty() {
1525 let orig_filename = if !original_basename.is_empty() {
1527 Some(original_basename.to_string())
1528 } else {
1529 None
1530 };
1531 slots_meta.push(SlotMeta::Skip {
1532 file_str,
1533 derived_base: String::new(),
1534 name_truncated: false,
1535 original_name: None,
1536 original_filename: orig_filename,
1537 reason: "could not derive a non-empty kebab-case name from filename".to_string(),
1538 });
1539 continue;
1540 }
1541
1542 let derived_base = match args.name_prefix.as_deref() {
1546 Some(prefix) => format!("{prefix}{derived_base}"),
1547 None => derived_base,
1548 };
1549
1550 match unique_name(&derived_base, &taken_names) {
1551 Ok(derived_name) => {
1552 taken_names.insert(derived_name.clone());
1553 let idx = slots_meta.len();
1554 let orig_filename = if original_basename != derived_name {
1556 Some(original_basename.to_string())
1557 } else {
1558 None
1559 };
1560 process_items.push(ProcessItem {
1561 idx,
1562 path: path.clone(),
1563 file_str: file_str.clone(),
1564 derived_name: derived_name.clone(),
1565 });
1566 slots_meta.push(SlotMeta::Process {
1567 file_str,
1568 derived_name,
1569 name_truncated,
1570 original_name,
1571 original_filename: orig_filename,
1572 });
1573 }
1574 Err(e) => {
1575 let orig_filename = if original_basename != derived_base {
1576 Some(original_basename.to_string())
1577 } else {
1578 None
1579 };
1580 slots_meta.push(SlotMeta::Skip {
1581 file_str,
1582 derived_base,
1583 name_truncated,
1584 original_name,
1585 original_filename: orig_filename,
1586 reason: e.to_string(),
1587 });
1588 }
1589 }
1590 }
1591
1592 if !truncations.is_empty() {
1593 tracing::info!(
1594 target: "ingest",
1595 count = truncations.len(),
1596 max_name_length = max_name_length,
1597 max_len = DERIVED_NAME_MAX_LEN,
1598 "derived names truncated; pass -vv (debug) for per-file detail"
1599 );
1600 }
1601
1602 if args.dry_run {
1604 for meta in &slots_meta {
1605 match meta {
1606 SlotMeta::Skip {
1607 file_str,
1608 derived_base,
1609 name_truncated,
1610 original_name,
1611 original_filename,
1612 reason,
1613 } => {
1614 output::emit_json_compact(&IngestFileEvent {
1615 file: file_str,
1616 name: derived_base,
1617 status: "skip",
1618 truncated: *name_truncated,
1619 original_name: original_name.clone(),
1620 original_filename: original_filename.as_deref(),
1621 error: Some(reason.clone()),
1622 memory_id: None,
1623 action: None,
1624 body_length: 0,
1625 backend_invoked: None,
1626 })?;
1627 }
1628 SlotMeta::Process {
1629 file_str,
1630 derived_name,
1631 name_truncated,
1632 original_name,
1633 original_filename,
1634 } => {
1635 output::emit_json_compact(&IngestFileEvent {
1636 file: file_str,
1637 name: derived_name,
1638 status: "preview",
1639 truncated: *name_truncated,
1640 original_name: original_name.clone(),
1641 original_filename: original_filename.as_deref(),
1642 error: None,
1643 memory_id: None,
1644 action: None,
1645 body_length: 0,
1646 backend_invoked: None,
1647 })?;
1648
1649 match std::fs::read_to_string(file_str) {
1653 Ok(body) => {
1654 let budget = chunking::assess_body_budget(&body);
1655 output::emit_json_compact(&IngestDryRunBudget {
1656 budget: true,
1657 file: file_str,
1658 name: derived_name,
1659 bytes: budget.bytes,
1660 chunk_count: budget.chunk_count,
1661 token_count: budget.approx_tokens,
1662 partition_count: budget.partition_count,
1663 exceeds_limits: budget.exceeds_limits,
1664 })?;
1665 }
1666 Err(e) => {
1667 tracing::warn!(
1668 target: "ingest",
1669 file = %file_str,
1670 "dry-run: could not read file for budget assessment: {e}"
1671 );
1672 }
1673 }
1674 }
1675 }
1676 }
1677 output::emit_json_compact(&IngestSummary {
1678 summary: true,
1679 dir: args.dir.to_string_lossy().into_owned(),
1680 pattern: args.pattern.clone(),
1681 recursive: args.recursive,
1682 files_total: total,
1683 files_succeeded: 0,
1684 files_failed: 0,
1685 files_skipped: 0,
1686 elapsed_ms: started.elapsed().as_millis() as u64,
1687 })?;
1688 return Ok(());
1689 }
1690
1691 if args.low_memory {
1693 if let Some(n) = args.ingest_parallelism {
1694 if n > 1 {
1695 return Err(AppError::Validation(
1696 "--ingest-parallelism N>1 conflicts with --low-memory; use one or the other"
1697 .to_string(),
1698 ));
1699 }
1700 }
1701 }
1702
1703 let parallelism = resolve_parallelism(args.low_memory, args.ingest_parallelism);
1706
1707 let pool = rayon::ThreadPoolBuilder::new()
1708 .num_threads(parallelism)
1709 .build()
1710 .map_err(|e| AppError::Internal(anyhow::anyhow!("rayon pool: {e}")))?;
1711
1712 if args.enable_ner && args.skip_extraction {
1713 return Err(AppError::Validation(
1714 "--enable-ner and --skip-extraction are mutually exclusive; remove one".to_string(),
1715 ));
1716 }
1717 if args.skip_extraction && !args.enable_ner {
1718 tracing::warn!(
1725 "--skip-extraction is deprecated since v1.0.45 and has no effect (NER is disabled by default); remove this flag to silence the warning"
1726 );
1727 }
1728 let enable_ner = args.enable_ner;
1729 let auto_describe = args.auto_describe && !args.no_auto_describe;
1730 let max_rss_mb = args.max_rss_mb;
1731 let llm_parallelism = args.llm_parallelism as usize;
1732
1733 let total_to_process = process_items.len();
1734 tracing::info!(
1735 target: "ingest",
1736 phase = "pipeline_start",
1737 files = total_to_process,
1738 ingest_parallelism = parallelism,
1739 "incremental pipeline starting: Phase A (rayon) → channel → Phase B (main thread)",
1740 );
1741
1742 let channel_bound = (parallelism * 2).max(1);
1746 let (tx, rx) = mpsc::sync_channel::<(usize, Result<Vec<StagedFile>, AppError>)>(channel_bound);
1747
1748 let paths_owned = paths.clone();
1753 let llm_backend_owned = llm_backend;
1754 let embedding_backend_owned = embedding_backend;
1755 let producer_handle = std::thread::spawn(move || {
1756 pool.install(|| {
1757 process_items.into_par_iter().for_each(|item| {
1758 if crate::shutdown_requested() {
1759 return;
1760 }
1761 let t0 = std::time::Instant::now();
1762 let result = stage_file(
1763 item.idx,
1764 &item.path,
1765 &item.derived_name,
1766 &paths_owned,
1767 enable_ner,
1768 max_rss_mb,
1769 llm_parallelism,
1770 llm_backend_owned,
1771 embedding_backend_owned,
1772 auto_describe,
1773 );
1774 let elapsed_ms = t0.elapsed().as_millis() as u64;
1775
1776 let (n_entities, n_relationships) = match &result {
1779 Ok(parts) => (
1780 parts.iter().map(|sf| sf.entities.len()).sum::<usize>(),
1781 parts.iter().map(|sf| sf.relationships.len()).sum::<usize>(),
1782 ),
1783 Err(_) => (0, 0),
1784 };
1785 let progress = StageProgressEvent {
1786 schema_version: 1,
1787 event: "file_extracted",
1788 path: &item.file_str,
1789 ms: elapsed_ms,
1790 entities: n_entities,
1791 relationships: n_relationships,
1792 };
1793 if let Ok(line) = serde_json::to_string(&progress) {
1794 tracing::info!(target: "ingest_progress", "{}", line);
1795 }
1796
1797 let _ = tx.send((item.idx, result));
1801 });
1802 drop(tx);
1804 });
1805 });
1806
1807 let fail_fast = args.fail_fast;
1819
1820 for meta in &slots_meta {
1822 if let SlotMeta::Skip {
1823 file_str,
1824 derived_base,
1825 name_truncated,
1826 original_name,
1827 original_filename,
1828 reason,
1829 } = meta
1830 {
1831 output::emit_json_compact(&IngestFileEvent {
1832 file: file_str,
1833 name: derived_base,
1834 status: "skipped",
1835 truncated: *name_truncated,
1836 original_name: original_name.clone(),
1837 original_filename: original_filename.as_deref(),
1838 error: Some(reason.clone()),
1839 memory_id: None,
1840 action: None,
1841 body_length: 0,
1842 backend_invoked: None,
1843 })?;
1844 skipped += 1;
1845 }
1846 }
1847
1848 let meta_index: std::collections::HashMap<usize, &SlotMeta> = slots_meta
1851 .iter()
1852 .enumerate()
1853 .filter(|(_, m)| matches!(m, SlotMeta::Process { .. }))
1854 .collect();
1855
1856 tracing::info!(
1857 target: "ingest",
1858 phase = "persist_start",
1859 files = total_to_process,
1860 "phase B starting: persisting files incrementally as Phase A completes each one",
1861 );
1862
1863 for (idx, stage_result) in rx {
1867 if crate::shutdown_requested() {
1868 tracing::info!(target: "ingest", "shutdown requested, stopping persistence loop");
1869 break;
1870 }
1871 let meta = meta_index.get(&idx).ok_or_else(|| {
1872 AppError::Internal(anyhow::anyhow!(
1873 "channel idx {idx} has no corresponding Process slot"
1874 ))
1875 })?;
1876 let (file_str, derived_name, name_truncated, original_name, original_filename) = match meta
1877 {
1878 SlotMeta::Process {
1879 file_str,
1880 derived_name,
1881 name_truncated,
1882 original_name,
1883 original_filename,
1884 } => (
1885 file_str,
1886 derived_name,
1887 name_truncated,
1888 original_name,
1889 original_filename,
1890 ),
1891 SlotMeta::Skip { .. } => unreachable!("channel only carries Process results"),
1892 };
1893
1894 let conn = match conn_or_err.as_mut() {
1896 Ok(c) => c,
1897 Err(err_msg) => {
1898 let err_clone = err_msg.clone();
1899 output::emit_json_compact(&IngestFileEvent {
1900 file: file_str,
1901 name: derived_name,
1902 status: "failed",
1903 truncated: *name_truncated,
1904 original_name: original_name.clone(),
1905 original_filename: original_filename.as_deref(),
1906 error: Some(err_clone.clone()),
1907 memory_id: None,
1908 action: None,
1909 body_length: 0,
1910 backend_invoked: None,
1911 })?;
1912 failed += 1;
1913 if fail_fast {
1914 output::emit_json_compact(&IngestSummary {
1915 summary: true,
1916 dir: args.dir.display().to_string(),
1917 pattern: args.pattern.clone(),
1918 recursive: args.recursive,
1919 files_total: total,
1920 files_succeeded: succeeded,
1921 files_failed: failed,
1922 files_skipped: skipped,
1923 elapsed_ms: started.elapsed().as_millis() as u64,
1924 })?;
1925 return Err(AppError::Validation(format!(
1926 "ingest aborted on first failure: {err_clone}"
1927 )));
1928 }
1929 continue;
1930 }
1931 };
1932
1933 match stage_result {
1934 Ok(parts) => {
1935 for staged in parts {
1938 let part_name = staged.name.clone();
1939 match persist_staged(
1940 conn,
1941 &namespace,
1942 &memory_type_str,
1943 staged,
1944 args.force_merge,
1945 ) {
1946 Ok(FileSuccess {
1947 memory_id,
1948 action,
1949 body_length,
1950 backend_invoked: file_backend_invoked,
1951 }) => {
1952 output::emit_json_compact(&IngestFileEvent {
1953 file: file_str,
1954 name: &part_name,
1955 status: "indexed",
1956 truncated: *name_truncated,
1957 original_name: original_name.clone(),
1958 original_filename: original_filename.as_deref(),
1959 error: None,
1960 memory_id: Some(memory_id),
1961 action: Some(action),
1962 body_length,
1963 backend_invoked: file_backend_invoked,
1964 })?;
1965 succeeded += 1;
1966 }
1967 Err(ref e) if matches!(e, AppError::Duplicate(_)) => {
1968 output::emit_json_compact(&IngestFileEvent {
1969 file: file_str,
1970 name: &part_name,
1971 status: "skipped",
1972 truncated: *name_truncated,
1973 original_name: original_name.clone(),
1974 original_filename: original_filename.as_deref(),
1975 error: Some(format!("{e}")),
1976 memory_id: None,
1977 action: Some("duplicate".to_string()),
1978 body_length: 0,
1979 backend_invoked: None,
1980 })?;
1981 skipped += 1;
1982 }
1983 Err(e) => {
1984 let err_msg = format!("{e}");
1985 output::emit_json_compact(&IngestFileEvent {
1986 file: file_str,
1987 name: &part_name,
1988 status: "failed",
1989 truncated: *name_truncated,
1990 original_name: original_name.clone(),
1991 original_filename: original_filename.as_deref(),
1992 error: Some(err_msg.clone()),
1993 memory_id: None,
1994 action: None,
1995 body_length: 0,
1996 backend_invoked: None,
1997 })?;
1998 failed += 1;
1999 if fail_fast {
2000 output::emit_json_compact(&IngestSummary {
2001 summary: true,
2002 dir: args.dir.display().to_string(),
2003 pattern: args.pattern.clone(),
2004 recursive: args.recursive,
2005 files_total: total,
2006 files_succeeded: succeeded,
2007 files_failed: failed,
2008 files_skipped: skipped,
2009 elapsed_ms: started.elapsed().as_millis() as u64,
2010 })?;
2011 return Err(AppError::Validation(format!(
2012 "ingest aborted on first failure: {err_msg}"
2013 )));
2014 }
2015 }
2016 }
2017 }
2018 }
2019 Err(e) => {
2020 let err_msg = format!("{e}");
2021 output::emit_json_compact(&IngestFileEvent {
2022 file: file_str,
2023 name: derived_name,
2024 status: "failed",
2025 truncated: *name_truncated,
2026 original_name: original_name.clone(),
2027 original_filename: original_filename.as_deref(),
2028 error: Some(err_msg.clone()),
2029 memory_id: None,
2030 action: None,
2031 body_length: 0,
2032 backend_invoked: None,
2033 })?;
2034 failed += 1;
2035 if fail_fast {
2036 output::emit_json_compact(&IngestSummary {
2037 summary: true,
2038 dir: args.dir.display().to_string(),
2039 pattern: args.pattern.clone(),
2040 recursive: args.recursive,
2041 files_total: total,
2042 files_succeeded: succeeded,
2043 files_failed: failed,
2044 files_skipped: skipped,
2045 elapsed_ms: started.elapsed().as_millis() as u64,
2046 })?;
2047 return Err(AppError::Validation(format!(
2048 "ingest aborted on first failure: {err_msg}"
2049 )));
2050 }
2051 }
2052 }
2053 }
2054
2055 producer_handle
2057 .join()
2058 .map_err(|_| AppError::Internal(anyhow::anyhow!("ingest producer thread panicked")))?;
2059
2060 if let Ok(ref conn) = conn_or_err {
2061 if succeeded > 0 {
2062 let _ = conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);");
2063 }
2064 }
2065
2066 output::emit_json_compact(&IngestSummary {
2067 summary: true,
2068 dir: args.dir.display().to_string(),
2069 pattern: args.pattern.clone(),
2070 recursive: args.recursive,
2071 files_total: total,
2072 files_succeeded: succeeded,
2073 files_failed: failed,
2074 files_skipped: skipped,
2075 elapsed_ms: started.elapsed().as_millis() as u64,
2076 })?;
2077
2078 if args.enrich_after && succeeded > 0 {
2079 output::emit_json_compact(&serde_json::json!({
2080 "event": "enrich_phase_started",
2081 "operation": "memory-bindings"
2082 }))?;
2083 let enrich_args = super::enrich::EnrichArgs {
2084 operation: Some(super::enrich::EnrichOperation::MemoryBindings),
2085 mode: Some(super::enrich::EnrichMode::ClaudeCode),
2086 limit: None,
2087 target: super::enrich::ReEmbedTarget::Memories,
2088 dry_run: false,
2089 namespace: args.namespace.clone(),
2090 claude_binary: args.claude_binary.clone(),
2091 claude_model: args.claude_model.clone(),
2092 claude_timeout: args.claude_timeout,
2093 codex_binary: args.codex_binary.clone(),
2094 codex_model: args.codex_model.clone(),
2095 codex_timeout: args.codex_timeout,
2096 opencode_binary: args.opencode_binary.clone(),
2097 opencode_model: args.opencode_model.clone(),
2098 opencode_timeout: args.opencode_timeout,
2099 openrouter_model: None,
2100 openrouter_api_key: None,
2101 openrouter_timeout: 300,
2102 openrouter_base_url: None,
2103 db: args.db.clone(),
2104 json: false,
2105 resume: false,
2106 retry_failed: false,
2107 reset_stale_claims: false,
2108 stale_claim_secs: 1800,
2109 max_cost_usd: args.max_cost_usd,
2110 llm_parallelism: args.llm_parallelism as u32,
2111 wait_job_singleton: args.wait_job_singleton,
2112 force_job_singleton: args.force_job_singleton,
2113 names: Vec::new(),
2114 names_file: None,
2115 preflight_check: false,
2116 fallback_mode: None,
2117 rate_limit_buffer: 300,
2118 max_load_check: true,
2119 circuit_breaker_threshold: 5,
2120 preserve_threshold: 0.7,
2121 codex_model_validate: true,
2122 codex_model_fallback: None,
2123 min_output_chars: 500,
2124 max_output_chars: 2000,
2125 preserve_check: true,
2126 prompt_template: None,
2127 until_empty: false,
2128 max_runtime: None,
2129 max_attempts: 5,
2130 status: false,
2131 rest_concurrency: None,
2132 list_dead: false,
2135 requeue_dead: false,
2136 prune_dead_orphans: false,
2137 prune_dead_entity_orphans: false,
2138 ignore_backoff: false,
2139 body_extract_graph_only: false,
2140 };
2141 match super::enrich::run(&enrich_args, llm_backend, embedding_backend) {
2142 Ok(()) => {
2143 output::emit_json_compact(&serde_json::json!({
2144 "event": "enrich_phase_completed"
2145 }))?;
2146 }
2147 Err(e) => {
2148 tracing::warn!(error = %e, "enrich --operation memory-bindings failed after ingest");
2149 output::emit_json_compact(&serde_json::json!({
2150 "event": "enrich_phase_failed",
2151 "error": e.to_string()
2152 }))?;
2153 }
2154 }
2155 }
2156
2157 Ok(())
2158}
2159
2160fn init_storage(paths: &AppPaths) -> Result<Connection, AppError> {
2166 ensure_db_ready(paths)?;
2167 let conn = open_rw(&paths.db)?;
2168 Ok(conn)
2169}
2170
2171pub(crate) fn collect_files(
2172 dir: &Path,
2173 pattern: &str,
2174 recursive: bool,
2175 out: &mut Vec<PathBuf>,
2176) -> Result<(), AppError> {
2177 let entries = std::fs::read_dir(dir).map_err(AppError::Io)?;
2178 for entry in entries {
2179 let entry = entry.map_err(AppError::Io)?;
2180 let path = entry.path();
2181 let file_type = entry.file_type().map_err(AppError::Io)?;
2182 if file_type.is_file() {
2183 let name = entry.file_name();
2184 let name_str = name.to_string_lossy();
2185 if matches_pattern(&name_str, pattern) {
2186 out.push(path);
2187 }
2188 } else if file_type.is_dir() && recursive {
2189 collect_files(&path, pattern, recursive, out)?;
2190 }
2191 }
2192 Ok(())
2193}
2194
2195fn matches_pattern(name: &str, pattern: &str) -> bool {
2196 if let Some(suffix) = pattern.strip_prefix('*') {
2197 name.ends_with(suffix)
2198 } else if let Some(prefix) = pattern.strip_suffix('*') {
2199 name.starts_with(prefix)
2200 } else {
2201 name == pattern
2202 }
2203}
2204
2205pub(crate) fn validate_name_prefix(
2222 prefix: &str,
2223 max_name_length: usize,
2224) -> Result<usize, AppError> {
2225 if prefix.is_empty() {
2226 return Err(AppError::Validation(
2227 "--name-prefix cannot be empty".to_string(),
2228 ));
2229 }
2230 let starts_lower = prefix
2231 .chars()
2232 .next()
2233 .is_some_and(|c| c.is_ascii_lowercase());
2234 let all_slug_chars = prefix
2235 .chars()
2236 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
2237 if !starts_lower || !all_slug_chars {
2238 return Err(AppError::Validation(format!(
2239 "--name-prefix '{prefix}' must start with a lowercase letter and contain \
2240 only lowercase letters, digits and hyphens (kebab-case)"
2241 )));
2242 }
2243 let cap = crate::constants::MAX_MEMORY_NAME_LEN;
2244 if prefix.len() >= cap {
2245 return Err(AppError::LimitExceeded(format!(
2246 "--name-prefix is {} chars; prefixed names would exceed the {cap}-char \
2247 name cap (MAX_MEMORY_NAME_LEN)",
2248 prefix.len()
2249 )));
2250 }
2251 Ok(max_name_length.min(cap - prefix.len()))
2252}
2253
2254pub(crate) fn derive_kebab_name(path: &Path, max_len: usize) -> (String, bool, Option<String>) {
2255 let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
2256 let lowered: String = stem
2257 .nfd()
2258 .filter(|c| !unicode_normalization::char::is_combining_mark(*c))
2259 .map(|c| {
2260 if c == '_' || c.is_whitespace() {
2261 '-'
2262 } else {
2263 c
2264 }
2265 })
2266 .map(|c| c.to_ascii_lowercase())
2267 .filter(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '-')
2268 .collect();
2269 let collapsed = collapse_dashes(&lowered);
2270 let trimmed_raw = collapsed.trim_matches('-').to_string();
2271 let trimmed = if trimmed_raw.starts_with(|c: char| c.is_ascii_digit()) {
2273 format!("doc-{trimmed_raw}")
2274 } else {
2275 trimmed_raw
2276 };
2277 if trimmed.len() > max_len {
2278 let truncated = trimmed[..max_len].trim_matches('-').to_string();
2279 tracing::warn!(
2284 target: "ingest",
2285 original = %trimmed,
2286 truncated_to = %truncated,
2287 max_len = max_len,
2288 "derived memory name truncated to fit length cap; collisions will be resolved with numeric suffixes"
2289 );
2290 (truncated, true, Some(trimmed))
2291 } else {
2292 (trimmed, false, None)
2293 }
2294}
2295
2296fn unique_name(base: &str, taken: &BTreeSet<String>) -> Result<String, AppError> {
2309 if !taken.contains(base) {
2310 return Ok(base.to_string());
2311 }
2312 for suffix in 1..=MAX_NAME_COLLISION_SUFFIX {
2313 let candidate = format!("{base}-{suffix}");
2314 if !taken.contains(&candidate) {
2315 tracing::warn!(
2316 target: "ingest",
2317 base = %base,
2318 resolved = %candidate,
2319 suffix,
2320 "memory name collision resolved with numeric suffix"
2321 );
2322 return Ok(candidate);
2323 }
2324 }
2325 Err(AppError::Validation(format!(
2326 "too many name collisions for base '{base}' (>{MAX_NAME_COLLISION_SUFFIX}); rename source files to disambiguate"
2327 )))
2328}
2329
2330fn collapse_dashes(s: &str) -> String {
2331 let mut out = String::with_capacity(s.len());
2332 let mut prev_dash = false;
2333 for c in s.chars() {
2334 if c == '-' {
2335 if !prev_dash {
2336 out.push('-');
2337 }
2338 prev_dash = true;
2339 } else {
2340 out.push(c);
2341 prev_dash = false;
2342 }
2343 }
2344 out
2345}
2346
2347#[cfg(test)]
2348mod tests {
2349 use super::*;
2350 use std::path::PathBuf;
2351
2352 #[test]
2354 fn validate_name_prefix_shrinks_budget_to_fit_name_cap() {
2355 let budget = validate_name_prefix("projx-team", 60).unwrap();
2358 assert_eq!(budget, 60);
2359 let long_prefix = "p".repeat(75);
2361 let budget = validate_name_prefix(&long_prefix, 60).unwrap();
2362 assert_eq!(budget, 5, "80-char cap minus 75-char prefix leaves 5");
2363 }
2364
2365 #[test]
2366 fn validate_name_prefix_rejects_invalid_slugs() {
2367 for bad in ["", "-lead", "Upper", "has_underscore", "acentuação", "1x"] {
2368 let err = validate_name_prefix(bad, 60).unwrap_err();
2369 assert_eq!(err.exit_code(), 1, "prefix '{bad}' must be Validation");
2370 }
2371 }
2372
2373 #[test]
2374 fn validate_name_prefix_too_long_is_limit_exceeded() {
2375 let huge = "p".repeat(crate::constants::MAX_MEMORY_NAME_LEN);
2376 let err = validate_name_prefix(&huge, 60).unwrap_err();
2377 assert_eq!(err.exit_code(), 6, "prefix >= name cap must be exit 6");
2378 assert!(
2379 err.to_string().contains("MAX_MEMORY_NAME_LEN"),
2380 "obtido: {err}"
2381 );
2382 }
2383
2384 #[test]
2385 fn name_prefix_applies_after_kebab_normalization_and_fits_cap() {
2386 let prefix = "projx-";
2387 let budget = validate_name_prefix(prefix, 60).unwrap();
2388 let (base, _, _) = derive_kebab_name(&PathBuf::from("My File Name.md"), budget);
2389 let final_name = format!("{prefix}{base}");
2390 assert_eq!(final_name, "projx-my-file-name");
2391 assert!(final_name.len() <= crate::constants::MAX_MEMORY_NAME_LEN);
2392 assert!(crate::constants::name_slug_regex().is_match(&final_name));
2393 }
2394
2395 #[test]
2402 fn ingest_mode_none_with_resume_is_rejected() {
2403 use crate::cli::{Cli, Commands};
2404 use clap::Parser;
2405
2406 let none_resume = Cli::try_parse_from([
2407 "sqlite-graphrag",
2408 "ingest",
2409 "./docs",
2410 "--mode",
2411 "none",
2412 "--resume",
2413 ])
2414 .expect("parse succeeds; the conflict is value-conditional");
2415 let args = match none_resume.command {
2416 Some(Commands::Ingest(a)) => a,
2417 other => panic!("expected ingest, got {other:?}"),
2418 };
2419 assert!(
2420 validate_mode_conditional_flags_ingest(&args).is_err(),
2421 "--mode none + --resume must be rejected fail-fast"
2422 );
2423
2424 let claude_resume = Cli::try_parse_from([
2426 "sqlite-graphrag",
2427 "ingest",
2428 "./docs",
2429 "--mode",
2430 "claude-code",
2431 "--resume",
2432 ])
2433 .expect("parse");
2434 let args = match claude_resume.command {
2435 Some(Commands::Ingest(a)) => a,
2436 other => panic!("expected ingest, got {other:?}"),
2437 };
2438 assert!(
2439 validate_mode_conditional_flags_ingest(&args).is_ok(),
2440 "--mode claude-code + --resume is valid and must pass"
2441 );
2442 }
2443
2444 fn setup_ingest_conn() -> Connection {
2445 crate::storage::connection::register_vec_extension();
2446 let mut conn = Connection::open_in_memory().unwrap();
2447 crate::migrations::runner().run(&mut conn).unwrap();
2448 conn
2449 }
2450
2451 fn make_staged(name: &str, body: &str) -> StagedFile {
2452 StagedFile {
2453 body: body.to_string(),
2454 body_hash: blake3::hash(body.as_bytes()).to_hex().to_string(),
2455 snippet: body.chars().take(200).collect(),
2456 name: name.to_string(),
2457 description: "desc".to_string(),
2458 embedding: None,
2459 chunk_embeddings: None,
2460 chunks_info: Vec::new(),
2461 entities: Vec::new(),
2462 relationships: Vec::new(),
2463 entity_embeddings: None,
2464 urls: Vec::new(),
2465 backend_invoked: None,
2466 }
2467 }
2468
2469 #[test]
2472 fn persist_staged_force_merge_updates_existing() {
2473 let mut conn = setup_ingest_conn();
2474
2475 let first = persist_staged(
2476 &mut conn,
2477 "global",
2478 "document",
2479 make_staged("doc-a", "v1"),
2480 false,
2481 )
2482 .expect("create");
2483 assert_eq!(first.action, "created");
2484
2485 let dup = persist_staged(
2487 &mut conn,
2488 "global",
2489 "document",
2490 make_staged("doc-a", "v2-changed"),
2491 false,
2492 );
2493 assert!(matches!(dup, Err(AppError::Duplicate(_))));
2494
2495 let upd = persist_staged(
2497 &mut conn,
2498 "global",
2499 "document",
2500 make_staged("doc-a", "v2-changed"),
2501 true,
2502 )
2503 .expect("update");
2504 assert_eq!(upd.action, "updated");
2505 assert_eq!(upd.memory_id, first.memory_id);
2506 let body: String = conn
2507 .query_row(
2508 "SELECT body FROM memories WHERE id = ?1",
2509 rusqlite::params![first.memory_id],
2510 |r| r.get(0),
2511 )
2512 .unwrap();
2513 assert_eq!(body, "v2-changed");
2514 }
2515
2516 #[test]
2518 fn persist_staged_dedupes_by_body_hash() {
2519 let mut conn = setup_ingest_conn();
2520 persist_staged(
2521 &mut conn,
2522 "global",
2523 "document",
2524 make_staged("parte-1", "identical content"),
2525 false,
2526 )
2527 .expect("create");
2528
2529 let res = persist_staged(
2531 &mut conn,
2532 "global",
2533 "document",
2534 make_staged("part-01", "identical content"),
2535 false,
2536 );
2537 match res {
2538 Err(AppError::Duplicate(msg)) => assert!(msg.contains("body_hash")),
2539 other => panic!("expected body_hash dedup duplicate, got {other:?}"),
2540 }
2541 let n: i64 = conn
2543 .query_row("SELECT COUNT(*) FROM memories", [], |r| r.get(0))
2544 .unwrap();
2545 assert_eq!(n, 1);
2546 }
2547
2548 #[test]
2550 fn ingest_force_merge_flag_parses() {
2551 use crate::cli::{Cli, Commands};
2552 use clap::Parser;
2553 let cli = Cli::try_parse_from(["sqlite-graphrag", "ingest", "./docs", "--force-merge"])
2554 .expect("parse");
2555 match cli.command {
2556 Some(Commands::Ingest(a)) => assert!(a.force_merge),
2557 other => panic!("expected ingest, got {other:?}"),
2558 }
2559 let cli2 = Cli::try_parse_from(["sqlite-graphrag", "ingest", "./docs"]).expect("parse");
2561 match cli2.command {
2562 Some(Commands::Ingest(a)) => assert!(!a.force_merge),
2563 other => panic!("expected ingest, got {other:?}"),
2564 }
2565 }
2566
2567 #[test]
2568 fn matches_pattern_suffix() {
2569 assert!(matches_pattern("foo.md", "*.md"));
2570 assert!(!matches_pattern("foo.txt", "*.md"));
2571 assert!(matches_pattern("foo.md", "*"));
2572 }
2573
2574 #[test]
2575 fn matches_pattern_prefix() {
2576 assert!(matches_pattern("README.md", "README*"));
2577 assert!(!matches_pattern("CHANGELOG.md", "README*"));
2578 }
2579
2580 #[test]
2581 fn matches_pattern_exact() {
2582 assert!(matches_pattern("README.md", "README.md"));
2583 assert!(!matches_pattern("readme.md", "README.md"));
2584 }
2585
2586 #[test]
2587 fn derive_kebab_underscore_to_dash() {
2588 let p = PathBuf::from("/tmp/claude_code_headless.md");
2589 let (name, truncated, original) = derive_kebab_name(&p, DERIVED_NAME_MAX_LEN);
2590 assert_eq!(name, "claude-code-headless");
2591 assert!(!truncated);
2592 assert!(original.is_none());
2593 }
2594
2595 #[test]
2596 fn derive_kebab_uppercase_lowered() {
2597 let p = PathBuf::from("/tmp/README.md");
2598 let (name, truncated, original) = derive_kebab_name(&p, DERIVED_NAME_MAX_LEN);
2599 assert_eq!(name, "readme");
2600 assert!(!truncated);
2601 assert!(original.is_none());
2602 }
2603
2604 #[test]
2605 fn derive_kebab_strips_non_kebab_chars() {
2606 let p = PathBuf::from("/tmp/some@weird#name!.md");
2607 let (name, truncated, original) = derive_kebab_name(&p, DERIVED_NAME_MAX_LEN);
2608 assert_eq!(name, "someweirdname");
2609 assert!(!truncated);
2610 assert!(original.is_none());
2611 }
2612
2613 #[test]
2616 fn derive_kebab_folds_accented_letters_to_ascii() {
2617 let p = PathBuf::from("/tmp/açaí.md");
2618 let (name, _, _) = derive_kebab_name(&p, DERIVED_NAME_MAX_LEN);
2619 assert_eq!(name, "acai", "got '{name}'");
2620 }
2621
2622 #[test]
2623 fn derive_kebab_handles_naive_with_diaeresis() {
2624 let p = PathBuf::from("/tmp/naïve-test.md");
2625 let (name, _, _) = derive_kebab_name(&p, DERIVED_NAME_MAX_LEN);
2626 assert_eq!(name, "naive-test", "got '{name}'");
2627 }
2628
2629 #[test]
2630 fn derive_kebab_drops_emoji_keeps_word() {
2631 let p = PathBuf::from("/tmp/🚀-rocket.md");
2632 let (name, _, _) = derive_kebab_name(&p, DERIVED_NAME_MAX_LEN);
2633 assert_eq!(name, "rocket", "got '{name}'");
2634 }
2635
2636 #[test]
2637 fn derive_kebab_mixed_unicode_emoji_keeps_letters() {
2638 let p = PathBuf::from("/tmp/açaí🦜.md");
2639 let (name, _, _) = derive_kebab_name(&p, DERIVED_NAME_MAX_LEN);
2640 assert_eq!(name, "acai", "got '{name}'");
2641 }
2642
2643 #[test]
2644 fn derive_kebab_pure_emoji_yields_empty() {
2645 let p = PathBuf::from("/tmp/🦜🚀🌟.md");
2646 let (name, _, _) = derive_kebab_name(&p, DERIVED_NAME_MAX_LEN);
2647 assert!(name.is_empty(), "got '{name}'");
2648 }
2649
2650 #[test]
2651 fn derive_kebab_collapses_consecutive_dashes() {
2652 let p = PathBuf::from("/tmp/a__b___c.md");
2653 let (name, truncated, original) = derive_kebab_name(&p, DERIVED_NAME_MAX_LEN);
2654 assert_eq!(name, "a-b-c");
2655 assert!(!truncated);
2656 assert!(original.is_none());
2657 }
2658
2659 #[test]
2660 fn derive_kebab_truncates_to_60_chars() {
2661 let p = PathBuf::from(format!("/tmp/{}.md", "a".repeat(80)));
2662 let (name, truncated, original) = derive_kebab_name(&p, DERIVED_NAME_MAX_LEN);
2663 assert!(name.len() <= 60, "got len {}", name.len());
2664 assert!(truncated);
2665 assert!(original.is_some());
2666 assert!(original.unwrap().len() > 60);
2667 }
2668
2669 #[test]
2670 fn collect_files_finds_md_files() {
2671 let tmp = tempfile::tempdir().expect("tempdir");
2672 std::fs::write(tmp.path().join("a.md"), "x").unwrap();
2673 std::fs::write(tmp.path().join("b.md"), "y").unwrap();
2674 std::fs::write(tmp.path().join("c.txt"), "z").unwrap();
2675 let mut out = Vec::new();
2676 collect_files(tmp.path(), "*.md", false, &mut out).expect("collect");
2677 assert_eq!(out.len(), 2, "should find 2 .md files, got {out:?}");
2678 }
2679
2680 #[test]
2681 fn collect_files_recursive_descends_subdirs() {
2682 let tmp = tempfile::tempdir().expect("tempdir");
2683 let sub = tmp.path().join("sub");
2684 std::fs::create_dir(&sub).unwrap();
2685 std::fs::write(tmp.path().join("a.md"), "x").unwrap();
2686 std::fs::write(sub.join("b.md"), "y").unwrap();
2687 let mut out = Vec::new();
2688 collect_files(tmp.path(), "*.md", true, &mut out).expect("collect");
2689 assert_eq!(out.len(), 2);
2690 }
2691
2692 #[test]
2693 fn collect_files_non_recursive_skips_subdirs() {
2694 let tmp = tempfile::tempdir().expect("tempdir");
2695 let sub = tmp.path().join("sub");
2696 std::fs::create_dir(&sub).unwrap();
2697 std::fs::write(tmp.path().join("a.md"), "x").unwrap();
2698 std::fs::write(sub.join("b.md"), "y").unwrap();
2699 let mut out = Vec::new();
2700 collect_files(tmp.path(), "*.md", false, &mut out).expect("collect");
2701 assert_eq!(out.len(), 1);
2702 }
2703
2704 #[test]
2707 fn derive_kebab_long_basename_truncated_within_cap() {
2708 let p = PathBuf::from(format!("/tmp/{}.md", "a".repeat(120)));
2709 let (name, truncated, original) = derive_kebab_name(&p, DERIVED_NAME_MAX_LEN);
2710 assert!(
2711 name.len() <= DERIVED_NAME_MAX_LEN,
2712 "truncated name must respect cap; got {} chars",
2713 name.len()
2714 );
2715 assert!(!name.is_empty());
2716 assert!(truncated);
2717 assert!(original.is_some());
2718 }
2719
2720 #[test]
2721 fn unique_name_returns_base_when_free() {
2722 let taken: BTreeSet<String> = BTreeSet::new();
2723 let resolved = unique_name("note", &taken).expect("must resolve");
2724 assert_eq!(resolved, "note");
2725 }
2726
2727 #[test]
2728 fn unique_name_appends_first_free_suffix_on_collision() {
2729 let mut taken: BTreeSet<String> = BTreeSet::new();
2730 taken.insert("note".to_string());
2731 taken.insert("note-1".to_string());
2732 let resolved = unique_name("note", &taken).expect("must resolve");
2733 assert_eq!(resolved, "note-2");
2734 }
2735
2736 #[test]
2737 fn unique_name_errors_after_collision_cap() {
2738 let mut taken: BTreeSet<String> = BTreeSet::new();
2739 taken.insert("note".to_string());
2740 for i in 1..=MAX_NAME_COLLISION_SUFFIX {
2741 taken.insert(format!("note-{i}"));
2742 }
2743 let err = unique_name("note", &taken).expect_err("must surface error");
2744 assert!(matches!(err, AppError::Validation(_)));
2745 }
2746
2747 #[test]
2750 fn validate_relation_format_accepts_valid_relations() {
2751 use crate::parsers::{is_canonical_relation, validate_relation_format};
2752 assert!(validate_relation_format("applies_to").is_ok());
2753 assert!(validate_relation_format("depends_on").is_ok());
2754 assert!(validate_relation_format("implements").is_ok());
2755 assert!(validate_relation_format("").is_err());
2756 assert!(is_canonical_relation("applies_to"));
2757 assert!(!is_canonical_relation("implements"));
2758 }
2759
2760 use serial_test::serial;
2763
2764 fn with_env_var<F: FnOnce()>(value: Option<&str>, f: F) {
2766 let key = "SQLITE_GRAPHRAG_LOW_MEMORY";
2767 let prev = std::env::var(key).ok();
2768 match value {
2769 Some(v) => std::env::set_var(key, v),
2770 None => std::env::remove_var(key),
2771 }
2772 f();
2773 match prev {
2774 Some(p) => std::env::set_var(key, p),
2775 None => std::env::remove_var(key),
2776 }
2777 }
2778
2779 #[test]
2780 #[serial]
2781 fn env_low_memory_enabled_unset_returns_false() {
2782 with_env_var(None, || assert!(!env_low_memory_enabled()));
2783 }
2784
2785 #[test]
2786 #[serial]
2787 fn env_low_memory_enabled_empty_returns_false() {
2788 with_env_var(Some(""), || assert!(!env_low_memory_enabled()));
2789 }
2790
2791 #[test]
2792 #[serial]
2793 fn env_low_memory_enabled_truthy_values_return_true() {
2794 for v in ["1", "true", "TRUE", "yes", "YES", "on", "On"] {
2795 with_env_var(Some(v), || {
2796 assert!(env_low_memory_enabled(), "value {v:?} should be truthy")
2797 });
2798 }
2799 }
2800
2801 #[test]
2802 #[serial]
2803 fn env_low_memory_enabled_falsy_values_return_false() {
2804 for v in ["0", "false", "FALSE", "no", "off"] {
2805 with_env_var(Some(v), || {
2806 assert!(!env_low_memory_enabled(), "value {v:?} should be falsy")
2807 });
2808 }
2809 }
2810
2811 #[test]
2812 #[serial]
2813 fn env_low_memory_enabled_unrecognized_value_returns_false() {
2814 with_env_var(Some("maybe"), || assert!(!env_low_memory_enabled()));
2815 }
2816
2817 #[test]
2818 #[serial]
2819 fn resolve_parallelism_flag_forces_one_overriding_explicit_value() {
2820 with_env_var(None, || {
2821 assert_eq!(resolve_parallelism(true, Some(4)), 1);
2822 assert_eq!(resolve_parallelism(true, Some(8)), 1);
2823 assert_eq!(resolve_parallelism(true, None), 1);
2824 });
2825 }
2826
2827 #[test]
2828 #[serial]
2829 fn resolve_parallelism_env_forces_one_when_flag_off() {
2830 with_env_var(Some("1"), || {
2831 assert_eq!(resolve_parallelism(false, Some(4)), 1);
2832 assert_eq!(resolve_parallelism(false, None), 1);
2833 });
2834 }
2835
2836 #[test]
2837 #[serial]
2838 fn resolve_parallelism_falsy_env_does_not_override() {
2839 with_env_var(Some("0"), || {
2840 assert_eq!(resolve_parallelism(false, Some(4)), 4);
2841 });
2842 }
2843
2844 #[test]
2845 #[serial]
2846 fn resolve_parallelism_explicit_value_when_low_memory_off() {
2847 with_env_var(None, || {
2848 assert_eq!(resolve_parallelism(false, Some(3)), 3);
2849 assert_eq!(resolve_parallelism(false, Some(1)), 1);
2850 });
2851 }
2852
2853 #[test]
2854 #[serial]
2855 fn resolve_parallelism_default_when_unset() {
2856 with_env_var(None, || {
2857 let p = resolve_parallelism(false, None);
2858 assert!((1..=4).contains(&p), "default must be in [1, 4]; got {p}");
2859 });
2860 }
2861
2862 #[test]
2863 fn ingest_args_parses_low_memory_flag_via_clap() {
2864 use clap::Parser;
2865 let cli = crate::cli::Cli::try_parse_from([
2868 "sqlite-graphrag",
2869 "ingest",
2870 "/tmp/dummy",
2871 "--type",
2872 "document",
2873 "--low-memory",
2874 ])
2875 .expect("parse must succeed");
2876 match cli.command {
2877 Some(crate::cli::Commands::Ingest(args)) => {
2878 assert!(args.low_memory, "--low-memory must set field to true");
2879 }
2880 _ => panic!("expected Ingest subcommand"),
2881 }
2882 }
2883
2884 #[test]
2885 fn ingest_args_low_memory_defaults_false() {
2886 use clap::Parser;
2887 let cli = crate::cli::Cli::try_parse_from([
2888 "sqlite-graphrag",
2889 "ingest",
2890 "/tmp/dummy",
2891 "--type",
2892 "document",
2893 ])
2894 .expect("parse must succeed");
2895 match cli.command {
2896 Some(crate::cli::Commands::Ingest(args)) => {
2897 assert!(!args.low_memory, "default must be false");
2898 }
2899 _ => panic!("expected Ingest subcommand"),
2900 }
2901 }
2902
2903 #[test]
2906 fn dry_run_budget_event_serializes_chunk_and_token_counts() {
2907 let ev = IngestDryRunBudget {
2908 budget: true,
2909 file: "/tmp/doc.md",
2910 name: "doc",
2911 bytes: 1234,
2912 chunk_count: 3,
2913 token_count: 567,
2914 partition_count: 1,
2915 exceeds_limits: false,
2916 };
2917 let json = serde_json::to_string(&ev).expect("serialize budget event");
2918 assert!(json.contains("\"chunk_count\":3"), "got: {json}");
2919 assert!(json.contains("\"token_count\":567"), "got: {json}");
2920 assert!(json.contains("\"partition_count\":1"), "got: {json}");
2921 assert!(json.contains("\"exceeds_limits\":false"), "got: {json}");
2922 }
2923
2924 #[test]
2925 fn assess_body_budget_feeds_dry_run_with_positive_counts() {
2926 let body = "# Title\n\nsome representative body text for the budget.";
2929 let budget = chunking::assess_body_budget(body);
2930 assert!(budget.chunk_count >= 1);
2931 assert!(budget.approx_tokens >= 1);
2932 assert_eq!(budget.partition_count, 1);
2933 }
2934}