Skip to main content

sqlite_graphrag/commands/enrich/
args.rs

1//! CLI argument types for the `enrich` subcommand.
2//! Extracted from mod.rs (Wave C1).
3
4use serde::{Deserialize, Serialize};
5use std::path::PathBuf;
6
7use super::DEFAULT_BODY_ENRICH_MAX_CHARS;
8use super::DEFAULT_BODY_ENRICH_MIN_CHARS;
9
10// ---------------------------------------------------------------------------
11// CLI args
12// ---------------------------------------------------------------------------
13
14/// Operation to perform in the `enrich` command.
15#[derive(Debug, Clone, PartialEq, Eq, clap::ValueEnum, Serialize, Deserialize)]
16#[serde(rename_all = "kebab-case")]
17pub enum EnrichOperation {
18    /// Add missing entity/relationship bindings to memories (fully implemented).
19    /// memory-bindings LINKS each memory to the EXISTING entities extracted from
20    /// its body — it does not invent a new graph, it only connects what is missing. Scans
21    /// only UNBOUND memories (those with zero `memory_entities`).
22    MemoryBindings,
23    /// GAP-SG-24/26: additive augmentation — re-run binding extraction over
24    /// memories that are ALREADY bound, filtered by `--names`/`--names-file`, to
25    /// merge newly-discovered entities/relationships WITHOUT removing existing
26    /// links. Requires a name filter (refuses to re-scan the whole namespace).
27    AugmentBindings,
28    /// Fill NULL/empty entity descriptions with LLM-generated summaries (fully implemented).
29    EntityDescriptions,
30    /// Expand short memory bodies into richer content (fully implemented, GAP-18).
31    BodyEnrich,
32    /// Rebuild missing memory embeddings without rewriting the memory body.
33    ReEmbed,
34    /// Calibrate relationship weights using LLM analysis (fully implemented; persists weight).
35    WeightCalibrate,
36    /// Reclassify relationship types using LLM judgment (fully implemented; persists relation).
37    RelationReclassify,
38    /// Connect isolated entities by suggesting and persisting new relationships (fully implemented).
39    EntityConnect,
40    /// Validate entity type assignments using LLM judgment (fully implemented; persists type).
41    EntityTypeValidate,
42    /// Enrich memory descriptions that are generic/auto-generated (fully implemented; persists description).
43    DescriptionEnrich,
44    /// Identify cross-domain bridges between disconnected subgraphs.
45    /// Shares the O(k) pair scan + `entity_connect_seen` drain path with
46    /// `entity-connect` (v1.1.04+ / v1.1.06); status backlog proxy remains 0.
47    CrossDomainBridges,
48    /// Classify memories into domain categories (fully implemented; persists metadata).
49    DomainClassify,
50    /// Audit the graph for quality issues (scan/report; does not mutate graph structure).
51    GraphAudit,
52    /// Synthesize deep-research findings into graph memories (fully implemented when bindings persist).
53    DeepResearchSynth,
54    /// Extract structured body from unstructured text (fully implemented; persists body).
55    BodyExtract,
56}
57
58/// v1.1.1 (P2): which embedding table the `re-embed` operation backfills.
59///
60/// `memories` is the historical behaviour (and the default, so existing
61/// invocations are unchanged); `entities` and `chunks` close the retroactive
62/// coverage gap for `entity_embeddings` / `chunk_embeddings`; `all` runs the
63/// three scans in one invocation.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum, Serialize, Deserialize)]
65#[serde(rename_all = "kebab-case")]
66pub enum ReEmbedTarget {
67    /// Memories without a live vector in `memory_embeddings` (default).
68    Memories,
69    /// Entities without a live vector in `entity_embeddings`.
70    Entities,
71    /// Chunks without a live vector in `chunk_embeddings`.
72    Chunks,
73    /// All three targets in a single run.
74    All,
75}
76
77/// LLM provider for enrichment.
78#[derive(Debug, Clone, PartialEq, Eq, clap::ValueEnum)]
79pub enum EnrichMode {
80    /// Use locally installed Claude Code CLI (OAuth-first).
81    ClaudeCode,
82    /// Use locally installed OpenAI Codex CLI.
83    Codex,
84    /// Use locally installed OpenCode CLI.
85    #[value(name = "opencode")]
86    Opencode,
87    /// Use the OpenRouter chat-completions REST API (no local CLI; v1.0.95).
88    #[value(name = "openrouter")]
89    OpenRouter,
90}
91
92impl std::fmt::Display for EnrichMode {
93    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94        match self {
95            EnrichMode::ClaudeCode => write!(f, "claude-code"),
96            EnrichMode::Codex => write!(f, "codex"),
97            EnrichMode::Opencode => write!(f, "opencode"),
98            EnrichMode::OpenRouter => write!(f, "openrouter"),
99        }
100    }
101}
102
103/// Arguments for the `enrich` subcommand.
104#[derive(clap::Args)]
105#[command(
106    about = "Enrich graph memories and entities using an LLM provider",
107    after_long_help = "EXAMPLES:\n  \
108    # Add missing entity bindings to all unbound memories\n  \
109    sqlite-graphrag enrich --operation memory-bindings --mode codex --codex-model gpt-5.4-mini\n\n  \
110    # Fill entity descriptions (dry-run preview, no tokens spent)\n  \
111    sqlite-graphrag enrich --operation entity-descriptions --dry-run --json\n\n  \
112    # Expand short memory bodies (GAP-18)\n  \
113    sqlite-graphrag enrich --operation body-enrich --min-output-chars 600\n\n  \
114    # Rebuild only missing memory embeddings without rewriting bodies\n  \
115    sqlite-graphrag enrich --operation re-embed --limit 100\n\n  \
116    # Resume an interrupted body-enrich run\n  \
117    sqlite-graphrag enrich --operation body-enrich --resume --json\n\n  \
118    # Retry only failed items from a previous run\n  \
119    sqlite-graphrag enrich --operation memory-bindings --retry-failed --json\n\n  \
120    # Converge the whole backlog (internal scan+drain loop, no bash wrapper)\n  \
121    sqlite-graphrag enrich --operation memory-bindings --mode openrouter \\\n    \
122      --openrouter-model deepseek/deepseek-v4-flash:nitro --until-empty --max-runtime 600\n\n  \
123    # Inspect / resurrect dead-letter items\n  \
124    sqlite-graphrag enrich --operation memory-bindings --list-dead\n  \
125    sqlite-graphrag enrich --operation memory-bindings --requeue-dead\n\n  \
126    # Read-only status (no LLM, no singleton)\n  \
127    sqlite-graphrag enrich --operation memory-bindings --status\n\n\
128    OPERATIONS NOTE:\n  \
129    memory-bindings LINKS each memory to the EXISTING entities extracted from its\n  \
130    body — it does not invent a new graph, it connects what is missing. It scans\n  \
131    only UNBOUND memories. To re-run extraction over ALREADY-bound memories and\n  \
132    MERGE newly-found entities/relationships additively (without removing links),\n  \
133    use --operation augment-bindings with --names/--names-file.\n\n\
134    DEAD-LETTER SIDECAR (.enrich-queue.sqlite):\n  \
135    A SQLite sidecar tracks each work item across runs. Schema (table `queue`):\n  \
136    item_key (UNIQUE name/id), item_type (memory|entity), operation, memory_id,\n  \
137    status (pending|processing|done|skipped|dead), attempt, error, error_class,\n  \
138    next_retry_at (backoff cooldown). --until-empty loops scan→drain internally\n  \
139    until eligible items are exhausted; transient failures (incl. malformed/non-\n  \
140    JSON LLM output, GAP-SG-09) reschedule with backoff until --max-attempts, then\n  \
141    land in status='dead'. Use --status to see the queue, --list-dead to inspect\n  \
142    the sink, --requeue-dead to retry it, and --ignore-backoff to skip cooldowns.\n  \
143    --names/--names-file also remedy a cooldown by targeting a specific subset.\n\n\
144    EXIT CODES:\n  \
145    0  success\n  \
146    1  validation error (bad args, binary not found)\n  \
147    14 I/O error"
148)]
149#[derive(Clone)]
150pub struct EnrichArgs {
151    /// Enrichment operation to run. Required for write operations; optional for
152    /// the read-only queue inspectors (`--status` / `--list-dead` /
153    /// `--requeue-dead`), where it defaults to `memory-bindings` when omitted
154    /// (GAP-SG-31).
155    #[arg(
156        long,
157        short = 'o',
158        value_enum,
159        value_name = "OPERATION",
160        // GAP-CLI-DRY-01: dry-run is read-only (no LLM); still needs --operation.
161        // R-AN-01: --print-schema exits before any work.
162        required_unless_present_any = ["status", "list_dead", "requeue_dead", "list_skipped", "requeue_skipped", "prune_dead_orphans", "prune_dead_entity_orphans", "print_schema"]
163    )]
164    pub operation: Option<EnrichOperation>,
165
166    /// LLM provider to use. Required for write operations; not needed for the
167    /// read-only queue inspectors (`--status` / `--list-dead` /
168    /// `--requeue-dead` / `--list-skipped` / `--requeue-skipped`), which never
169    /// call the LLM (GAP-SG-31).
170    ///
171    /// GAP-CLI-DRY-01 (v1.1.8): also optional for `--dry-run` (preview only).
172    #[arg(
173        long,
174        value_enum,
175        required_unless_present_any = ["status", "list_dead", "requeue_dead", "list_skipped", "requeue_skipped", "prune_dead_orphans", "prune_dead_entity_orphans", "dry_run", "print_schema"]
176    )]
177    pub mode: Option<EnrichMode>,
178
179    /// Maximum number of items to process in this run. Omit for all.
180    #[arg(long, value_name = "N")]
181    pub limit: Option<usize>,
182
183    /// v1.1.1 (P2): embedding table backfilled by `--operation re-embed`.
184    /// `memories` (default) preserves the historical behaviour; `entities`
185    /// and `chunks` rebuild `entity_embeddings` / `chunk_embeddings`; `all`
186    /// covers the three tables in one run. The scan also selects rows whose
187    /// stored `dim` diverges from the configured `--embedding-dim` (P10),
188    /// so legacy-dimension vectors are regenerated, not only missing ones.
189    /// Ignored (rejected) for every other operation.
190    #[arg(long, value_enum, value_name = "TARGET", default_value_t = ReEmbedTarget::Memories)]
191    pub target: ReEmbedTarget,
192
193    /// Preview items without calling the LLM (zero tokens consumed).
194    #[arg(long)]
195    pub dry_run: bool,
196
197    /// Namespace to operate on. Default: global.
198    #[arg(long)]
199    pub namespace: Option<String>,
200
201    // -- Provider flags (Claude) --
202    /// Path to the Claude Code binary. Default: auto-detect from PATH.
203    #[arg(long, value_name = "PATH")]
204    pub claude_binary: Option<PathBuf>,
205
206    /// Claude model to use (e.g. claude-sonnet-4-6).
207    #[arg(long, value_name = "MODEL")]
208    pub claude_model: Option<String>,
209
210    /// Timeout per item in seconds when using Claude Code. Default: 300.
211    #[arg(long, value_name = "SECONDS", default_value_t = 300)]
212    pub claude_timeout: u64,
213
214    // -- Provider flags (Codex) --
215    /// Path to the Codex CLI binary. Default: auto-detect from PATH.
216    #[arg(long, value_name = "PATH")]
217    pub codex_binary: Option<PathBuf>,
218
219    /// Codex model to use (e.g. o4-mini).
220    #[arg(long, value_name = "MODEL")]
221    pub codex_model: Option<String>,
222
223    /// Timeout per item in seconds when using Codex. Default: 300.
224    #[arg(long, value_name = "SECONDS", default_value_t = 300)]
225    pub codex_timeout: u64,
226
227    // -- Provider flags (OpenCode) --
228    /// Path to the OpenCode binary. Default: auto-detect from PATH.
229    #[arg(long, value_name = "PATH")]
230    pub opencode_binary: Option<PathBuf>,
231
232    /// OpenCode model to use.
233    #[arg(long, value_name = "MODEL")]
234    pub opencode_model: Option<String>,
235
236    /// Timeout per item in seconds when using OpenCode. Default: 300.
237    #[arg(long, value_name = "SECONDS", default_value_t = 300)]
238    pub opencode_timeout: u64,
239
240    // -- Provider flags (OpenRouter, v1.0.95) --
241    /// OpenRouter text model to use (REQUIRED with --mode openrouter; no default).
242    #[arg(long, value_name = "MODEL")]
243    pub openrouter_model: Option<String>,
244
245    /// OpenRouter API key. Falls back to OPENROUTER_API_KEY env or stored config.
246    #[arg(long, value_name = "KEY", hide_env_values = true)]
247    pub openrouter_api_key: Option<String>,
248
249    /// Timeout per item in seconds when using OpenRouter. Default: 600.
250    ///
251    /// GAP-SG-17: raised from 300 to 600 because dense bodies (close to the
252    /// ~32K-token context ceiling of the configured model) routinely take
253    /// longer than five minutes to generate via `deepseek-v4-flash:nitro`.
254    /// Raise it further for very large corpora; lower it for short snippets.
255    #[arg(long, value_name = "SECONDS", default_value_t = 600)]
256    pub openrouter_timeout: u64,
257
258    /// Optional OpenRouter base URL override (reserved; defaults to the public API).
259    #[arg(long, value_name = "URL")]
260    pub openrouter_base_url: Option<String>,
261
262    // -- Cost controls --
263    /// Abort when cumulative cost exceeds this USD budget (API key only; ignored for OAuth).
264    #[arg(long, value_name = "USD")]
265    pub max_cost_usd: Option<f64>,
266
267    // -- Queue controls --
268    /// Resume a previously interrupted run (skip already-done items).
269    #[arg(long)]
270    pub resume: bool,
271
272    /// Retry only items that failed in a previous run.
273    #[arg(long)]
274    pub retry_failed: bool,
275
276    /// v1.1.2 (Bug 4): reset `processing` rows whose `claimed_at` is older than
277    /// `--stale-claim-secs` (default 1800s = 30 min) back to `pending`, then exit.
278    /// Recover rows orphaned by a kill -9 mid-enrich without a full re-scan.
279    #[arg(long)]
280    pub reset_stale_claims: bool,
281
282    /// v1.1.2 (Bug 4): age threshold (seconds) for `--reset-stale-claims` and
283    /// the run-startup stale sweep. Default 1800 (30 min).
284    #[arg(long, value_name = "SECONDS", default_value_t = 1800)]
285    pub stale_claim_secs: u64,
286
287    /// GAP-ENRICH-BACKLOG-CONVERGE: loop scan→drain internally until the queue
288    /// empties of eligible items or --max-runtime elapses; removes the need for
289    /// an external bash retry loop.
290    #[arg(long)]
291    pub until_empty: bool,
292
293    /// GAP-ENRICH-BACKLOG-CONVERGE: wall-clock ceiling in seconds for
294    /// --until-empty. Defaults to 3600 when omitted.
295    #[arg(long, value_name = "SECONDS")]
296    pub max_runtime: Option<u64>,
297
298    /// GAP-ENRICH-BACKLOG-CONVERGE: attempts per item before it becomes a
299    /// dead-letter (status='dead'). Range 1..=20. Default 8.
300    ///
301    /// GAP-SG-21: the default was raised from 5 to 8 because GAP-SG-09 now
302    /// reclassifies malformed / non-JSON LLM output as TRANSIENT (retryable)
303    /// rather than a permanent HardFailure. A flaky structured-output model
304    /// (e.g. deepseek-v4-flash:nitro) may emit several bad generations in a row
305    /// even after JSON repair (GAP-SG-10) recovers most of them; the extra
306    /// attempts give the backlog room to converge before an item is parked in
307    /// the dead-letter sink. Permanent faults (ProviderError, NotFound) still
308    /// dead-letter on the first attempt regardless of this value.
309    #[arg(long, value_name = "N", default_value_t = 8, value_parser = clap::value_parser!(u32).range(1..=20))]
310    pub max_attempts: u32,
311
312    /// GAP-ENRICH-BACKLOG-CONVERGE: read-only mode — report queue and backlog
313    /// counts without calling the LLM or acquiring the singleton.
314    ///
315    /// Field semantics (v1.1.03 clarification):
316    /// - `scan_backlog` = candidates a fresh scan WOULD select from the database
317    ///   (REAL pending work, same WHERE predicate as the scanners).
318    /// - `queue_pending` = a COMPUTED COUNT over the sidecar queue, NOT a physical
319    ///   queue of rows to process — it stays non-zero even after a clean drain.
320    /// - `eligible_now == 0` WITH `queue_pending > 0` means COOLDOWN (rate-limit
321    ///   backoff), NOT a deadlock: items are parked on `next_retry_at`.
322    /// - `eligible_now > 0` stuck against `state: "draining"` IS a deadlock —
323    ///   stale `processing` claims hold the state. Run `--reset-stale-claims`
324    ///   to clear processing claims older than the threshold.
325    #[arg(long)]
326    pub status: bool,
327
328    /// GAP-SG-23: list every dead-letter item (status='dead') for the current
329    /// operation with its error_class, attempt count and last error message.
330    /// Read-only — no LLM, no singleton. Use it to inspect what `--requeue-dead`
331    /// would resurrect before running it.
332    #[arg(long)]
333    pub list_dead: bool,
334
335    /// GAP-SG-11/14: resurrect dead-letter items — move every `status='dead'`
336    /// row back to `pending`, zeroing `attempt`, `next_retry_at`, `error` and
337    /// `error_class`. Distinct from `--retry-failed`, which only resets the
338    /// legacy `status='failed'` rows; dead-letter rows are the terminal sink of
339    /// the v1.0.96 converge loop and are never re-selected without this flag.
340    /// No LLM call or singleton is taken — it only rewrites queue statuses.
341    #[arg(long)]
342    pub requeue_dead: bool,
343
344    /// GAP-SG-96 / G-PR-4: list every `status='skipped'` row for the operation
345    /// (preservation_failed / veto sink). Read-only — no LLM, no singleton.
346    #[arg(long)]
347    pub list_skipped: bool,
348
349    /// GAP-SG-96 / G-PR-3/4: resurrect skipped items — move every
350    /// `status='skipped'` row back to `pending`, zeroing attempt/backoff so a
351    /// lower grounding threshold or corpus fix can re-process them without SQL.
352    #[arg(long)]
353    pub requeue_skipped: bool,
354
355    /// GAP-SG-66: prune ORPHAN dead-letter rows — remove every `status='dead'`
356    /// memory row whose `item_key` (the memory name) no longer exists in the
357    /// main DB for this namespace. These are terminal "not found" failures that
358    /// `--requeue-dead` can never recover (re-processing re-fails the same way),
359    /// so they inflate `queue_dead` forever. Read-only on the main DB; deletes
360    /// only confirmed-orphan rows from the queue sidecar. Entity-keyed dead rows
361    /// are left untouched. No LLM, no singleton — like `--list-dead`.
362    #[arg(long)]
363    pub prune_dead_orphans: bool,
364
365    /// v1.1.2: prune dead ENTITY orphan rows — remove every `status='dead'`
366    /// `item_type='entity'` row from the queue sidecar. Distinct from
367    /// `--prune-dead-orphans` (memory-keyed, consults the main DB): entity dead
368    /// rows are terminal artifacts of re-extraction and have no recovery path,
369    /// so no main-DB check is needed. Required because the v1.1.1 re-embed bug
370    /// left 14680 entity-keyed dead-letter rows that the memory-scoped pruner
371    /// cannot reach. No LLM, no singleton — like `--list-dead`.
372    #[arg(long, conflicts_with = "prune_dead_orphans")]
373    pub prune_dead_entity_orphans: bool,
374
375    /// GAP-SG-16: ignore the per-item backoff cooldown (`next_retry_at`) when
376    /// selecting candidates, so items waiting on exponential backoff are
377    /// processed immediately. Use to drain a backlog whose cooldown windows are
378    /// long but the provider has recovered. Without it, `--status` reports such
379    /// items under `waiting` and they are skipped until their `next_retry_at`.
380    #[arg(long)]
381    pub ignore_backoff: bool,
382
383    /// GAP-SG-28: read-only `body-extract` — extract entities/relationships into
384    /// the graph WITHOUT rewriting (or truncating) the memory body. The default
385    /// `body-extract` restructures the stored body in place; with this flag the
386    /// body is left untouched and only graph bindings are persisted (additive,
387    /// via the same upsert path as `memory-bindings`). Ignored for every other
388    /// operation.
389    #[arg(long)]
390    pub body_extract_graph_only: bool,
391
392    /// GAP-ENRICH-BACKLOG-CONVERGE: REST concurrency for --mode openrouter
393    /// (clamp 1..=16, default 8). Distinct from the legacy --llm-parallelism.
394    #[arg(long, value_name = "N", value_parser = clap::value_parser!(u32).range(1..=16))]
395    pub rest_concurrency: Option<u32>,
396
397    // -- body-enrich specific flags (GAP-18) --
398    /// Minimum output character count for body-enrich. Default: 500.
399    #[arg(long, value_name = "CHARS", default_value_t = DEFAULT_BODY_ENRICH_MIN_CHARS)]
400    pub min_output_chars: usize,
401
402    /// Maximum output character count for body-enrich. Default: 2000.
403    #[arg(long, value_name = "CHARS", default_value_t = DEFAULT_BODY_ENRICH_MAX_CHARS)]
404    pub max_output_chars: usize,
405
406    /// Check that enriched body preserves all facts from the original (LLM judge). Default: true.
407    #[arg(long, default_value_t = true)]
408    pub preserve_check: bool,
409
410    /// Path to a custom prompt template file for body-enrich.
411    #[arg(long, value_name = "PATH")]
412    pub prompt_template: Option<PathBuf>,
413
414    /// Number of parallel LLM workers (default 1 = serial).
415    /// Each worker claims items atomically from the queue DB via UPDATE...RETURNING.
416    /// Range: 1–32. For 2321 entities, --llm-parallelism 4 reduces wall time ~4×.
417    #[arg(long, default_value_t = 1, value_name = "N", value_parser = clap::value_parser!(u32).range(1..=32))]
418    pub llm_parallelism: u32,
419
420    // -- Output / infra --
421    /// Emit NDJSON output. Always true; flag accepted for compatibility.
422    #[arg(long)]
423    pub json: bool,
424
425    /// Database path override.
426    #[arg(long)]
427    pub db: Option<String>,
428
429    /// G30: poll for the job singleton every second for up to N seconds
430    /// when another invocation holds the lock. Default: 0 (fail fast).
431    #[arg(long, value_name = "SECONDS")]
432    pub wait_job_singleton: Option<u64>,
433
434    /// G30: force acquisition of the singleton lock by removing a stale
435    /// lock file from a previously crashed invocation. Use only when you
436    /// are certain no other `enrich`/`ingest` is running.
437    #[arg(long, default_value_t = false)]
438    pub force_job_singleton: bool,
439
440    /// Select a subset of names to enrich instead of the full candidate set.
441    /// Comma-separated, e.g. `--names a,b,c`. Semantics depend on the
442    /// operation (GAP-CLI-NAMES-01): entity-keyed ops (`entity-descriptions`,
443    /// `entity-connect`, …) treat values as **entity** names; memory-keyed
444    /// ops (`memory-bindings`, `body-enrich`, …) treat values as **memory**
445    /// names. Prefer `--entity-names` / `--memory-names` for an explicit
446    /// namespace. Empty when omitted (processes all candidates).
447    ///
448    /// GAP-SG-18: also a cooldown remedy — when `--status` shows items under
449    /// `waiting` (parked on `next_retry_at` backoff), pass the exact names here
450    /// to re-enqueue and process just that subset on the next run instead of
451    /// waiting for every cooldown to elapse. REQUIRED for `--operation
452    /// augment-bindings`, which refuses to re-scan the whole namespace.
453    #[arg(long, value_name = "NAMES", value_delimiter = ',')]
454    pub names: Vec<String>,
455
456    /// G37: read the subset of memory names from a file (one per line).
457    /// Lines starting with `#` and empty lines are ignored. Combined with
458    /// `--names` (union) when both are set.
459    #[arg(long, value_name = "PATH")]
460    pub names_file: Option<PathBuf>,
461
462    /// G35: probe the LLM provider with a 1-turn ping before processing
463    /// the batch. Aborts with a clear error if the rate-limit window is
464    /// closed (avoids burning N turns only to fail on item 1).
465    #[arg(long, default_value_t = false)]
466    pub preflight_check: bool,
467
468    /// G35: if a preflight probe or in-flight call hits the Claude rate
469    /// limit, fall back to `--fallback-mode` (typically `codex`) instead
470    /// of failing the batch. Ignored when `--mode` is already `codex`.
471    #[arg(long, value_enum)]
472    pub fallback_mode: Option<EnrichMode>,
473
474    /// G35: number of seconds before the OAuth rate-limit reset at which
475    /// the preflight probe should refuse to start. Default 300 (5 min).
476    #[arg(long, value_name = "SECONDS", default_value_t = 300)]
477    pub rate_limit_buffer: u64,
478
479    /// G28-D: refuse to start when the 1-minute load average exceeds
480    /// `2 × ncpus` (or XDG `enrich.max_load_per_ncpu` if set).
481    /// Set to false to skip the check on contended CI runners.
482    #[arg(long, default_value_t = true)]
483    pub max_load_check: bool,
484
485    /// G28-D: when the system is saturated, abort the job after this
486    /// many consecutive HardFailure outcomes. Default 5.
487    #[arg(long, value_name = "N", default_value_t = 5)]
488    pub circuit_breaker_threshold: u32,
489
490    /// G29 Step 4: minimum trigram-Jaccard similarity between the
491    /// original body and the LLM-rewritten body for the rewrite to be
492    /// accepted. Scores below the threshold are rejected and emitted as
493    /// `EnrichItemResult::PreservationFailed`. Default 0.7 (per the G29
494    /// gap specification). Ignored when `--operation` is not
495    /// `body-enrich`.
496    #[arg(long, value_name = "FLOAT", default_value_t = 0.7)]
497    pub preserve_threshold: f64,
498
499    /// GAP-CLI-ED-03: minimum grounding coverage of an entity description
500    /// against linked memory bodies. Uses trigram coverage (`|A∩B|/|A|`),
501    /// not symmetric Jaccard, because descriptions are short. Default 0.12.
502    /// Set to 0 to use the compiled default. Only applies to
503    /// `entity-descriptions`.
504    #[arg(long, value_name = "FLOAT", default_value_t = 0.12)]
505    pub entity_description_grounding_threshold: f64,
506
507    /// GAP-CLI-ED-06 / CAPA-B: re-scan entities whose description is empty OR
508    /// matches high-precision low-quality compound markers (e.g. "is a software
509    /// component", "is a configuration file" — not bare domain phrases).
510    /// Once per invocation, reopens matching `skipped`/`done` queue rows for
511    /// those scan keys to `pending` (does not reopen `dead`; use
512    /// `--requeue-dead`). Default false (write-once for non-empty descriptions).
513    #[arg(long, default_value_t = false)]
514    pub force_redescribe: bool,
515
516    /// Wave 2 / GAP-CLI-OBS-04: sample N entities with descriptions and report
517    /// `quality_pct` / `scan_backlog_low_grounding_est` on `--status`.
518    /// `scan_backlog_low_grounding_est` is a **sample-based estimate only** —
519    /// it is not a drain backlog and is not processed by `--force-redescribe`.
520    /// Precedence: this flag > XDG `enrich.entity_description.quality_sample`
521    /// > default 50. Set 0 to disable sampling.
522    #[arg(long, value_name = "N")]
523    pub quality_sample: Option<usize>,
524
525    /// GAP-CLI-NAMES-02: explicit entity name filter (entity-descriptions,
526    /// entity-connect, …). Alias of `--names` for entity-keyed ops.
527    #[arg(long, value_name = "NAMES", value_delimiter = ',')]
528    pub entity_names: Vec<String>,
529
530    /// GAP-CLI-NAMES-02: explicit memory name filter (memory-bindings,
531    /// body-enrich, …). Alias of `--names` for memory-keyed ops.
532    #[arg(long, value_name = "NAMES", value_delimiter = ',')]
533    pub memory_names: Vec<String>,
534
535    /// GAP-CLI-EC-03: limit entity-connect pair scan to the subgraph of
536    /// entities linked to this memory name.
537    #[arg(long, value_name = "NAME")]
538    pub anchor_memory: Option<String>,
539
540    /// GAP-CLI-ED-05: optional domain label for entity-descriptions
541    /// (`auto` = no hint, `none` = force neutral, or free-form e.g. `fiscal`).
542    /// Precedence: this flag > XDG `enrich.entity_description.domain` > `auto`.
543    #[arg(long, value_name = "LABEL", default_value = "auto")]
544    pub entity_description_domain: String,
545
546    /// GAP-CLI-PRIO-05: cooperative yield every N processed items so hot-set
547    /// entity-descriptions can preempt long entity-connect drains. 0 disables.
548    /// XDG key: `enrich.yield_every_n_items` (default 10).
549    #[arg(long, value_name = "N")]
550    pub yield_every_n_items: Option<usize>,
551
552    /// GAP-CLI-OBS-02 / PRIO-04: run gate ops in order
553    /// memory-bindings → entity-descriptions (before entity-connect).
554    #[arg(long, default_value_t = false)]
555    pub ops_gate: bool,
556
557    /// G33 Step 3: when set, validate `--codex-model` against the
558    /// ChatGPT Pro OAuth accepted-model list and abort with a
559    /// suggestion when the value is unknown. Default true (fail fast
560    /// to avoid burning OAuth turns). Set to false to opt out.
561    #[arg(long, default_value_t = true)]
562    pub codex_model_validate: bool,
563
564    /// G33 Step 3: when set together with an invalid `--codex-model`,
565    /// automatically substitute the supplied default (e.g. `gpt-5.5`)
566    /// instead of aborting. The substitution is recorded in the NDJSON
567    /// stream as `provider_substituted: true` for traceability.
568    #[arg(long, value_name = "MODEL")]
569    pub codex_model_fallback: Option<String>,
570
571    /// Emit the JSON Schema for `enrich --status` stdout and exit 0 without
572    /// opening the database or calling the LLM (agent-native R-AN-01).
573    #[arg(
574        long,
575        default_value_t = false,
576        help = "Print JSON Schema for enrich --status output and exit"
577    )]
578    pub print_schema: bool,
579}
580
581impl EnrichArgs {
582    /// GAP-SG-31: resolved enrichment operation.
583    ///
584    /// `operation` is `Option` so the read-only queue inspectors
585    /// (`--status` / `--list-dead` / `--requeue-dead`) can run without it.
586    /// Write paths always carry a value (enforced by
587    /// `required_unless_present_any` at parse time); the read-only paths fall
588    /// back to `memory-bindings`, the most common queue, when it is omitted.
589    pub(crate) fn operation(&self) -> EnrichOperation {
590        self.operation
591            .clone()
592            .unwrap_or(EnrichOperation::MemoryBindings)
593    }
594
595    /// GAP-SG-31: resolved LLM provider. `mode` is `Option` for the read-only
596    /// inspectors that never call the LLM; write paths always carry a value
597    /// (enforced by `required_unless_present_any`). The fallback is only ever
598    /// observed by read-only code that does not actually invoke the provider.
599    pub(crate) fn mode(&self) -> EnrichMode {
600        self.mode.clone().unwrap_or(EnrichMode::OpenRouter)
601    }
602}