Skip to main content

sqlite_graphrag/commands/
ingest.rs

1//! Handler for the `ingest` CLI subcommand.
2//!
3//! Bulk-ingests every file under a directory that matches a glob pattern.
4//! Each matched file is persisted as a separate memory using the same
5//! validation, chunking, embedding and persistence pipeline as `remember`,
6//! but executed in-process so the ONNX model is loaded only once per
7//! invocation. This is the v1.0.32 Onda 4B (finding A2) refactor that
8//! replaced a fork-spawn-per-file pipeline (every file paid the ~17s ONNX
9//! cold-start cost) with an in-process loop reusing the warm embedder
10//! (daemon when available, in-process `Embedder::new` otherwise).
11//!
12//! Memory names are derived from file basenames (kebab-case, lowercase,
13//! ASCII alphanumerics + hyphens). Output is line-delimited JSON: one
14//! object per processed file (success or error), followed by a final
15//! summary object. Designed for streaming consumption by agents.
16//!
17//! ## Incremental pipeline (v1.0.43)
18//!
19//! Phase A runs on a rayon thread pool (size = `--ingest-parallelism`):
20//! read + chunk + embed + NER per file. Results are sent immediately via a
21//! bounded `mpsc::sync_channel` to Phase B so persistence starts as soon
22//! as the first file completes — no waiting for all files to finish Phase A.
23//!
24//! Phase B runs on the main thread: receives staged files from the channel,
25//! writes to SQLite per-file (WAL absorbs individual commits), and emits
26//! NDJSON progress events to stderr as each file is persisted. `Connection`
27//! is not `Sync` so it never crosses thread boundaries.
28//!
29//! This fixes B1: with the old 2-phase design, a 50-file corpus with 27s/file
30//! NER would spend ~22min in Phase A alone, exceeding the user's 900s timeout
31//! before Phase B (and any DB writes) could begin. With this pipeline, the
32//! first file is committed within seconds of starting.
33
34use 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
56/// Hard cap on the numeric suffix appended for collision resolution. If 1000
57/// candidates collide we surface an error rather than loop forever.
58const 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    /// Directory containing files to ingest.
92    #[arg(
93        value_name = "DIR",
94        help = "Directory to ingest recursively (each matching file becomes a memory)"
95    )]
96    pub dir: PathBuf,
97
98    /// Memory type stored in `memories.type` for every ingested file. Defaults to `document`.
99    #[arg(long, value_enum, default_value_t = MemoryType::Document)]
100    pub r#type: MemoryType,
101
102    /// Glob pattern matched against file basenames (default: `*.md`). Supports
103    /// `*.<ext>`, `<prefix>*`, and exact filename match.
104    #[arg(long, default_value = "*.md")]
105    pub pattern: String,
106
107    /// Recurse into subdirectories.
108    #[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    /// GAP-E2E-011: generates a heuristic description from the first meaningful
123    /// line of the body, instead of "ingested from `<path>`". When
124    /// `--no-auto-describe` is passed, keeps the legacy behaviour.
125    #[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    /// Deprecated: NER is now disabled by default. Kept for backwards compatibility.
140    #[arg(long, default_value_t = false, hide = true)]
141    pub skip_extraction: bool,
142
143    /// Stop on first per-file error instead of continuing with the next file.
144    #[arg(long, default_value_t = false)]
145    pub fail_fast: bool,
146
147    /// Preview file-to-name mapping without loading model or persisting.
148    #[arg(long, default_value_t = false)]
149    pub dry_run: bool,
150
151    /// Maximum number of files to ingest (safety cap to prevent runaway ingestion).
152    #[arg(long, default_value_t = 10_000)]
153    pub max_files: usize,
154
155    /// Namespace for the ingested memories.
156    #[arg(long)]
157    pub namespace: Option<String>,
158
159    /// Database path. Falls back to XDG `db.default_path`, then `./graphrag.sqlite`.
160    #[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    /// Number of files to extract+embed in parallel; default = max(1, cpus/2).min(4).
170    #[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    /// Force single-threaded ingest to reduce RSS pressure.
177    ///
178    /// Equivalent to `--ingest-parallelism 1`, takes precedence over any
179    /// explicit value. Recommended for environments with <4 GB available
180    /// RAM or container/cgroup constraints. Trade-off: 3-4x longer wall
181    /// time. Also available via XDG `ingest.low_memory=1`
182    /// (CLI flag has higher precedence than the env var).
183    #[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    /// Maximum process RSS in MiB; abort if exceeded during embedding.
194    #[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    /// G42/S3 (v1.0.79): maximum simultaneous LLM embedding subprocesses
199    /// PER FILE. Multiplies with --ingest-parallelism (files staged
200    /// concurrently), hence the conservative default of 2. The effective
201    /// value is further bounded by CPU count and available RAM.
202    #[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    /// Maximum character length for derived memory names from file basenames.
208    ///
209    /// Overrides the compile-time `DERIVED_NAME_MAX_LEN` constant (default 60).
210    /// Shorter values leave more headroom for collision suffix resolution.
211    #[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    /// v1.1.1 (P12): kebab-case prefix prepended to every derived memory name,
216    /// AFTER the basename is normalized. Namespaces a corpus inside a shared
217    /// database (e.g. `--name-prefix projx-` yields `projx-<derived>`). The
218    /// derived part's budget shrinks so the final name always respects the
219    /// 80-char name cap. Only supported with `--mode none`.
220    #[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    /// Extraction mode: `none` (body-only, default) or `claude-code`/`codex`/`opencode` (LLM-curated).
228    #[arg(long, value_enum, default_value_t = IngestMode::None)]
229    pub mode: IngestMode,
230
231    /// Explicit path to the Claude Code binary (only with --mode claude-code).
232    #[arg(long)]
233    pub claude_binary: Option<std::path::PathBuf>,
234
235    /// Model override for Claude Code extraction (e.g. claude-sonnet-4-6).
236    #[arg(long)]
237    pub claude_model: Option<String>,
238
239    /// Resume a previously interrupted claude-code ingest from the queue DB.
240    #[arg(long, default_value_t = false)]
241    pub resume: bool,
242
243    /// Retry only failed files from a previous claude-code ingest.
244    #[arg(long, default_value_t = false)]
245    pub retry_failed: bool,
246
247    /// Keep the queue DB (.ingest-queue.sqlite) after completion.
248    #[arg(long, default_value_t = false)]
249    pub keep_queue: bool,
250
251    /// Custom path for the ingest queue DB. Default: alongside the --db database.
252    #[arg(long)]
253    pub queue_db: Option<String>,
254
255    /// Initial wait time in seconds when rate-limited (only with --mode claude-code).
256    #[arg(long, default_value_t = 60)]
257    pub rate_limit_wait: u64,
258
259    /// Maximum cumulative cost in USD before aborting (only with --mode claude-code).
260    #[arg(long)]
261    pub max_cost_usd: Option<f64>,
262
263    /// Timeout in seconds for each claude -p invocation (only with --mode claude-code).
264    #[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    /// Explicit path to the Codex CLI binary (only with --mode codex).
272    #[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    /// Model override for Codex extraction (e.g. o4-mini, gpt-5.1-codex).
279    #[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    /// Timeout in seconds for each codex exec invocation.
286    #[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    /// Path to the `opencode` binary (override PATH lookup, only with --mode opencode).
294    #[arg(long, value_name = "PATH")]
295    pub opencode_binary: Option<PathBuf>,
296
297    /// Model override for OpenCode extraction.
298    #[arg(
299        long,
300        value_name = "MODEL",
301                help = "Model override for OpenCode extraction"
302    )]
303    pub opencode_model: Option<String>,
304
305    /// Timeout in seconds for each opencode run invocation.
306    #[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    /// G30: poll for the job singleton every second for up to N seconds
315    /// when another invocation holds the lock. Default: 0 (fail fast).
316    #[arg(long, value_name = "SECONDS")]
317    pub wait_job_singleton: Option<u64>,
318
319    /// G30: force acquisition of the singleton lock by removing a stale
320    /// lock file from a previously crashed invocation.
321    #[arg(long, default_value_t = false)]
322    pub force_job_singleton: bool,
323
324    /// v1.0.93 (GAP-OR-INGEST): run `enrich --operation memory-bindings`
325    /// after all files are embedded, using the active `--llm-backend`.
326    #[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    /// GAP-SG-54: update existing memories instead of skipping them. Without
334    /// this flag a file whose derived name already exists is reported `skipped`;
335    /// with it the existing memory's body, embedding and chunks are refreshed
336    /// (the `remember --force-merge` update path applied per file).
337    #[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/// Extraction mode for the ingest pipeline.
346#[derive(Clone, Debug, PartialEq, Eq, clap::ValueEnum)]
347pub enum IngestMode {
348    /// Body-only ingestion without entity/relationship extraction (default).
349    None,
350    /// LLM-curated extraction via locally installed Claude Code CLI.
351    ClaudeCode,
352    /// LLM-curated extraction via locally installed OpenAI Codex CLI.
353    Codex,
354    /// LLM-curated extraction via locally installed OpenCode CLI.
355    #[value(name = "opencode")]
356    Opencode,
357}
358
359/// Returns true when the `SQLITE_GRAPHRAG_LOW_MEMORY` env var is set to a
360/// truthy value (`1`, `true`, `yes`, `on`, case-insensitive). Empty or unset
361/// values evaluate to false. Unrecognized non-empty values emit a
362/// `tracing::warn!` and evaluate to false.
363fn env_low_memory_enabled() -> bool {
364    // G-T-XDG-04: XDG setting `ingest.low_memory` (no product env).
365    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
383/// Resolves the effective ingest parallelism honoring `--low-memory` and the
384/// `SQLITE_GRAPHRAG_LOW_MEMORY` env var.
385///
386/// Precedence:
387/// 1. `--low-memory` CLI flag forces parallelism = 1.
388/// 2. `SQLITE_GRAPHRAG_LOW_MEMORY=1` env var forces parallelism = 1.
389/// 3. Explicit `--ingest-parallelism N` (when low-memory is off).
390/// 4. Default heuristic `(cpus/2).clamp(1, 4)`.
391///
392/// When low-memory wins and the user also passed `--ingest-parallelism N>1`,
393/// emits a `tracing::warn!` advertising the override.
394fn 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    /// True when the derived name was truncated to fit `DERIVED_NAME_MAX_LEN`. False otherwise.
440    truncated: bool,
441    /// Original derived name before truncation; only present when `truncated=true`.
442    #[serde(skip_serializing_if = "Option::is_none")]
443    original_name: Option<String>,
444    /// Original file basename (without extension); only present when it differs from `name`.
445    #[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    /// Byte length of the body ingested; 0 when not yet read (e.g. skip or dry-run events).
454    body_length: usize,
455    /// v1.0.84 (ADR-0042): discriminator of the LLM backend that actually
456    /// ran the live embedding. `"claude" | "codex" | "none"`. Absent on
457    /// the wire when `None` (kept for happy-path envelope cleanliness, or
458    /// when the file never reached the embed phase due to duplication/error).
459    #[serde(skip_serializing_if = "Option::is_none")]
460    backend_invoked: Option<&'a str>,
461}
462
463/// GAP-SG-06: per-file budget assessment emitted during `--dry-run` so the
464/// operator sees chunk and token counts (and how many sub-memories an
465/// auto-split would create) before running a real ingest.
466#[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/// Outcome of a successful per-file ingest, used to build the NDJSON event.
492#[derive(Debug)]
493struct FileSuccess {
494    memory_id: i64,
495    action: String,
496    body_length: usize,
497    backend_invoked: Option<&'static str>,
498}
499
500/// NDJSON progress event emitted to stderr after each file completes Phase A.
501/// Schema version 1; consumers should check `schema_version` before parsing.
502#[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
512/// All artefacts pre-computed by Phase A (CPU-bound, runs on rayon thread pool).
513/// Phase B persists these to SQLite on the main thread in submission order.
514struct 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    /// v1.0.84 (ADR-0042): discriminator of the LLM backend that actually
528    /// ran the body embedding. `None` when the parallel batch
529    /// embed_passages_parallel_local fell back to different backends
530    /// across chunks (there is no single stable discriminator).
531    backend_invoked: Option<&'static str>,
532}
533
534/// Phase A worker: reads, chunks, embeds and extracts NER for one file.
535/// Never touches the database — safe to run on any rayon thread.
536// G42/S3 added `llm_parallelism` as the 8th parameter; grouping the
537// stage knobs into a struct is a wider refactor than the surgical
538// scope of v1.0.79 allows.
539#[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    // GAP-SG-04/07: auto-split a body that exceeds the single-memory budgets
606    // (bytes, chunk count, token count) into section-aligned sub-memories so
607    // ingestion never fails on an oversized document. A body that fits returns
608    // a single partition under the original name.
609    // Token-ceiling enforcement for ingest lives in chunking::fits_single_partition
610    // (EMBEDDING_REQUEST_MAX_TOKENS): the auto-split keeps every partition under
611    // the cap, so no edge rejection is needed here (v1.1.02).
612    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
646/// Builds a partition description by appending a `(part i/n)` marker, trimming
647/// the base (on a char boundary) when the marker would push it past
648/// [`crate::constants::MAX_MEMORY_DESCRIPTION_LEN`].
649fn 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/// Stages a single body (one memory) into a [`StagedFile`]: NER extraction,
663/// chunking, embedding and entity embedding. Extracted from `stage_file` so the
664/// GAP-SG-04/07 auto-split path stages each partition independently.
665#[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                // v1.0.76: ExtractionResult.entities is now
687                // Vec<ExtractedEntity>, not Vec<NewEntity>. Convert
688                // via name + type only; start/end offsets are not
689                // carried forward into the storage layer.
690                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                // v1.0.76: relationships are no longer in the
700                // ExtractionResult struct; the LLM backend returns
701                // them in its own payload. The default build is
702                // URL-only extraction.
703                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    // v1.0.84 (ADR-0042): tuple (Vec<f32>, LlmBackendKind) — extrai o
753    // backend que efetivamente rodou para popular `backend_invoked` no
754    // envelope NDJSON por arquivo.
755    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            // v1.1.2 (Gap 2): typed payload rejections are permanent and
767            // must not be swallowed by --skip-embedding-on-failure.
768            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        // G42/S2+S3 (v1.0.79): batched bounded fan-out replaces the
781        // serial per-chunk subprocess loop.
782        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                // v1.0.84 (ADR-0042): batch paralelo não retorna discriminador
813                // único por chamada. Conservadoramente, populamos None aqui.
814                (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    // G42/S2+A4 (v1.0.79): entity names use the short-text batch profile.
830    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    // G56 (v1.0.80): ingest reuses canonical entity names across many
838    // memories (e.g. `sqlite-graphrag`, `claude-code`); the in-process
839    // cache collapses the repeated LLM calls into one per unique text.
840    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
882/// Links the staged entities and relationships to `memory_id` within `tx`.
883/// Shared by the create and `--force-merge` update paths so the graph-binding
884/// logic lives in one place.
885fn 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
954/// Phase B: persists one `StagedFile` to the database on the main thread.
955///
956/// GAP-SG-54: when `force_merge` is true an existing memory with the same name
957/// is UPDATED (body/embedding/chunks/graph refreshed) instead of being rejected
958/// as a duplicate. GAP-SG-55: a memory whose `body_hash` already exists under a
959/// DIFFERENT name is skipped (content-level dedup) so divergent derived names do
960/// not duplicate identical content.
961fn 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            // GAP-SG-54: --force-merge update path. Refresh body, embedding,
1014            // chunks and graph bindings of the existing memory.
1015            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            // Re-index chunks: drop the old slices then re-insert the staged set.
1049            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            // GAP-SG-55: identical content already stored under a different name
1093            // → skip creating a duplicate (reported as `skipped` by the caller).
1094            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
1164// ---------------------------------------------------------------------------
1165// G20: mode-conditional flag validation
1166// ---------------------------------------------------------------------------
1167
1168/// True when a scalar value matches its declared default. Local
1169/// re-declaration (also defined in ) to keep this module
1170/// self-contained for the G20 fix.
1171fn is_at_default<T: PartialEq>(value: T, default: T) -> bool {
1172    value == default
1173}
1174
1175/// G20: validate that flags for one LLM provider were not passed when
1176/// the operator selected a different provider (or no provider). Flags
1177/// silently discarded by the wrong mode are surfaced as
1178///  BEFORE any DB work, so the operator gets
1179/// an actionable error instead of a surprise at runtime.
1180///
1181/// Mode-specific matrices:
1182/// - `mode=none` rejects: claude_binary, claude_model,
1183///   claude_timeout!=300, max_cost_usd, resume, retry_failed, keep_queue,
1184///   codex_binary, codex_model, codex_timeout!=300
1185/// - `mode=claude-code` rejects: codex_binary, codex_model, codex_timeout!=300
1186/// - `mode=codex` rejects: claude_binary, claude_model, claude_timeout!=300,
1187///   max_cost_usd, resume, retry_failed, keep_queue
1188fn 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    // v1.1.1 (P12): --name-prefix is only applied by the local staging path;
1197    // rejecting it under LLM modes avoids a silently unprefixed corpus.
1198    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// ---------------------------------------------------------------------------
1387
1388#[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    // G20: mode-conditional flag validation BEFORE any DB access.
1395    // Surfaces flags that the wrong mode would silently discard.
1396    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    // Pre-resolve all names before parallelisation so Phase A workers see a
1450    // consistent, immutable name assignment (v1.0.31 A10 contract preserved).
1451    let mut taken_names: BTreeSet<String> = BTreeSet::new();
1452
1453    // SlotMeta: per-slot output metadata retained on the main thread for NDJSON.
1454    // ProcessItem: the data moved into the producer thread for Phase A computation.
1455    // We split these so `slots_meta` (non-Send BTreeSet-dependent) stays on main
1456    // thread while `process_items` (Send: only PathBuf + String) crosses the thread
1457    // boundary into the rayon producer.
1458    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    // v1.1.1 (P12): validate the prefix once and shrink the derived-name
1504    // budget so `prefix + derived` always fits MAX_MEMORY_NAME_LEN.
1505    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            // original_filename: always include when it differs from the empty derived name
1523            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        // v1.1.1 (P12): prefix applied AFTER kebab normalization of the
1540        // basename; the shrunken budget above guarantees the final length
1541        // fits MAX_MEMORY_NAME_LEN.
1542        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                // original_filename: present only when the raw basename differs from the derived name
1552                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    // --dry-run: emit preview events and exit before loading ONNX or touching DB.
1600    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                    // GAP-SG-06: report chunk + token counts and how many
1647                    // sub-memories an auto-split would create, so the operator
1648                    // detects chunk/token overflow before a real ingest.
1649                    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    // Reject contradictory flag combination: explicit parallelism > 1 with --low-memory.
1689    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    // Determine rayon thread pool size, honoring --low-memory and the
1701    // SQLITE_GRAPHRAG_LOW_MEMORY env var (both force parallelism = 1).
1702    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        // v1.0.74: revert to v1.0.45 hidden no-op behavior. The v1.0.67
1716        // commit (9ddb17b) promoted this to a hard validation error, which
1717        // broke the "kept as a hidden no-op for backwards compatibility"
1718        // promise documented in CHANGELOG v1.0.45 and started failing
1719        // 5+ CI jobs whose E2E tests use this flag to skip the
1720        // (since-removed) GLiNER-ONNX model download in CI environments.
1721        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    // Bounded channel: producer never gets more than parallelism*2 items ahead of
1740    // the consumer, preventing memory blowup when Phase A is faster than Phase B.
1741    // Each message carries the slot index so Phase B can look up SlotMeta in order.
1742    let channel_bound = (parallelism * 2).max(1);
1743    let (tx, rx) = mpsc::sync_channel::<(usize, Result<Vec<StagedFile>, AppError>)>(channel_bound);
1744
1745    // Phase A: launched in a dedicated OS thread so the main thread can consume
1746    // the channel concurrently. pool.install() blocks the calling thread until
1747    // all rayon workers finish — if called on the main thread it would
1748    // reintroduce the 2-phase blocking behaviour we are eliminating.
1749    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                // Emit NDJSON progress event to stderr so the user sees work
1774                // happening during long NER runs (e.g. 50 files × 27s each).
1775                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                // Blocking send applies backpressure: if Phase B is slower,
1795                // Phase A workers wait here instead of accumulating staged files
1796                // in memory. If the receiver is dropped (fail_fast abort), ignore.
1797                let _ = tx.send((item.idx, result));
1798            });
1799            // Explicit drop of tx signals Phase B (rx iteration) to stop.
1800            drop(tx);
1801        });
1802    });
1803
1804    // Phase B: main thread persists files as results arrive from the channel.
1805    // Results arrive in completion order (par_iter is unordered). We persist
1806    // each file immediately on arrival — this is the key fix for B1: with the
1807    // old 2-phase design the first DB write happened only after ALL files had
1808    // finished Phase A. Now the first commit happens as soon as the first file
1809    // completes Phase A, regardless of how many files remain.
1810    //
1811    // NDJSON output order follows completion order (not file-system sort order).
1812    // Skip slots are emitted at the end, after all Process results are consumed.
1813    // This trade-off is intentional: deterministic NDJSON ordering is a lesser
1814    // requirement than ensuring data is persisted before the user's timeout fires.
1815    let fail_fast = args.fail_fast;
1816
1817    // Emit pending Skip events first so agents see them early.
1818    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    // Build a quick index from slot index → SlotMeta reference for O(1) lookups
1846    // as channel messages arrive in completion order.
1847    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    // Drain channel and persist each file immediately — no accumulation into a
1861    // HashMap. The bounded channel ensures Phase A cannot run too far ahead of
1862    // Phase B without applying backpressure.
1863    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        // If storage init failed, every file fails with the same error.
1892        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                // GAP-SG-04/07: one source file can stage as multiple
1933                // sub-memories (auto-split partitions); persist and report each.
1934                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    // Wait for the producer thread to finish cleanly.
2053    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            // enrich-after runs a plain memory-bindings pass; dead-letter,
2139            // backoff-ignore and graph-only flags stay at their defaults.
2140            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
2166/// Auto-initialises the database (matches the contract of every other CRUD
2167/// handler) and returns a fresh read/write connection ready for the ingest
2168/// loop. Errors here are recoverable per-file: the caller surfaces them as
2169/// failure events so `--fail-fast` and the continue-on-error path keep
2170/// working when, for example, the user points `--db` at an unwritable path.
2171fn 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
2211/// Returns `(final_name, truncated, original_name)`.
2212/// `truncated` is true when the derived name exceeded `max_len`.
2213/// `original_name` holds the pre-truncation name only when `truncated=true`.
2214///
2215/// Non-ASCII characters are first decomposed via NFD and then stripped of
2216/// combining marks so accented letters fold to their base ASCII letter
2217/// (e.g. `acai` from accented input, `naive` from diaeresis). Characters with no ASCII
2218/// fallback (emoji, CJK ideographs, symbols) are dropped silently. This
2219/// preserves meaningful word content rather than collapsing the basename
2220/// to a few stray ASCII letters as the previous filter did.
2221/// v1.1.1 (P12): validates `--name-prefix` and returns the effective budget
2222/// for the DERIVED part of the name, so `prefix + derived` never exceeds
2223/// [`crate::constants::MAX_MEMORY_NAME_LEN`]. The prefix is applied verbatim
2224/// AFTER kebab normalization of the basename, so it must itself be a valid
2225/// slug head: starting with a lowercase letter and containing only
2226/// lowercase letters, digits and hyphens.
2227pub(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    // Prefix names that start with a digit to keep them valid kebab-case identifiers.
2278    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        // GAP-SG-38: warn (not debug) so the operator sees that a derived name
2286        // was cut at the cap and that any collision will be resolved with a
2287        // numeric disambiguation suffix. The pre-truncation form is also
2288        // surfaced per-file via `IngestFileEvent.original_name`.
2289        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
2302/// v1.0.31 A10: returns the first non-colliding kebab name by appending a
2303/// numeric suffix (`-1`, `-2`, …) when needed.
2304///
2305/// `taken` is the set of names already consumed in the current ingest run.
2306/// The caller is expected to insert the returned name into `taken` so the
2307/// next call observes the consumption. Cross-run collisions are intentionally
2308/// surfaced by the per-file persistence path as duplicates so re-ingestion
2309/// of identical corpora stays idempotent.
2310///
2311/// Returns `Err(AppError::Validation)` after `MAX_NAME_COLLISION_SUFFIX`
2312/// candidates collide, signalling a pathological corpus that should be
2313/// renamed manually.
2314fn 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;