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 value_parser = crate::parsers::parse_bool_flexible,
114 action = clap::ArgAction::Set,
115 num_args = 0..=1,
116 default_missing_value = "true",
117 default_value = "false",
118 help = "Enable automatic URL-regex extraction (URL-regex only since v1.0.79)"
119 )]
120 pub enable_ner: bool,
121
122 #[arg(
126 long,
127 default_value_t = true,
128 overrides_with = "no_auto_describe",
129 help = "Derive memory description from the first meaningful body line instead of the legacy `ingested from <path>` placeholder."
130 )]
131 pub auto_describe: bool,
132 #[arg(
133 long = "no-auto-describe",
134 default_value_t = false,
135 help = "Disable `--auto-describe` and fall back to the legacy `ingested from <path>` description placeholder."
136 )]
137 pub no_auto_describe: bool,
138
139 #[arg(long, default_value_t = false, hide = true)]
141 pub skip_extraction: bool,
142
143 #[arg(long, default_value_t = false)]
145 pub fail_fast: bool,
146
147 #[arg(long, default_value_t = false)]
149 pub dry_run: bool,
150
151 #[arg(long, default_value_t = 10_000)]
153 pub max_files: usize,
154
155 #[arg(long)]
157 pub namespace: Option<String>,
158
159 #[arg(long)]
161 pub db: Option<String>,
162
163 #[arg(long, value_enum, default_value_t = JsonOutputFormat::Json)]
164 pub format: JsonOutputFormat,
165
166 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
167 pub json: bool,
168
169 #[arg(
171 long,
172 help = "Number of files to extract+embed in parallel; default = max(1, cpus/2).min(4)"
173 )]
174 pub ingest_parallelism: Option<usize>,
175
176 #[arg(
184 long,
185 default_value_t = false,
186 help = "Forces single-threaded ingest (--ingest-parallelism 1) to reduce RSS pressure. \
187 Recommended for environments with <4 GB available RAM or container/cgroup \
188 constraints. Trade-off: 3-4x longer wall time. Also honored via \
189 XDG ingest.low_memory=1."
190 )]
191 pub low_memory: bool,
192
193 #[arg(long, default_value_t = crate::constants::DEFAULT_MAX_RSS_MB,
195 help = "Maximum process RSS in MiB; abort if exceeded during embedding (default: 8192)")]
196 pub max_rss_mb: u64,
197
198 #[arg(long, default_value_t = 2, value_name = "N",
203 value_parser = clap::value_parser!(u64).range(1..=32),
204 help = "Maximum simultaneous LLM embedding subprocesses per file (default: 2, clamp [1,32])")]
205 pub llm_parallelism: u64,
206
207 #[arg(long, default_value_t = crate::constants::DERIVED_NAME_MAX_LEN,
212 help = "Maximum length for derived memory names (default: 60)")]
213 pub max_name_length: usize,
214
215 #[arg(
221 long,
222 value_name = "PREFIX",
223 help = "Kebab-case prefix applied to every derived memory name (e.g. 'projx-')"
224 )]
225 pub name_prefix: Option<String>,
226
227 #[arg(long, value_enum, default_value_t = IngestMode::None)]
229 pub mode: IngestMode,
230
231 #[arg(long)]
233 pub claude_binary: Option<std::path::PathBuf>,
234
235 #[arg(long)]
237 pub claude_model: Option<String>,
238
239 #[arg(long, default_value_t = false)]
241 pub resume: bool,
242
243 #[arg(long, default_value_t = false)]
245 pub retry_failed: bool,
246
247 #[arg(long, default_value_t = false)]
249 pub keep_queue: bool,
250
251 #[arg(long)]
253 pub queue_db: Option<String>,
254
255 #[arg(long, default_value_t = 60)]
257 pub rate_limit_wait: u64,
258
259 #[arg(long)]
261 pub max_cost_usd: Option<f64>,
262
263 #[arg(
265 long,
266 default_value_t = 300,
267 help = "Timeout in seconds for each claude -p invocation (default: 300)"
268 )]
269 pub claude_timeout: u64,
270
271 #[arg(
273 long,
274 help = "Explicit path to the Codex CLI binary (only with --mode codex)"
275 )]
276 pub codex_binary: Option<PathBuf>,
277
278 #[arg(
280 long,
281 help = "Model override for Codex extraction (e.g. o4-mini, gpt-5.1-codex)"
282 )]
283 pub codex_model: Option<String>,
284
285 #[arg(
287 long,
288 default_value_t = 300,
289 help = "Timeout in seconds for each codex exec invocation (default: 300)"
290 )]
291 pub codex_timeout: u64,
292
293 #[arg(long, value_name = "PATH")]
295 pub opencode_binary: Option<PathBuf>,
296
297 #[arg(
299 long,
300 value_name = "MODEL",
301 help = "Model override for OpenCode extraction"
302 )]
303 pub opencode_model: Option<String>,
304
305 #[arg(
307 long,
308 value_name = "SECONDS",
309 default_value_t = 300,
310 help = "Timeout in seconds for each opencode run invocation (default: 300)"
311 )]
312 pub opencode_timeout: u64,
313
314 #[arg(long, value_name = "SECONDS")]
317 pub wait_job_singleton: Option<u64>,
318
319 #[arg(long, default_value_t = false)]
322 pub force_job_singleton: bool,
323
324 #[arg(
327 long,
328 default_value_t = false,
329 help = "Run enrich --operation memory-bindings after all files are ingested"
330 )]
331 pub enrich_after: bool,
332
333 #[arg(
338 long,
339 default_value_t = false,
340 help = "Update existing memories on name collision instead of skipping (idempotent re-ingest)"
341 )]
342 pub force_merge: bool,
343}
344
345#[derive(Clone, Debug, PartialEq, Eq, clap::ValueEnum)]
347pub enum IngestMode {
348 None,
350 ClaudeCode,
352 Codex,
354 #[value(name = "opencode")]
356 Opencode,
357}
358
359fn env_low_memory_enabled() -> bool {
364 match crate::config::get_setting("ingest.low_memory") {
366 Ok(Some(v)) if v.is_empty() => false,
367 Ok(Some(v)) => match v.to_lowercase().as_str() {
368 "1" | "true" | "yes" | "on" => true,
369 "0" | "false" | "no" | "off" => false,
370 other => {
371 tracing::warn!(
372 target: "ingest",
373 value = %other,
374 "ingest.low_memory value not recognized; treating as disabled"
375 );
376 false
377 }
378 },
379 _ => false,
380 }
381}
382
383fn resolve_parallelism(low_memory_flag: bool, ingest_parallelism: Option<usize>) -> usize {
395 let env_flag = env_low_memory_enabled();
396 let low_memory = low_memory_flag || env_flag;
397
398 if low_memory {
399 if let Some(n) = ingest_parallelism {
400 if n > 1 {
401 tracing::warn!(
402 target: "ingest",
403 requested = n,
404 "--ingest-parallelism overridden by --low-memory; using 1"
405 );
406 }
407 }
408 if low_memory_flag {
409 tracing::info!(
410 target: "ingest",
411 source = "flag",
412 "low-memory mode enabled: forcing --ingest-parallelism 1"
413 );
414 } else {
415 tracing::info!(
416 target: "ingest",
417 source = "env",
418 "low-memory mode enabled via SQLITE_GRAPHRAG_LOW_MEMORY: forcing --ingest-parallelism 1"
419 );
420 }
421 return 1;
422 }
423
424 ingest_parallelism
425 .unwrap_or_else(|| {
426 std::thread::available_parallelism()
427 .map(|v| v.get() / 2)
428 .unwrap_or(1)
429 .clamp(1, 4)
430 })
431 .max(1)
432}
433
434#[derive(Serialize)]
435struct IngestFileEvent<'a> {
436 file: &'a str,
437 name: &'a str,
438 status: &'a str,
439 truncated: bool,
441 #[serde(skip_serializing_if = "Option::is_none")]
443 original_name: Option<String>,
444 #[serde(skip_serializing_if = "Option::is_none")]
446 original_filename: Option<&'a str>,
447 #[serde(skip_serializing_if = "Option::is_none")]
448 error: Option<String>,
449 #[serde(skip_serializing_if = "Option::is_none")]
450 memory_id: Option<i64>,
451 #[serde(skip_serializing_if = "Option::is_none")]
452 action: Option<String>,
453 body_length: usize,
455 #[serde(skip_serializing_if = "Option::is_none")]
460 backend_invoked: Option<&'a str>,
461}
462
463#[derive(Serialize)]
467struct IngestDryRunBudget<'a> {
468 budget: bool,
469 file: &'a str,
470 name: &'a str,
471 bytes: usize,
472 chunk_count: usize,
473 token_count: usize,
474 partition_count: usize,
475 exceeds_limits: bool,
476}
477
478#[derive(Serialize)]
479struct IngestSummary {
480 summary: bool,
481 dir: String,
482 pattern: String,
483 recursive: bool,
484 files_total: usize,
485 files_succeeded: usize,
486 files_failed: usize,
487 files_skipped: usize,
488 elapsed_ms: u64,
489}
490
491#[derive(Debug)]
493struct FileSuccess {
494 memory_id: i64,
495 action: String,
496 body_length: usize,
497 backend_invoked: Option<&'static str>,
498}
499
500#[derive(Serialize)]
503struct StageProgressEvent<'a> {
504 schema_version: u8,
505 event: &'a str,
506 path: &'a str,
507 ms: u64,
508 entities: usize,
509 relationships: usize,
510}
511
512struct StagedFile {
515 body: String,
516 body_hash: String,
517 snippet: String,
518 name: String,
519 description: String,
520 embedding: Option<Vec<f32>>,
521 chunk_embeddings: Option<Vec<Vec<f32>>>,
522 chunks_info: Vec<crate::chunking::Chunk>,
523 entities: Vec<NewEntity>,
524 relationships: Vec<NewRelationship>,
525 entity_embeddings: Option<Vec<Vec<f32>>>,
526 urls: Vec<crate::extraction::ExtractedUrl>,
527 backend_invoked: Option<&'static str>,
532}
533
534#[allow(clippy::too_many_arguments)]
540fn stage_file(
541 _idx: usize,
542 path: &Path,
543 name: &str,
544 paths: &AppPaths,
545 enable_ner: bool,
546 max_rss_mb: u64,
547 llm_parallelism: usize,
548 llm_backend: crate::cli::LlmBackendChoice,
549 embedding_backend: crate::cli::EmbeddingBackendChoice,
550 auto_describe: bool,
551) -> Result<Vec<StagedFile>, AppError> {
552 use crate::constants::*;
553
554 if name.len() > MAX_MEMORY_NAME_LEN {
555 return Err(AppError::LimitExceeded(
556 crate::i18n::validation::name_length(MAX_MEMORY_NAME_LEN),
557 ));
558 }
559 if name.starts_with("__") {
560 return Err(AppError::Validation(
561 crate::i18n::validation::reserved_name(),
562 ));
563 }
564 {
565 let slug_re = crate::constants::name_slug_regex();
566 if !slug_re.is_match(name) {
567 return Err(AppError::Validation(crate::i18n::validation::name_kebab(
568 name,
569 )));
570 }
571 }
572
573 let file_size = std::fs::metadata(path).map_err(AppError::Io)?.len();
574 if file_size > MAX_MEMORY_BODY_LEN as u64 {
575 return Err(AppError::BodyTooLarge {
576 bytes: file_size,
577 limit: MAX_MEMORY_BODY_LEN as u64,
578 });
579 }
580 let raw_body = std::fs::read_to_string(path).map_err(AppError::Io)?;
581 if raw_body.len() > MAX_MEMORY_BODY_LEN {
582 return Err(AppError::BodyTooLarge {
583 bytes: raw_body.len() as u64,
584 limit: MAX_MEMORY_BODY_LEN as u64,
585 });
586 }
587 if raw_body.trim().is_empty() {
588 return Err(AppError::Validation(crate::i18n::validation::empty_body()));
589 }
590
591 let description = if auto_describe {
592 crate::commands::ingest_heuristics::extract_heuristic_description(
593 &raw_body,
594 Some(&path.display().to_string()),
595 )
596 } else {
597 format!("ingested from {}", path.display())
598 };
599 if description.len() > MAX_MEMORY_DESCRIPTION_LEN {
600 return Err(AppError::Validation(
601 crate::i18n::validation::description_exceeds(MAX_MEMORY_DESCRIPTION_LEN),
602 ));
603 }
604
605 let partitions = chunking::split_body_by_sections(&raw_body);
613 let total_parts = partitions.len();
614 let mut staged = Vec::with_capacity(total_parts);
615 for (part_idx, part_body) in partitions.into_iter().enumerate() {
616 let part_name = if total_parts == 1 {
617 name.to_string()
618 } else {
619 format!("{name}-part-{}", part_idx + 1)
620 };
621 if part_name.len() > MAX_MEMORY_NAME_LEN {
622 return Err(AppError::LimitExceeded(
623 crate::i18n::validation::name_length(MAX_MEMORY_NAME_LEN),
624 ));
625 }
626 let part_description = if total_parts == 1 {
627 description.clone()
628 } else {
629 partition_description(&description, part_idx + 1, total_parts)
630 };
631 staged.push(stage_one_body(
632 part_body,
633 part_name,
634 part_description,
635 paths,
636 enable_ner,
637 max_rss_mb,
638 llm_parallelism,
639 llm_backend,
640 embedding_backend,
641 )?);
642 }
643 Ok(staged)
644}
645
646fn partition_description(base: &str, part: usize, total: usize) -> String {
650 let suffix = format!(" (part {part}/{total})");
651 let max = crate::constants::MAX_MEMORY_DESCRIPTION_LEN;
652 if base.len() + suffix.len() <= max {
653 return format!("{base}{suffix}");
654 }
655 let mut cut = max.saturating_sub(suffix.len()).min(base.len());
656 while cut > 0 && !base.is_char_boundary(cut) {
657 cut -= 1;
658 }
659 format!("{}{}", &base[..cut], suffix)
660}
661
662#[allow(clippy::too_many_arguments)]
666fn stage_one_body(
667 raw_body: String,
668 name: String,
669 description: String,
670 paths: &AppPaths,
671 enable_ner: bool,
672 max_rss_mb: u64,
673 llm_parallelism: usize,
674 llm_backend: crate::cli::LlmBackendChoice,
675 embedding_backend: crate::cli::EmbeddingBackendChoice,
676) -> Result<StagedFile, AppError> {
677 use crate::constants::*;
678
679 let mut extracted_entities: Vec<NewEntity> = Vec::with_capacity(30);
680 let mut extracted_relationships: Vec<NewRelationship> = Vec::with_capacity(50);
681 let mut extracted_urls: Vec<crate::extraction::ExtractedUrl> = Vec::with_capacity(4);
682 if enable_ner {
683 match crate::extraction::extract_graph_auto(&raw_body, paths) {
684 Ok(extracted) => {
685 extracted_urls = extracted.urls;
686 extracted_entities = extracted
691 .entities
692 .into_iter()
693 .map(|e| NewEntity {
694 name: e.name,
695 entity_type: crate::entity_type::EntityType::Concept,
696 description: None,
697 })
698 .collect();
699 extracted_relationships.clear();
704
705 if extracted_entities.len() > max_entities_per_memory() {
706 extracted_entities.truncate(max_entities_per_memory());
707 }
708 if extracted_relationships.len() > max_relationships_per_memory() {
709 extracted_relationships.truncate(max_relationships_per_memory());
710 }
711 }
712 Err(e) => {
713 tracing::warn!(
714 target: "ingest",
715 file = %name,
716 "auto-extraction failed (graceful degradation): {e:#}"
717 );
718 }
719 }
720 }
721
722 for rel in &mut extracted_relationships {
723 rel.relation = crate::parsers::normalize_relation(&rel.relation);
724 if let Err(e) = crate::parsers::validate_relation_format(&rel.relation) {
725 return Err(AppError::Validation(format!(
726 "{e} for relationship '{}' -> '{}'",
727 rel.source, rel.target
728 )));
729 }
730 crate::parsers::warn_if_non_canonical(&rel.relation);
731 if !(0.0..=1.0).contains(&rel.strength) {
732 return Err(AppError::Validation(format!(
733 "invalid strength {} for relationship '{}' -> '{}'; expected value in [0.0, 1.0]",
734 rel.strength, rel.source, rel.target
735 )));
736 }
737 }
738
739 let body_hash = blake3::hash(raw_body.as_bytes()).to_hex().to_string();
740 let snippet: String = raw_body.chars().take(200).collect();
741
742 let chunks_info = chunking::split_into_chunks_hierarchical(&raw_body);
743 if chunks_info.len() > REMEMBER_MAX_SAFE_MULTI_CHUNKS {
744 return Err(AppError::TooManyChunks {
745 chunks: chunks_info.len(),
746 limit: REMEMBER_MAX_SAFE_MULTI_CHUNKS,
747 });
748 }
749
750 let mut chunk_embeddings_opt: Option<Vec<Vec<f32>>> = None;
751 let skip_embed = crate::embedder::should_skip_embedding_on_failure();
752 let (embedding, backend_invoked): (Option<Vec<f32>>, Option<&'static str>) = if chunks_info
756 .len()
757 == 1
758 {
759 match crate::embedder::embed_passage_with_embedding_choice(
760 &paths.models,
761 &raw_body,
762 embedding_backend,
763 llm_backend,
764 ) {
765 Ok((v, k)) => (Some(v), Some(k.as_str())),
766 Err(
769 e @ (AppError::Validation(_)
770 | AppError::BodyTooLarge { .. }
771 | AppError::TooManyTokens { .. }),
772 ) => return Err(e),
773 Err(e) if skip_embed => {
774 tracing::warn!(error = %e, file = %name, "ingest: embedding failed; --skip-embedding-on-failure active, persisting without embedding");
775 (None, None)
776 }
777 Err(e) => return Err(e),
778 }
779 } else {
780 let chunk_texts: Vec<String> = chunks_info
783 .iter()
784 .map(|c| chunking::chunk_text(&raw_body, c).to_string())
785 .collect();
786 if let Some(rss) = crate::memory_guard::current_process_memory_mb() {
787 if rss > max_rss_mb {
788 tracing::error!(
789 target: "ingest",
790 rss_mb = rss,
791 max_rss_mb = max_rss_mb,
792 file = %name,
793 "RSS exceeded --max-rss-mb threshold; aborting to prevent system instability"
794 );
795 return Err(AppError::LowMemory {
796 available_mb: crate::memory_guard::available_memory_mb(),
797 required_mb: max_rss_mb,
798 });
799 }
800 }
801 match crate::embedder::embed_passages_parallel_with_embedding_choice(
802 &paths.models,
803 &chunk_texts,
804 llm_parallelism,
805 crate::embedder::chunk_embed_batch_size(),
806 embedding_backend,
807 llm_backend,
808 ) {
809 Ok(chunk_embeddings) => {
810 let aggregated = chunking::aggregate_embeddings(&chunk_embeddings);
811 chunk_embeddings_opt = Some(chunk_embeddings);
812 (Some(aggregated), None)
815 }
816 Err(
817 e @ (AppError::Validation(_)
818 | AppError::BodyTooLarge { .. }
819 | AppError::TooManyTokens { .. }),
820 ) => return Err(e),
821 Err(e) if skip_embed => {
822 tracing::warn!(error = %e, file = %name, "ingest: chunk embedding failed; --skip-embedding-on-failure active, persisting without embedding");
823 (None, None)
824 }
825 Err(e) => return Err(e),
826 }
827 };
828
829 let entity_texts: Vec<String> = extracted_entities
831 .iter()
832 .map(|entity| match &entity.description {
833 Some(desc) => format!("{} {}", entity.name, desc),
834 None => entity.name.clone(),
835 })
836 .collect();
837 let entity_embeddings_opt = match crate::embedder::embed_entity_texts_cached(
841 &paths.models,
842 &entity_texts,
843 llm_parallelism,
844 embedding_backend,
845 llm_backend,
846 ) {
847 Ok((entity_embeddings, embed_cache_stats)) => {
848 if embed_cache_stats.hits > 0 {
849 tracing::debug!(
850 hits = embed_cache_stats.hits,
851 misses = embed_cache_stats.misses,
852 requested = embed_cache_stats.requested,
853 "G56: entity embed cache hit (ingest)"
854 );
855 }
856 Some(entity_embeddings)
857 }
858 Err(e) if skip_embed => {
859 tracing::warn!(error = %e, file = %name, "ingest: entity embedding failed; --skip-embedding-on-failure active");
860 None
861 }
862 Err(e) => return Err(e),
863 };
864
865 Ok(StagedFile {
866 body: raw_body,
867 body_hash,
868 snippet,
869 name,
870 description,
871 embedding,
872 chunk_embeddings: chunk_embeddings_opt,
873 chunks_info,
874 entities: extracted_entities,
875 relationships: extracted_relationships,
876 entity_embeddings: entity_embeddings_opt,
877 urls: extracted_urls,
878 backend_invoked,
879 })
880}
881
882fn link_staged_graph(
886 tx: &Connection,
887 namespace: &str,
888 memory_id: i64,
889 staged: &StagedFile,
890) -> Result<(), AppError> {
891 if staged.entities.is_empty() && staged.relationships.is_empty() {
892 return Ok(());
893 }
894 for (idx, entity) in staged.entities.iter().enumerate() {
895 let entity_id = entities::upsert_entity(tx, namespace, entity)?;
896 if let Some(ref entity_embeddings) = staged.entity_embeddings {
897 if let Some(entity_embedding) = entity_embeddings.get(idx) {
898 entities::upsert_entity_vec(
899 tx,
900 entity_id,
901 namespace,
902 entity.entity_type,
903 entity_embedding,
904 &entity.name,
905 )?;
906 }
907 }
908 entities::link_memory_entity(tx, memory_id, entity_id)?;
909 }
910 let entity_types: std::collections::HashMap<&str, EntityType> = staged
911 .entities
912 .iter()
913 .map(|entity| (entity.name.as_str(), entity.entity_type))
914 .collect();
915
916 let mut affected_entity_ids: std::collections::HashSet<i64> = std::collections::HashSet::new();
917 for entity in &staged.entities {
918 if let Some(eid) = entities::find_entity_id(tx, namespace, &entity.name)? {
919 affected_entity_ids.insert(eid);
920 }
921 }
922
923 for rel in &staged.relationships {
924 let source_entity = NewEntity {
925 name: rel.source.clone(),
926 entity_type: entity_types
927 .get(rel.source.as_str())
928 .copied()
929 .unwrap_or(EntityType::Concept),
930 description: None,
931 };
932 let target_entity = NewEntity {
933 name: rel.target.clone(),
934 entity_type: entity_types
935 .get(rel.target.as_str())
936 .copied()
937 .unwrap_or(EntityType::Concept),
938 description: None,
939 };
940 let source_id = entities::upsert_entity(tx, namespace, &source_entity)?;
941 let target_id = entities::upsert_entity(tx, namespace, &target_entity)?;
942 let rel_id = entities::upsert_relationship(tx, namespace, source_id, target_id, rel)?;
943 entities::link_memory_relationship(tx, memory_id, rel_id)?;
944 affected_entity_ids.insert(source_id);
945 affected_entity_ids.insert(target_id);
946 }
947
948 for &eid in &affected_entity_ids {
949 entities::recalculate_degree(tx, eid)?;
950 }
951 Ok(())
952}
953
954fn persist_staged(
962 conn: &mut Connection,
963 namespace: &str,
964 memory_type: &str,
965 staged: StagedFile,
966 force_merge: bool,
967) -> Result<FileSuccess, AppError> {
968 {
969 let active_count: u32 = conn.query_row(
970 "SELECT COUNT(DISTINCT namespace) FROM memories WHERE deleted_at IS NULL",
971 [],
972 |r| r.get::<_, i64>(0).map(|v| v as u32),
973 )?;
974 let ns_exists: bool = conn.query_row(
975 "SELECT EXISTS(SELECT 1 FROM memories WHERE namespace = ?1 AND deleted_at IS NULL)",
976 rusqlite::params![namespace],
977 |r| r.get::<_, i64>(0).map(|v| v > 0),
978 )?;
979 if !ns_exists && active_count >= crate::constants::MAX_NAMESPACES_ACTIVE {
980 return Err(AppError::NamespaceError(format!(
981 "active namespace limit of {} exceeded while creating '{namespace}'",
982 crate::constants::MAX_NAMESPACES_ACTIVE
983 )));
984 }
985 }
986
987 let existing_memory = memories::find_by_name(conn, namespace, &staged.name)?;
988 let duplicate_hash_id = memories::find_by_hash(conn, namespace, &staged.body_hash)?;
989
990 let new_memory = NewMemory {
991 namespace: namespace.to_string(),
992 name: staged.name.clone(),
993 memory_type: memory_type.to_string(),
994 description: staged.description.clone(),
995 body: staged.body.clone(),
996 body_hash: staged.body_hash.clone(),
997 session_id: None,
998 source: "agent".to_string(),
999 metadata: serde_json::json!({}),
1000 };
1001 let body_length = new_memory.body.len();
1002 let metadata_json = serde_json::to_string(&new_memory.metadata)?;
1003
1004 match existing_memory {
1005 Some((existing_id, _updated_at, _version)) => {
1006 if !force_merge {
1007 return Err(AppError::Duplicate(errors_msg::duplicate_memory(
1008 &staged.name,
1009 namespace,
1010 )));
1011 }
1012
1013 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1016
1017 let (old_name, old_desc, old_body): (String, String, String) = tx.query_row(
1018 "SELECT name, description, body FROM memories WHERE id = ?1",
1019 rusqlite::params![existing_id],
1020 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
1021 )?;
1022
1023 let next_v = versions::next_version(&tx, existing_id)?;
1024 memories::update(&tx, existing_id, &new_memory, None)?;
1025 memories::sync_fts_after_update(
1026 &tx,
1027 existing_id,
1028 &old_name,
1029 &old_desc,
1030 &old_body,
1031 &staged.name,
1032 &staged.description,
1033 &new_memory.body,
1034 )?;
1035 versions::insert_version(
1036 &tx,
1037 existing_id,
1038 next_v,
1039 &staged.name,
1040 memory_type,
1041 &staged.description,
1042 &new_memory.body,
1043 &metadata_json,
1044 None,
1045 "edit",
1046 )?;
1047
1048 storage_chunks::delete_chunks(&tx, existing_id)?;
1050 if let Some(ref emb) = staged.embedding {
1051 memories::upsert_vec(
1052 &tx,
1053 existing_id,
1054 namespace,
1055 memory_type,
1056 emb,
1057 &staged.name,
1058 &staged.snippet,
1059 )?;
1060 }
1061 if staged.chunks_info.len() > 1 {
1062 storage_chunks::insert_chunk_slices(
1063 &tx,
1064 existing_id,
1065 &new_memory.body,
1066 &staged.chunks_info,
1067 )?;
1068 if let Some(ref chunk_embeddings) = staged.chunk_embeddings {
1069 for (i, emb) in chunk_embeddings.iter().enumerate() {
1070 storage_chunks::upsert_chunk_vec(
1071 &tx,
1072 i as i64,
1073 existing_id,
1074 i as i32,
1075 emb,
1076 )?;
1077 }
1078 }
1079 }
1080
1081 link_staged_graph(&tx, namespace, existing_id, &staged)?;
1082 tx.commit()?;
1083
1084 Ok(FileSuccess {
1085 memory_id: existing_id,
1086 action: "updated".to_string(),
1087 body_length,
1088 backend_invoked: staged.backend_invoked,
1089 })
1090 }
1091 None => {
1092 if let Some(hash_id) = duplicate_hash_id {
1095 return Err(AppError::Duplicate(format!(
1096 "identical body already stored as memory id {hash_id} (dedup by body_hash); skipping '{}'",
1097 staged.name
1098 )));
1099 }
1100
1101 let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1102 let memory_id = memories::insert(&tx, &new_memory)?;
1103 versions::insert_version(
1104 &tx,
1105 memory_id,
1106 1,
1107 &staged.name,
1108 memory_type,
1109 &staged.description,
1110 &new_memory.body,
1111 &metadata_json,
1112 None,
1113 "create",
1114 )?;
1115 if let Some(ref emb) = staged.embedding {
1116 memories::upsert_vec(
1117 &tx,
1118 memory_id,
1119 namespace,
1120 memory_type,
1121 emb,
1122 &staged.name,
1123 &staged.snippet,
1124 )?;
1125 }
1126 if staged.chunks_info.len() > 1 {
1127 storage_chunks::insert_chunk_slices(
1128 &tx,
1129 memory_id,
1130 &new_memory.body,
1131 &staged.chunks_info,
1132 )?;
1133 if let Some(ref chunk_embeddings) = staged.chunk_embeddings {
1134 for (i, emb) in chunk_embeddings.iter().enumerate() {
1135 storage_chunks::upsert_chunk_vec(&tx, i as i64, memory_id, i as i32, emb)?;
1136 }
1137 }
1138 }
1139 link_staged_graph(&tx, namespace, memory_id, &staged)?;
1140 tx.commit()?;
1141
1142 if !staged.urls.is_empty() {
1143 let url_entries: Vec<storage_urls::MemoryUrl> = staged
1144 .urls
1145 .into_iter()
1146 .map(|u| storage_urls::MemoryUrl {
1147 url: u.url,
1148 offset: Some(u.start as i64),
1149 })
1150 .collect();
1151 let _ = storage_urls::insert_urls(conn, memory_id, &url_entries);
1152 }
1153
1154 Ok(FileSuccess {
1155 memory_id,
1156 action: "created".to_string(),
1157 body_length,
1158 backend_invoked: staged.backend_invoked,
1159 })
1160 }
1161 }
1162}
1163
1164fn is_at_default<T: PartialEq>(value: T, default: T) -> bool {
1172 value == default
1173}
1174
1175fn validate_mode_conditional_flags_ingest(args: &IngestArgs) -> Result<(), AppError> {
1189 const DEFAULT_TIMEOUT: u64 = 300;
1190 const DEFAULT_RATE_LIMIT_WAIT: u64 = 60;
1191
1192 let mut conflicts: Vec<String> = Vec::new();
1193
1194 let is_local_mode = args.mode == IngestMode::None;
1195
1196 if args.name_prefix.is_some() && !is_local_mode {
1199 return Err(AppError::Validation(
1200 "--name-prefix is not supported with --mode claude-code/codex/opencode; \
1201 use --mode none (default)"
1202 .to_string(),
1203 ));
1204 }
1205
1206 if is_local_mode {
1207 if args.claude_binary.is_some() {
1208 conflicts.push("--claude-binary is ignored when --mode is none".to_string());
1209 }
1210 if args.claude_model.is_some() {
1211 conflicts.push("--claude-model is ignored when --mode is none".to_string());
1212 }
1213 if !is_at_default(args.claude_timeout, DEFAULT_TIMEOUT) {
1214 conflicts.push(format!(
1215 "--claude-timeout={} is ignored when --mode is none (remove the flag to use the default 300s)",
1216 args.claude_timeout
1217 ));
1218 }
1219 if args.codex_binary.is_some() {
1220 conflicts.push("--codex-binary is ignored when --mode is none".to_string());
1221 }
1222 if args.codex_model.is_some() {
1223 conflicts.push("--codex-model is ignored when --mode is none".to_string());
1224 }
1225 if !is_at_default(args.codex_timeout, DEFAULT_TIMEOUT) {
1226 conflicts.push(format!(
1227 "--codex-timeout={} is ignored when --mode is none (remove the flag to use the default 300s)",
1228 args.codex_timeout
1229 ));
1230 }
1231 if args.opencode_binary.is_some() {
1232 conflicts.push("--opencode-binary is ignored when --mode is none".to_string());
1233 }
1234 if args.opencode_model.is_some() {
1235 conflicts.push("--opencode-model is ignored when --mode is none".to_string());
1236 }
1237 if !is_at_default(args.opencode_timeout, DEFAULT_TIMEOUT) {
1238 conflicts.push(format!(
1239 "--opencode-timeout={} is ignored when --mode is none (remove the flag to use the default 300s)",
1240 args.opencode_timeout
1241 ));
1242 }
1243 if args.max_cost_usd.is_some() {
1244 conflicts.push("--max-cost-usd is ignored when --mode is none (cost is only tracked for LLM-backed modes)".to_string());
1245 }
1246 if args.resume {
1247 conflicts.push("--resume is ignored when --mode is none (the queue DB is only used by LLM-backed modes)".to_string());
1248 }
1249 if args.retry_failed {
1250 conflicts.push("--retry-failed is ignored when --mode is none".to_string());
1251 }
1252 if args.keep_queue {
1253 conflicts.push("--keep-queue is ignored when --mode is none".to_string());
1254 }
1255 if !is_at_default(args.rate_limit_wait, DEFAULT_RATE_LIMIT_WAIT) {
1256 conflicts.push(format!(
1257 "--rate-limit-wait={} is ignored when --mode is none",
1258 args.rate_limit_wait
1259 ));
1260 }
1261 }
1262
1263 match args.mode {
1264 IngestMode::ClaudeCode => {
1265 if args.codex_binary.is_some() {
1266 conflicts.push("--codex-binary is ignored when --mode=claude-code".to_string());
1267 }
1268 if args.codex_model.is_some() {
1269 conflicts.push("--codex-model is ignored when --mode=claude-code".to_string());
1270 }
1271 if !is_at_default(args.codex_timeout, DEFAULT_TIMEOUT) {
1272 conflicts.push(format!(
1273 "--codex-timeout={} is ignored when --mode=claude-code (remove the flag to use the default 300s)",
1274 args.codex_timeout
1275 ));
1276 }
1277 if args.opencode_binary.is_some() {
1278 conflicts.push("--opencode-binary is ignored when --mode=claude-code".to_string());
1279 }
1280 if args.opencode_model.is_some() {
1281 conflicts.push("--opencode-model is ignored when --mode=claude-code".to_string());
1282 }
1283 if !is_at_default(args.opencode_timeout, DEFAULT_TIMEOUT) {
1284 conflicts.push(format!(
1285 "--opencode-timeout={} is ignored when --mode=claude-code (remove the flag to use the default 300s)",
1286 args.opencode_timeout
1287 ));
1288 }
1289 }
1290 IngestMode::Codex => {
1291 if args.claude_binary.is_some() {
1292 conflicts.push("--claude-binary is ignored when --mode=codex".to_string());
1293 }
1294 if args.claude_model.is_some() {
1295 conflicts.push("--claude-model is ignored when --mode=codex".to_string());
1296 }
1297 if !is_at_default(args.claude_timeout, DEFAULT_TIMEOUT) {
1298 conflicts.push(format!(
1299 "--claude-timeout={} is ignored when --mode=codex (remove the flag to use the default 300s)",
1300 args.claude_timeout
1301 ));
1302 }
1303 if args.max_cost_usd.is_some() {
1304 conflicts.push(
1305 "--max-cost-usd is ignored when --mode=codex (OAuth-first; cost is metered by your subscription)"
1306 .to_string(),
1307 );
1308 }
1309 if args.resume {
1310 conflicts.push("--resume is only valid for --mode=claude-code".to_string());
1311 }
1312 if args.retry_failed {
1313 conflicts.push("--retry-failed is only valid for --mode=claude-code".to_string());
1314 }
1315 if args.keep_queue {
1316 conflicts.push("--keep-queue is only valid for --mode=claude-code".to_string());
1317 }
1318 if args.opencode_binary.is_some() {
1319 conflicts.push("--opencode-binary is ignored when --mode=codex".to_string());
1320 }
1321 if args.opencode_model.is_some() {
1322 conflicts.push("--opencode-model is ignored when --mode=codex".to_string());
1323 }
1324 if !is_at_default(args.opencode_timeout, DEFAULT_TIMEOUT) {
1325 conflicts.push(format!(
1326 "--opencode-timeout={} is ignored when --mode=codex (remove the flag to use the default 300s)",
1327 args.opencode_timeout
1328 ));
1329 }
1330 }
1331 IngestMode::Opencode => {
1332 if args.claude_binary.is_some() {
1333 conflicts.push("--claude-binary is ignored when --mode=opencode".to_string());
1334 }
1335 if args.claude_model.is_some() {
1336 conflicts.push("--claude-model is ignored when --mode=opencode".to_string());
1337 }
1338 if !is_at_default(args.claude_timeout, DEFAULT_TIMEOUT) {
1339 conflicts.push(format!(
1340 "--claude-timeout={} is ignored when --mode=opencode (remove the flag to use the default 300s)",
1341 args.claude_timeout
1342 ));
1343 }
1344 if args.codex_binary.is_some() {
1345 conflicts.push("--codex-binary is ignored when --mode=opencode".to_string());
1346 }
1347 if args.codex_model.is_some() {
1348 conflicts.push("--codex-model is ignored when --mode=opencode".to_string());
1349 }
1350 if !is_at_default(args.codex_timeout, DEFAULT_TIMEOUT) {
1351 conflicts.push(format!(
1352 "--codex-timeout={} is ignored when --mode=opencode (remove the flag to use the default 300s)",
1353 args.codex_timeout
1354 ));
1355 }
1356 if args.max_cost_usd.is_some() {
1357 conflicts.push(
1358 "--max-cost-usd is ignored when --mode=opencode (OAuth-first; cost is metered by your subscription)"
1359 .to_string(),
1360 );
1361 }
1362 if args.resume {
1363 conflicts.push("--resume is only valid for --mode=claude-code".to_string());
1364 }
1365 if args.retry_failed {
1366 conflicts.push("--retry-failed is only valid for --mode=claude-code".to_string());
1367 }
1368 if args.keep_queue {
1369 conflicts.push("--keep-queue is only valid for --mode=claude-code".to_string());
1370 }
1371 }
1372 IngestMode::None => {}
1373 }
1374
1375 if !conflicts.is_empty() {
1376 return Err(AppError::Validation(format!(
1377 "G20: mode-conditional flag conflicts detected for --mode={:?}:\n - {}",
1378 args.mode,
1379 conflicts.join("\n - ")
1380 )));
1381 }
1382
1383 Ok(())
1384}
1385
1386#[tracing::instrument(skip_all, level = "debug", name = "ingest")]
1389pub fn run(
1390 args: IngestArgs,
1391 llm_backend: crate::cli::LlmBackendChoice,
1392 embedding_backend: crate::cli::EmbeddingBackendChoice,
1393) -> Result<(), AppError> {
1394 validate_mode_conditional_flags_ingest(&args)?;
1397 tracing::debug!(target: "ingest", dir = %args.dir.display(), mode = ?args.mode, "starting ingest");
1398 if args.mode == IngestMode::ClaudeCode {
1399 return super::ingest_claude::run_claude_ingest(&args, embedding_backend, llm_backend);
1400 }
1401 if args.mode == IngestMode::Codex {
1402 return super::ingest_codex::run_codex_ingest(&args);
1403 }
1404 if args.mode == IngestMode::Opencode {
1405 return super::ingest_opencode::run_opencode_ingest(&args);
1406 }
1407
1408 let started = std::time::Instant::now();
1409
1410 if !args.dir.exists() {
1411 return Err(AppError::Validation(format!(
1412 "directory not found: {}",
1413 args.dir.display()
1414 )));
1415 }
1416 if !args.dir.is_dir() {
1417 return Err(AppError::Validation(format!(
1418 "path is not a directory: {}",
1419 args.dir.display()
1420 )));
1421 }
1422
1423 let mut files: Vec<PathBuf> = Vec::with_capacity(128);
1424 collect_files(&args.dir, &args.pattern, args.recursive, &mut files)?;
1425 files.sort_unstable();
1426
1427 if files.len() > args.max_files {
1428 return Err(AppError::Validation(format!(
1429 "found {} files matching pattern, exceeds --max-files cap of {} (raise the cap or narrow the pattern)",
1430 files.len(),
1431 args.max_files
1432 )));
1433 }
1434
1435 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
1436 let memory_type_str = args.r#type.as_str().to_string();
1437
1438 let paths = AppPaths::resolve(args.db.as_deref())?;
1439 let mut conn_or_err = match init_storage(&paths) {
1440 Ok(c) => Ok(c),
1441 Err(e) => Err(format!("{e}")),
1442 };
1443
1444 let mut succeeded: usize = 0;
1445 let mut failed: usize = 0;
1446 let mut skipped: usize = 0;
1447 let total = files.len();
1448
1449 let mut taken_names: BTreeSet<String> = BTreeSet::new();
1452
1453 enum SlotMeta {
1459 Skip {
1460 file_str: String,
1461 derived_base: String,
1462 name_truncated: bool,
1463 original_name: Option<String>,
1464 original_filename: Option<String>,
1465 reason: String,
1466 },
1467 Process {
1468 file_str: String,
1469 derived_name: String,
1470 name_truncated: bool,
1471 original_name: Option<String>,
1472 original_filename: Option<String>,
1473 },
1474 }
1475
1476 struct ProcessItem {
1477 idx: usize,
1478 path: PathBuf,
1479 file_str: String,
1480 derived_name: String,
1481 }
1482
1483 let files_cap = files.len();
1484 let mut slots_meta: Vec<SlotMeta> = Vec::new();
1485 slots_meta.try_reserve(files_cap).map_err(|_| {
1486 AppError::LimitExceeded(format!(
1487 "allocation of {files_cap} slot metadata entries would exceed available memory"
1488 ))
1489 })?;
1490 let mut process_items: Vec<ProcessItem> = Vec::new();
1491 process_items.try_reserve(files_cap).map_err(|_| {
1492 AppError::LimitExceeded(format!(
1493 "allocation of {files_cap} process items would exceed available memory"
1494 ))
1495 })?;
1496 let mut truncations: Vec<(String, String)> = Vec::new();
1497 truncations.try_reserve(files_cap).map_err(|_| {
1498 AppError::LimitExceeded(format!(
1499 "allocation of {files_cap} truncation entries would exceed available memory"
1500 ))
1501 })?;
1502
1503 let max_name_length = match args.name_prefix.as_deref() {
1506 Some(prefix) => validate_name_prefix(prefix, args.max_name_length)?,
1507 None => args.max_name_length,
1508 };
1509 for path in &files {
1510 let file_str = path.to_string_lossy().into_owned();
1511 let (derived_base, name_truncated, original_name) =
1512 derive_kebab_name(path, max_name_length);
1513 let original_basename = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
1514
1515 if name_truncated {
1516 if let Some(ref orig) = original_name {
1517 truncations.push((orig.clone(), derived_base.clone()));
1518 }
1519 }
1520
1521 if derived_base.is_empty() {
1522 let orig_filename = if !original_basename.is_empty() {
1524 Some(original_basename.to_string())
1525 } else {
1526 None
1527 };
1528 slots_meta.push(SlotMeta::Skip {
1529 file_str,
1530 derived_base: String::new(),
1531 name_truncated: false,
1532 original_name: None,
1533 original_filename: orig_filename,
1534 reason: "could not derive a non-empty kebab-case name from filename".to_string(),
1535 });
1536 continue;
1537 }
1538
1539 let derived_base = match args.name_prefix.as_deref() {
1543 Some(prefix) => format!("{prefix}{derived_base}"),
1544 None => derived_base,
1545 };
1546
1547 match unique_name(&derived_base, &taken_names) {
1548 Ok(derived_name) => {
1549 taken_names.insert(derived_name.clone());
1550 let idx = slots_meta.len();
1551 let orig_filename = if original_basename != derived_name {
1553 Some(original_basename.to_string())
1554 } else {
1555 None
1556 };
1557 process_items.push(ProcessItem {
1558 idx,
1559 path: path.clone(),
1560 file_str: file_str.clone(),
1561 derived_name: derived_name.clone(),
1562 });
1563 slots_meta.push(SlotMeta::Process {
1564 file_str,
1565 derived_name,
1566 name_truncated,
1567 original_name,
1568 original_filename: orig_filename,
1569 });
1570 }
1571 Err(e) => {
1572 let orig_filename = if original_basename != derived_base {
1573 Some(original_basename.to_string())
1574 } else {
1575 None
1576 };
1577 slots_meta.push(SlotMeta::Skip {
1578 file_str,
1579 derived_base,
1580 name_truncated,
1581 original_name,
1582 original_filename: orig_filename,
1583 reason: e.to_string(),
1584 });
1585 }
1586 }
1587 }
1588
1589 if !truncations.is_empty() {
1590 tracing::info!(
1591 target: "ingest",
1592 count = truncations.len(),
1593 max_name_length = max_name_length,
1594 max_len = DERIVED_NAME_MAX_LEN,
1595 "derived names truncated; pass -vv (debug) for per-file detail"
1596 );
1597 }
1598
1599 if args.dry_run {
1601 for meta in &slots_meta {
1602 match meta {
1603 SlotMeta::Skip {
1604 file_str,
1605 derived_base,
1606 name_truncated,
1607 original_name,
1608 original_filename,
1609 reason,
1610 } => {
1611 output::emit_json_compact(&IngestFileEvent {
1612 file: file_str,
1613 name: derived_base,
1614 status: "skip",
1615 truncated: *name_truncated,
1616 original_name: original_name.clone(),
1617 original_filename: original_filename.as_deref(),
1618 error: Some(reason.clone()),
1619 memory_id: None,
1620 action: None,
1621 body_length: 0,
1622 backend_invoked: None,
1623 })?;
1624 }
1625 SlotMeta::Process {
1626 file_str,
1627 derived_name,
1628 name_truncated,
1629 original_name,
1630 original_filename,
1631 } => {
1632 output::emit_json_compact(&IngestFileEvent {
1633 file: file_str,
1634 name: derived_name,
1635 status: "preview",
1636 truncated: *name_truncated,
1637 original_name: original_name.clone(),
1638 original_filename: original_filename.as_deref(),
1639 error: None,
1640 memory_id: None,
1641 action: None,
1642 body_length: 0,
1643 backend_invoked: None,
1644 })?;
1645
1646 match std::fs::read_to_string(file_str) {
1650 Ok(body) => {
1651 let budget = chunking::assess_body_budget(&body);
1652 output::emit_json_compact(&IngestDryRunBudget {
1653 budget: true,
1654 file: file_str,
1655 name: derived_name,
1656 bytes: budget.bytes,
1657 chunk_count: budget.chunk_count,
1658 token_count: budget.approx_tokens,
1659 partition_count: budget.partition_count,
1660 exceeds_limits: budget.exceeds_limits,
1661 })?;
1662 }
1663 Err(e) => {
1664 tracing::warn!(
1665 target: "ingest",
1666 file = %file_str,
1667 "dry-run: could not read file for budget assessment: {e}"
1668 );
1669 }
1670 }
1671 }
1672 }
1673 }
1674 output::emit_json_compact(&IngestSummary {
1675 summary: true,
1676 dir: args.dir.to_string_lossy().into_owned(),
1677 pattern: args.pattern.clone(),
1678 recursive: args.recursive,
1679 files_total: total,
1680 files_succeeded: 0,
1681 files_failed: 0,
1682 files_skipped: 0,
1683 elapsed_ms: started.elapsed().as_millis() as u64,
1684 })?;
1685 return Ok(());
1686 }
1687
1688 if args.low_memory {
1690 if let Some(n) = args.ingest_parallelism {
1691 if n > 1 {
1692 return Err(AppError::Validation(
1693 "--ingest-parallelism N>1 conflicts with --low-memory; use one or the other"
1694 .to_string(),
1695 ));
1696 }
1697 }
1698 }
1699
1700 let parallelism = resolve_parallelism(args.low_memory, args.ingest_parallelism);
1703
1704 let pool = rayon::ThreadPoolBuilder::new()
1705 .num_threads(parallelism)
1706 .build()
1707 .map_err(|e| AppError::Internal(anyhow::anyhow!("rayon pool: {e}")))?;
1708
1709 if args.enable_ner && args.skip_extraction {
1710 return Err(AppError::Validation(
1711 "--enable-ner and --skip-extraction are mutually exclusive; remove one".to_string(),
1712 ));
1713 }
1714 if args.skip_extraction && !args.enable_ner {
1715 tracing::warn!(
1722 "--skip-extraction is deprecated since v1.0.45 and has no effect (NER is disabled by default); remove this flag to silence the warning"
1723 );
1724 }
1725 let enable_ner = args.enable_ner;
1726 let auto_describe = args.auto_describe && !args.no_auto_describe;
1727 let max_rss_mb = args.max_rss_mb;
1728 let llm_parallelism = args.llm_parallelism as usize;
1729
1730 let total_to_process = process_items.len();
1731 tracing::info!(
1732 target: "ingest",
1733 phase = "pipeline_start",
1734 files = total_to_process,
1735 ingest_parallelism = parallelism,
1736 "incremental pipeline starting: Phase A (rayon) → channel → Phase B (main thread)",
1737 );
1738
1739 let channel_bound = (parallelism * 2).max(1);
1743 let (tx, rx) = mpsc::sync_channel::<(usize, Result<Vec<StagedFile>, AppError>)>(channel_bound);
1744
1745 let paths_owned = paths.clone();
1750 let llm_backend_owned = llm_backend;
1751 let embedding_backend_owned = embedding_backend;
1752 let producer_handle = std::thread::spawn(move || {
1753 pool.install(|| {
1754 process_items.into_par_iter().for_each(|item| {
1755 if crate::shutdown_requested() {
1756 return;
1757 }
1758 let t0 = std::time::Instant::now();
1759 let result = stage_file(
1760 item.idx,
1761 &item.path,
1762 &item.derived_name,
1763 &paths_owned,
1764 enable_ner,
1765 max_rss_mb,
1766 llm_parallelism,
1767 llm_backend_owned,
1768 embedding_backend_owned,
1769 auto_describe,
1770 );
1771 let elapsed_ms = t0.elapsed().as_millis() as u64;
1772
1773 let (n_entities, n_relationships) = match &result {
1776 Ok(parts) => (
1777 parts.iter().map(|sf| sf.entities.len()).sum::<usize>(),
1778 parts.iter().map(|sf| sf.relationships.len()).sum::<usize>(),
1779 ),
1780 Err(_) => (0, 0),
1781 };
1782 let progress = StageProgressEvent {
1783 schema_version: 1,
1784 event: "file_extracted",
1785 path: &item.file_str,
1786 ms: elapsed_ms,
1787 entities: n_entities,
1788 relationships: n_relationships,
1789 };
1790 if let Ok(line) = serde_json::to_string(&progress) {
1791 tracing::info!(target: "ingest_progress", "{}", line);
1792 }
1793
1794 let _ = tx.send((item.idx, result));
1798 });
1799 drop(tx);
1801 });
1802 });
1803
1804 let fail_fast = args.fail_fast;
1816
1817 for meta in &slots_meta {
1819 if let SlotMeta::Skip {
1820 file_str,
1821 derived_base,
1822 name_truncated,
1823 original_name,
1824 original_filename,
1825 reason,
1826 } = meta
1827 {
1828 output::emit_json_compact(&IngestFileEvent {
1829 file: file_str,
1830 name: derived_base,
1831 status: "skipped",
1832 truncated: *name_truncated,
1833 original_name: original_name.clone(),
1834 original_filename: original_filename.as_deref(),
1835 error: Some(reason.clone()),
1836 memory_id: None,
1837 action: None,
1838 body_length: 0,
1839 backend_invoked: None,
1840 })?;
1841 skipped += 1;
1842 }
1843 }
1844
1845 let meta_index: std::collections::HashMap<usize, &SlotMeta> = slots_meta
1848 .iter()
1849 .enumerate()
1850 .filter(|(_, m)| matches!(m, SlotMeta::Process { .. }))
1851 .collect();
1852
1853 tracing::info!(
1854 target: "ingest",
1855 phase = "persist_start",
1856 files = total_to_process,
1857 "phase B starting: persisting files incrementally as Phase A completes each one",
1858 );
1859
1860 for (idx, stage_result) in rx {
1864 if crate::shutdown_requested() {
1865 tracing::info!(target: "ingest", "shutdown requested, stopping persistence loop");
1866 break;
1867 }
1868 let meta = meta_index.get(&idx).ok_or_else(|| {
1869 AppError::Internal(anyhow::anyhow!(
1870 "channel idx {idx} has no corresponding Process slot"
1871 ))
1872 })?;
1873 let (file_str, derived_name, name_truncated, original_name, original_filename) = match meta
1874 {
1875 SlotMeta::Process {
1876 file_str,
1877 derived_name,
1878 name_truncated,
1879 original_name,
1880 original_filename,
1881 } => (
1882 file_str,
1883 derived_name,
1884 name_truncated,
1885 original_name,
1886 original_filename,
1887 ),
1888 SlotMeta::Skip { .. } => unreachable!("channel only carries Process results"),
1889 };
1890
1891 let conn = match conn_or_err.as_mut() {
1893 Ok(c) => c,
1894 Err(err_msg) => {
1895 let err_clone = err_msg.clone();
1896 output::emit_json_compact(&IngestFileEvent {
1897 file: file_str,
1898 name: derived_name,
1899 status: "failed",
1900 truncated: *name_truncated,
1901 original_name: original_name.clone(),
1902 original_filename: original_filename.as_deref(),
1903 error: Some(err_clone.clone()),
1904 memory_id: None,
1905 action: None,
1906 body_length: 0,
1907 backend_invoked: None,
1908 })?;
1909 failed += 1;
1910 if fail_fast {
1911 output::emit_json_compact(&IngestSummary {
1912 summary: true,
1913 dir: args.dir.display().to_string(),
1914 pattern: args.pattern.clone(),
1915 recursive: args.recursive,
1916 files_total: total,
1917 files_succeeded: succeeded,
1918 files_failed: failed,
1919 files_skipped: skipped,
1920 elapsed_ms: started.elapsed().as_millis() as u64,
1921 })?;
1922 return Err(AppError::Validation(format!(
1923 "ingest aborted on first failure: {err_clone}"
1924 )));
1925 }
1926 continue;
1927 }
1928 };
1929
1930 match stage_result {
1931 Ok(parts) => {
1932 for staged in parts {
1935 let part_name = staged.name.clone();
1936 match persist_staged(
1937 conn,
1938 &namespace,
1939 &memory_type_str,
1940 staged,
1941 args.force_merge,
1942 ) {
1943 Ok(FileSuccess {
1944 memory_id,
1945 action,
1946 body_length,
1947 backend_invoked: file_backend_invoked,
1948 }) => {
1949 output::emit_json_compact(&IngestFileEvent {
1950 file: file_str,
1951 name: &part_name,
1952 status: "indexed",
1953 truncated: *name_truncated,
1954 original_name: original_name.clone(),
1955 original_filename: original_filename.as_deref(),
1956 error: None,
1957 memory_id: Some(memory_id),
1958 action: Some(action),
1959 body_length,
1960 backend_invoked: file_backend_invoked,
1961 })?;
1962 succeeded += 1;
1963 }
1964 Err(ref e) if matches!(e, AppError::Duplicate(_)) => {
1965 output::emit_json_compact(&IngestFileEvent {
1966 file: file_str,
1967 name: &part_name,
1968 status: "skipped",
1969 truncated: *name_truncated,
1970 original_name: original_name.clone(),
1971 original_filename: original_filename.as_deref(),
1972 error: Some(format!("{e}")),
1973 memory_id: None,
1974 action: Some("duplicate".to_string()),
1975 body_length: 0,
1976 backend_invoked: None,
1977 })?;
1978 skipped += 1;
1979 }
1980 Err(e) => {
1981 let err_msg = format!("{e}");
1982 output::emit_json_compact(&IngestFileEvent {
1983 file: file_str,
1984 name: &part_name,
1985 status: "failed",
1986 truncated: *name_truncated,
1987 original_name: original_name.clone(),
1988 original_filename: original_filename.as_deref(),
1989 error: Some(err_msg.clone()),
1990 memory_id: None,
1991 action: None,
1992 body_length: 0,
1993 backend_invoked: None,
1994 })?;
1995 failed += 1;
1996 if fail_fast {
1997 output::emit_json_compact(&IngestSummary {
1998 summary: true,
1999 dir: args.dir.display().to_string(),
2000 pattern: args.pattern.clone(),
2001 recursive: args.recursive,
2002 files_total: total,
2003 files_succeeded: succeeded,
2004 files_failed: failed,
2005 files_skipped: skipped,
2006 elapsed_ms: started.elapsed().as_millis() as u64,
2007 })?;
2008 return Err(AppError::Validation(format!(
2009 "ingest aborted on first failure: {err_msg}"
2010 )));
2011 }
2012 }
2013 }
2014 }
2015 }
2016 Err(e) => {
2017 let err_msg = format!("{e}");
2018 output::emit_json_compact(&IngestFileEvent {
2019 file: file_str,
2020 name: derived_name,
2021 status: "failed",
2022 truncated: *name_truncated,
2023 original_name: original_name.clone(),
2024 original_filename: original_filename.as_deref(),
2025 error: Some(err_msg.clone()),
2026 memory_id: None,
2027 action: None,
2028 body_length: 0,
2029 backend_invoked: None,
2030 })?;
2031 failed += 1;
2032 if fail_fast {
2033 output::emit_json_compact(&IngestSummary {
2034 summary: true,
2035 dir: args.dir.display().to_string(),
2036 pattern: args.pattern.clone(),
2037 recursive: args.recursive,
2038 files_total: total,
2039 files_succeeded: succeeded,
2040 files_failed: failed,
2041 files_skipped: skipped,
2042 elapsed_ms: started.elapsed().as_millis() as u64,
2043 })?;
2044 return Err(AppError::Validation(format!(
2045 "ingest aborted on first failure: {err_msg}"
2046 )));
2047 }
2048 }
2049 }
2050 }
2051
2052 producer_handle
2054 .join()
2055 .map_err(|_| AppError::Internal(anyhow::anyhow!("ingest producer thread panicked")))?;
2056
2057 if let Ok(ref conn) = conn_or_err {
2058 if succeeded > 0 {
2059 let _ = conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);");
2060 }
2061 }
2062
2063 output::emit_json_compact(&IngestSummary {
2064 summary: true,
2065 dir: args.dir.display().to_string(),
2066 pattern: args.pattern.clone(),
2067 recursive: args.recursive,
2068 files_total: total,
2069 files_succeeded: succeeded,
2070 files_failed: failed,
2071 files_skipped: skipped,
2072 elapsed_ms: started.elapsed().as_millis() as u64,
2073 })?;
2074
2075 if args.enrich_after && succeeded > 0 {
2076 output::emit_json_compact(&serde_json::json!({
2077 "event": "enrich_phase_started",
2078 "operation": "memory-bindings"
2079 }))?;
2080 let enrich_args = super::enrich::EnrichArgs {
2081 operation: Some(super::enrich::EnrichOperation::MemoryBindings),
2082 mode: Some(super::enrich::EnrichMode::ClaudeCode),
2083 limit: None,
2084 target: super::enrich::ReEmbedTarget::Memories,
2085 dry_run: false,
2086 namespace: args.namespace.clone(),
2087 claude_binary: args.claude_binary.clone(),
2088 claude_model: args.claude_model.clone(),
2089 claude_timeout: args.claude_timeout,
2090 codex_binary: args.codex_binary.clone(),
2091 codex_model: args.codex_model.clone(),
2092 codex_timeout: args.codex_timeout,
2093 opencode_binary: args.opencode_binary.clone(),
2094 opencode_model: args.opencode_model.clone(),
2095 opencode_timeout: args.opencode_timeout,
2096 openrouter_model: None,
2097 openrouter_api_key: None,
2098 openrouter_timeout: 300,
2099 openrouter_base_url: None,
2100 db: args.db.clone(),
2101 json: false,
2102 resume: false,
2103 retry_failed: false,
2104 reset_stale_claims: false,
2105 stale_claim_secs: 1800,
2106 max_cost_usd: args.max_cost_usd,
2107 llm_parallelism: args.llm_parallelism as u32,
2108 wait_job_singleton: args.wait_job_singleton,
2109 force_job_singleton: args.force_job_singleton,
2110 names: Vec::new(),
2111 names_file: None,
2112 preflight_check: false,
2113 fallback_mode: None,
2114 rate_limit_buffer: 300,
2115 max_load_check: true,
2116 circuit_breaker_threshold: 5,
2117 preserve_threshold: 0.7,
2118 entity_description_grounding_threshold: 0.12,
2119 force_redescribe: false,
2120 quality_sample: None,
2121 entity_names: Vec::new(),
2122 memory_names: Vec::new(),
2123 anchor_memory: None,
2124 entity_description_domain: "auto".to_string(),
2125 yield_every_n_items: None,
2126 ops_gate: false,
2127 codex_model_validate: true,
2128 codex_model_fallback: None,
2129 min_output_chars: 500,
2130 max_output_chars: 2000,
2131 preserve_check: true,
2132 prompt_template: None,
2133 until_empty: false,
2134 max_runtime: None,
2135 max_attempts: 5,
2136 status: false,
2137 rest_concurrency: None,
2138 list_dead: false,
2141 requeue_dead: false,
2142 prune_dead_orphans: false,
2143 prune_dead_entity_orphans: false,
2144 ignore_backoff: false,
2145 body_extract_graph_only: false,
2146 };
2147 match super::enrich::run(&enrich_args, llm_backend, embedding_backend) {
2148 Ok(()) => {
2149 output::emit_json_compact(&serde_json::json!({
2150 "event": "enrich_phase_completed"
2151 }))?;
2152 }
2153 Err(e) => {
2154 tracing::warn!(error = %e, "enrich --operation memory-bindings failed after ingest");
2155 output::emit_json_compact(&serde_json::json!({
2156 "event": "enrich_phase_failed",
2157 "error": e.to_string()
2158 }))?;
2159 }
2160 }
2161 }
2162
2163 Ok(())
2164}
2165
2166fn init_storage(paths: &AppPaths) -> Result<Connection, AppError> {
2172 ensure_db_ready(paths)?;
2173 let conn = open_rw(&paths.db)?;
2174 Ok(conn)
2175}
2176
2177pub(crate) fn collect_files(
2178 dir: &Path,
2179 pattern: &str,
2180 recursive: bool,
2181 out: &mut Vec<PathBuf>,
2182) -> Result<(), AppError> {
2183 let entries = std::fs::read_dir(dir).map_err(AppError::Io)?;
2184 for entry in entries {
2185 let entry = entry.map_err(AppError::Io)?;
2186 let path = entry.path();
2187 let file_type = entry.file_type().map_err(AppError::Io)?;
2188 if file_type.is_file() {
2189 let name = entry.file_name();
2190 let name_str = name.to_string_lossy();
2191 if matches_pattern(&name_str, pattern) {
2192 out.push(path);
2193 }
2194 } else if file_type.is_dir() && recursive {
2195 collect_files(&path, pattern, recursive, out)?;
2196 }
2197 }
2198 Ok(())
2199}
2200
2201fn matches_pattern(name: &str, pattern: &str) -> bool {
2202 if let Some(suffix) = pattern.strip_prefix('*') {
2203 name.ends_with(suffix)
2204 } else if let Some(prefix) = pattern.strip_suffix('*') {
2205 name.starts_with(prefix)
2206 } else {
2207 name == pattern
2208 }
2209}
2210
2211pub(crate) fn validate_name_prefix(
2228 prefix: &str,
2229 max_name_length: usize,
2230) -> Result<usize, AppError> {
2231 if prefix.is_empty() {
2232 return Err(AppError::Validation(
2233 "--name-prefix cannot be empty".to_string(),
2234 ));
2235 }
2236 let starts_lower = prefix
2237 .chars()
2238 .next()
2239 .is_some_and(|c| c.is_ascii_lowercase());
2240 let all_slug_chars = prefix
2241 .chars()
2242 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
2243 if !starts_lower || !all_slug_chars {
2244 return Err(AppError::Validation(format!(
2245 "--name-prefix '{prefix}' must start with a lowercase letter and contain \
2246 only lowercase letters, digits and hyphens (kebab-case)"
2247 )));
2248 }
2249 let cap = crate::constants::MAX_MEMORY_NAME_LEN;
2250 if prefix.len() >= cap {
2251 return Err(AppError::LimitExceeded(format!(
2252 "--name-prefix is {} chars; prefixed names would exceed the {cap}-char \
2253 name cap (MAX_MEMORY_NAME_LEN)",
2254 prefix.len()
2255 )));
2256 }
2257 Ok(max_name_length.min(cap - prefix.len()))
2258}
2259
2260pub(crate) fn derive_kebab_name(path: &Path, max_len: usize) -> (String, bool, Option<String>) {
2261 let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
2262 let lowered: String = stem
2263 .nfd()
2264 .filter(|c| !unicode_normalization::char::is_combining_mark(*c))
2265 .map(|c| {
2266 if c == '_' || c.is_whitespace() {
2267 '-'
2268 } else {
2269 c
2270 }
2271 })
2272 .map(|c| c.to_ascii_lowercase())
2273 .filter(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '-')
2274 .collect();
2275 let collapsed = collapse_dashes(&lowered);
2276 let trimmed_raw = collapsed.trim_matches('-').to_string();
2277 let trimmed = if trimmed_raw.starts_with(|c: char| c.is_ascii_digit()) {
2279 format!("doc-{trimmed_raw}")
2280 } else {
2281 trimmed_raw
2282 };
2283 if trimmed.len() > max_len {
2284 let truncated = trimmed[..max_len].trim_matches('-').to_string();
2285 tracing::warn!(
2290 target: "ingest",
2291 original = %trimmed,
2292 truncated_to = %truncated,
2293 max_len = max_len,
2294 "derived memory name truncated to fit length cap; collisions will be resolved with numeric suffixes"
2295 );
2296 (truncated, true, Some(trimmed))
2297 } else {
2298 (trimmed, false, None)
2299 }
2300}
2301
2302fn unique_name(base: &str, taken: &BTreeSet<String>) -> Result<String, AppError> {
2315 if !taken.contains(base) {
2316 return Ok(base.to_string());
2317 }
2318 for suffix in 1..=MAX_NAME_COLLISION_SUFFIX {
2319 let candidate = format!("{base}-{suffix}");
2320 if !taken.contains(&candidate) {
2321 tracing::warn!(
2322 target: "ingest",
2323 base = %base,
2324 resolved = %candidate,
2325 suffix,
2326 "memory name collision resolved with numeric suffix"
2327 );
2328 return Ok(candidate);
2329 }
2330 }
2331 Err(AppError::Validation(format!(
2332 "too many name collisions for base '{base}' (>{MAX_NAME_COLLISION_SUFFIX}); rename source files to disambiguate"
2333 )))
2334}
2335
2336fn collapse_dashes(s: &str) -> String {
2337 let mut out = String::with_capacity(s.len());
2338 let mut prev_dash = false;
2339 for c in s.chars() {
2340 if c == '-' {
2341 if !prev_dash {
2342 out.push('-');
2343 }
2344 prev_dash = true;
2345 } else {
2346 out.push(c);
2347 prev_dash = false;
2348 }
2349 }
2350 out
2351}
2352
2353#[cfg(test)]
2354#[path = "ingest_tests.rs"]
2355mod tests;