1use crate::commands::*;
6use crate::i18n::{current, Language};
7use clap::{Parser, Subcommand};
8
9fn max_concurrency_ceiling() -> usize {
11 std::thread::available_parallelism()
12 .map(|n| n.get() * 2)
13 .unwrap_or(8)
14}
15
16#[derive(Copy, Clone, Debug, PartialEq, Eq, clap::ValueEnum)]
17pub enum GraphExportFormat {
18 Json,
19 Dot,
20 Mermaid,
21 Ndjson,
23}
24
25#[derive(Copy, Clone, Debug, PartialEq, Eq, clap::ValueEnum)]
29pub enum LlmBackendChoice {
30 Auto,
31 Claude,
32 Codex,
33 Opencode,
34 None,
35}
36
37impl LlmBackendChoice {
38 pub fn to_chain(self) -> Vec<crate::embedder::LlmBackendKind> {
48 use crate::embedder::LlmBackendKind;
49 match self {
50 LlmBackendChoice::Codex => vec![LlmBackendKind::Codex, LlmBackendKind::None],
51 LlmBackendChoice::Claude => vec![LlmBackendKind::Claude, LlmBackendKind::None],
52 LlmBackendChoice::Opencode => vec![
53 LlmBackendKind::Opencode,
54 LlmBackendKind::Codex,
55 LlmBackendKind::Claude,
56 LlmBackendKind::None,
57 ],
58 LlmBackendChoice::None => vec![LlmBackendKind::None],
59 LlmBackendChoice::Auto => parse_fallback_chain(
60 &std::env::var("SQLITE_GRAPHRAG_LLM_FALLBACK")
61 .unwrap_or_else(|_| "codex,claude,none".to_string()),
62 ),
63 }
64 }
65}
66
67fn parse_fallback_chain(s: &str) -> Vec<crate::embedder::LlmBackendKind> {
68 use crate::embedder::LlmBackendKind;
69 let mut chain: Vec<LlmBackendKind> = s
70 .split(',')
71 .filter_map(|tok| match tok.trim().to_ascii_lowercase().as_str() {
72 "codex" => Some(LlmBackendKind::Codex),
73 "claude" | "claude-code" => Some(LlmBackendKind::Claude),
74 "opencode" => Some(LlmBackendKind::Opencode),
75 "none" => Some(LlmBackendKind::None),
76 _ => {
77 tracing::warn!(
78 token = tok.trim(),
79 "unknown backend in --llm-fallback, skipping"
80 );
81 Option::None
82 }
83 })
84 .collect();
85 if chain.is_empty() {
86 chain = vec![
87 LlmBackendKind::Codex,
88 LlmBackendKind::Claude,
89 LlmBackendKind::None,
90 ];
91 }
92 chain
93}
94
95#[derive(Parser)]
96#[command(name = "sqlite-graphrag")]
97#[command(version)]
98#[command(about = "Local GraphRAG memory for LLMs in a single SQLite file")]
99#[command(arg_required_else_help = true)]
100pub struct Cli {
101 #[arg(long, global = true, value_name = "N")]
106 pub max_concurrency: Option<usize>,
107
108 #[arg(long, global = true, value_name = "SECONDS")]
113 pub wait_lock: Option<u64>,
114
115 #[arg(long, global = true, hide = true, default_value_t = false)]
119 pub skip_memory_guard: bool,
120
121 #[arg(
130 long,
131 global = true,
132 hide = true,
133 default_value_t = false,
134 value_parser = clap::builder::BoolishValueParser::new(),
135 env = "SQLITE_GRAPHRAG_STRICT_ENV_CLEAR"
136 )]
137 pub strict_env_clear: bool,
138
139 #[arg(
146 long,
147 global = true,
148 hide = true,
149 default_value_t = false,
150 value_parser = clap::builder::BoolishValueParser::new(),
151 env = "SQLITE_GRAPHRAG_DRY_RUN_BACKEND"
152 )]
153 pub dry_run_backend: bool,
154
155 #[arg(long, global = true, value_enum, value_name = "LANG")]
161 pub lang: Option<crate::i18n::Language>,
162
163 #[arg(long, global = true, value_name = "IANA")]
169 pub tz: Option<chrono_tz::Tz>,
170
171 #[arg(short = 'v', long, global = true, action = clap::ArgAction::Count)]
176 pub verbose: u8,
177
178 #[arg(long, global = true, value_name = "KIND", default_value = "llm")]
185 pub extraction_backend: Option<String>,
186
187 #[arg(long, global = true, value_name = "N", value_parser = clap::value_parser!(u64).range(8..=4096))]
195 pub embedding_dim: Option<u64>,
196
197 #[arg(long, global = true, value_enum, default_value_t = LlmBackendChoice::Auto, env = "SQLITE_GRAPHRAG_LLM_BACKEND")]
205 pub llm_backend: LlmBackendChoice,
206
207 #[arg(
211 long,
212 global = true,
213 value_name = "MODEL",
214 env = "SQLITE_GRAPHRAG_LLM_MODEL"
215 )]
216 pub llm_model: Option<String>,
217
218 #[arg(
221 long,
222 global = true,
223 value_name = "PATH",
224 env = "SQLITE_GRAPHRAG_CLAUDE_BINARY"
225 )]
226 pub claude_binary: Option<std::path::PathBuf>,
227
228 #[arg(
231 long,
232 global = true,
233 value_name = "PATH",
234 env = "SQLITE_GRAPHRAG_CODEX_BINARY"
235 )]
236 pub codex_binary: Option<std::path::PathBuf>,
237
238 #[arg(
241 long,
242 global = true,
243 value_name = "PATH",
244 env = "SQLITE_GRAPHRAG_OPENCODE_BINARY"
245 )]
246 pub opencode_binary: Option<std::path::PathBuf>,
247
248 #[arg(
252 long,
253 global = true,
254 default_value = "codex,claude,none",
255 env = "SQLITE_GRAPHRAG_LLM_FALLBACK"
256 )]
257 pub llm_fallback: String,
258
259 #[arg(
264 long,
265 global = true,
266 default_value_t = false,
267 value_parser = clap::builder::BoolishValueParser::new(),
268 env = "SQLITE_GRAPHRAG_SKIP_EMBEDDING_ON_FAILURE"
269 )]
270 pub skip_embedding_on_failure: bool,
271
272 #[arg(
276 long,
277 global = true,
278 value_name = "N",
279 env = "SQLITE_GRAPHRAG_LLM_MAX_HOST_CONCURRENCY"
280 )]
281 pub llm_max_host_concurrency: Option<u32>,
282
283 #[arg(
287 long,
288 global = true,
289 value_name = "SECONDS",
290 env = "SQLITE_GRAPHRAG_LLM_SLOT_WAIT_SECS"
291 )]
292 pub llm_slot_wait_secs: Option<u64>,
293
294 #[arg(
298 long,
299 global = true,
300 default_value_t = false,
301 value_parser = clap::builder::BoolishValueParser::new(),
302 env = "SQLITE_GRAPHRAG_LLM_SLOT_NO_WAIT"
303 )]
304 pub llm_slot_no_wait: bool,
305
306 #[command(subcommand)]
307 pub command: Option<Commands>,
308}
309
310#[cfg(test)]
311mod json_only_format_tests {
312 use super::Cli;
313 use clap::Parser;
314
315 #[test]
316 fn restore_accepts_only_format_json() {
317 assert!(Cli::try_parse_from([
318 "sqlite-graphrag",
319 "restore",
320 "--name",
321 "mem",
322 "--version",
323 "1",
324 "--format",
325 "json",
326 ])
327 .is_ok());
328
329 assert!(Cli::try_parse_from([
330 "sqlite-graphrag",
331 "restore",
332 "--name",
333 "mem",
334 "--version",
335 "1",
336 "--format",
337 "text",
338 ])
339 .is_err());
340 }
341
342 #[test]
343 fn hybrid_search_accepts_only_format_json() {
344 assert!(Cli::try_parse_from([
345 "sqlite-graphrag",
346 "hybrid-search",
347 "query",
348 "--format",
349 "json",
350 ])
351 .is_ok());
352
353 assert!(Cli::try_parse_from([
354 "sqlite-graphrag",
355 "hybrid-search",
356 "query",
357 "--format",
358 "markdown",
359 ])
360 .is_err());
361 }
362
363 #[test]
364 fn remember_recall_rename_vacuum_json_only() {
365 assert!(Cli::try_parse_from([
366 "sqlite-graphrag",
367 "remember",
368 "--name",
369 "mem",
370 "--type",
371 "project",
372 "--description",
373 "desc",
374 "--format",
375 "json",
376 ])
377 .is_ok());
378 assert!(Cli::try_parse_from([
379 "sqlite-graphrag",
380 "remember",
381 "--name",
382 "mem",
383 "--type",
384 "project",
385 "--description",
386 "desc",
387 "--format",
388 "text",
389 ])
390 .is_err());
391
392 assert!(
393 Cli::try_parse_from(["sqlite-graphrag", "recall", "query", "--format", "json",])
394 .is_ok()
395 );
396 assert!(
397 Cli::try_parse_from(["sqlite-graphrag", "recall", "query", "--format", "text",])
398 .is_err()
399 );
400
401 assert!(Cli::try_parse_from([
402 "sqlite-graphrag",
403 "rename",
404 "--name",
405 "old",
406 "--new-name",
407 "new",
408 "--format",
409 "json",
410 ])
411 .is_ok());
412 assert!(Cli::try_parse_from([
413 "sqlite-graphrag",
414 "rename",
415 "--name",
416 "old",
417 "--new-name",
418 "new",
419 "--format",
420 "markdown",
421 ])
422 .is_err());
423
424 assert!(Cli::try_parse_from(["sqlite-graphrag", "vacuum", "--format", "json",]).is_ok());
425 assert!(Cli::try_parse_from(["sqlite-graphrag", "vacuum", "--format", "text",]).is_err());
426 }
427}
428
429impl Cli {
430 pub fn validate_flags(&self) -> Result<(), String> {
435 if let Some(n) = self.max_concurrency {
436 if n == 0 {
437 return Err(match current() {
438 Language::English => "--max-concurrency must be >= 1".to_string(),
439 Language::Portuguese => "--max-concurrency deve ser >= 1".to_string(),
440 });
441 }
442 let teto = max_concurrency_ceiling();
443 if n > teto {
444 return Err(match current() {
445 Language::English => format!(
446 "--max-concurrency {n} exceeds the ceiling of {teto} (2×nCPUs) on this system"
447 ),
448 Language::Portuguese => format!(
449 "--max-concurrency {n} excede o teto de {teto} (2×nCPUs) neste sistema"
450 ),
451 });
452 }
453 }
454 Ok(())
455 }
456}
457
458impl Commands {
459 pub fn is_embedding_heavy(&self) -> bool {
461 matches!(
462 self,
463 Self::Init(_)
464 | Self::Remember(_)
465 | Self::RememberBatch(_)
466 | Self::Recall(_)
467 | Self::HybridSearch(_)
468 | Self::DeepResearch(_)
469 )
470 }
471
472 pub fn uses_cli_slot(&self) -> bool {
473 true
474 }
475}
476
477#[derive(Debug, clap::Args)]
482pub struct CodexModelsArgs {
483 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
485 pub json: bool,
486}
487
488#[derive(Subcommand)]
489pub enum Commands {
490 #[command(after_long_help = "EXAMPLES:\n \
492 # Initialize in current directory (default behavior)\n \
493 sqlite-graphrag init\n\n \
494 # Initialize at a specific path\n \
495 sqlite-graphrag init --db /path/to/graphrag.sqlite\n\n \
496 # Initialize using SQLITE_GRAPHRAG_HOME env var\n \
497 SQLITE_GRAPHRAG_HOME=/data sqlite-graphrag init\n\n\
498 NOTES:\n \
499 - `init` is OPTIONAL: any subsequent CRUD command auto-initializes graphrag.sqlite if missing.\n \
500 - As a side effect, `init` warms a smoke-test embedding via the LLM-only one-shot pipeline.")]
501 Init(init::InitArgs),
502 #[command(after_long_help = "EXAMPLES:\n \
504 # Inline body\n \
505 sqlite-graphrag remember --name onboarding --type user --description \"intro\" --body \"hello\"\n\n \
506 # Body from file\n \
507 sqlite-graphrag remember --name doc1 --type document --description \"...\" --body-file ./README.md\n\n \
508 # Body from stdin (pipe)\n \
509 cat README.md | sqlite-graphrag remember --name doc1 --type document --description \"...\" --body-stdin\n\n \
510 # Enable automatic URL extraction (URL-regex only since v1.0.79; GLiNER removed)\n \
511 sqlite-graphrag remember --name rich --type note --description \"...\" --body \"...\" --enable-ner")]
512 Remember(remember::RememberArgs),
513 #[command(after_long_help = "EXAMPLES:\n \
515 # Batch create from NDJSON\n \
516 cat memories.ndjson | sqlite-graphrag remember-batch --force-merge --json\n\n \
517 # Atomic batch\n \
518 cat memories.ndjson | sqlite-graphrag remember-batch --transaction --json")]
519 RememberBatch(remember_batch::RememberBatchArgs),
520 Ingest(ingest::IngestArgs),
522 #[command(after_long_help = "EXAMPLES:\n \
524 # Top 10 semantic matches (default)\n \
525 sqlite-graphrag recall \"agent memory\"\n\n \
526 # Top 3 only\n \
527 sqlite-graphrag recall \"agent memory\" -k 3\n\n \
528 # Search across all namespaces\n \
529 sqlite-graphrag recall \"agent memory\" --all-namespaces\n\n \
530 # Disable graph traversal (vector-only)\n \
531 sqlite-graphrag recall \"agent memory\" --no-graph")]
532 Recall(recall::RecallArgs),
533 Read(read::ReadArgs),
535 List(list::ListArgs),
537 Forget(forget::ForgetArgs),
539 Purge(purge::PurgeArgs),
541 Rename(rename::RenameArgs),
543 Edit(edit::EditArgs),
545 History(history::HistoryArgs),
547 Restore(restore::RestoreArgs),
549 #[command(after_long_help = "EXAMPLES:\n \
551 # Hybrid search combining KNN + FTS5 BM25 with RRF\n \
552 sqlite-graphrag hybrid-search \"agent memory architecture\"\n\n \
553 # Custom weights for vector vs full-text components\n \
554 sqlite-graphrag hybrid-search \"agent\" --weight-vec 0.7 --weight-fts 0.3")]
555 HybridSearch(hybrid_search::HybridSearchArgs),
556 Health(health::HealthArgs),
558 Migrate(migrate::MigrateArgs),
560 NamespaceDetect(namespace_detect::NamespaceDetectArgs),
562 Optimize(optimize::OptimizeArgs),
564 Stats(stats::StatsArgs),
566 SyncSafeCopy(sync_safe_copy::SyncSafeCopyArgs),
568 Backup(backup::BackupArgs),
570 Vacuum(vacuum::VacuumArgs),
572 Link(link::LinkArgs),
574 Unlink(unlink::UnlinkArgs),
576 #[command(name = "deep-research")]
578 DeepResearch(deep_research::DeepResearchArgs),
579 Related(related::RelatedArgs),
581 Graph(graph_export::GraphArgs),
583 Export(export::ExportArgs),
585 Fts(fts::FtsArgs),
587 Vec(vec::VecArgs),
589 #[command(name = "codex-models")]
595 CodexModels(CodexModelsArgs),
596 PruneRelations(prune_relations::PruneRelationsArgs),
598 #[command(name = "prune-ner")]
600 PruneNer(prune_ner::PruneNerArgs),
601 Slots(slots::SlotsArgs),
603 Pending(pending::PendingArgs),
605 Embedding(embedding::EmbeddingArgs),
607 #[command(name = "pending-embeddings")]
609 PendingEmbeddings(pending_embeddings::PendingEmbeddingsArgs),
610 CleanupOrphans(cleanup_orphans::CleanupOrphansArgs),
612 MemoryEntities(memory_entities::MemoryEntitiesArgs),
614 Cache(cache::CacheArgs),
616 #[command(name = "delete-entity")]
618 DeleteEntity(delete_entity::DeleteEntityArgs),
619 Reclassify(reclassify::ReclassifyArgs),
621 #[command(name = "rename-entity")]
623 RenameEntity(rename_entity::RenameEntityArgs),
624 #[command(name = "merge-entities")]
626 MergeEntities(merge_entities::MergeEntitiesArgs),
627 Enrich(enrich::EnrichArgs),
629 #[command(name = "reclassify-relation")]
631 ReclassifyRelation(reclassify_relation::ReclassifyRelationArgs),
632 #[command(name = "normalize-entities")]
634 NormalizeEntities(normalize_entities::NormalizeEntitiesArgs),
635 Completions(completions::CompletionsArgs),
637 #[command(name = "debug-schema", hide = true)]
638 DebugSchema(debug_schema::DebugSchemaArgs),
639}
640impl std::fmt::Debug for Commands {
646 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
647 let name = match self {
648 Self::Init(_) => "Init",
649 Self::Health(_) => "Health",
650 Self::Stats(_) => "Stats",
651 Self::List(_) => "List",
652 Self::Read(_) => "Read",
653 Self::Edit(_) => "Edit",
654 Self::Rename(_) => "Rename",
655 Self::Restore(_) => "Restore",
656 Self::History(_) => "History",
657 Self::Forget(_) => "Forget",
658 Self::Purge(_) => "Purge",
659 Self::Remember(_) => "Remember",
660 Self::RememberBatch(_) => "RememberBatch",
661 Self::Recall(_) => "Recall",
662 Self::HybridSearch(_) => "HybridSearch",
663 Self::Enrich(_) => "Enrich",
664 Self::Ingest(_) => "Ingest",
665 Self::Optimize(_) => "Optimize",
666 Self::Migrate(_) => "Migrate",
667 Self::SyncSafeCopy(_) => "SyncSafeCopy",
668 Self::Backup(_) => "Backup",
669 Self::Vacuum(_) => "Vacuum",
670 Self::Link(_) => "Link",
671 Self::Unlink(_) => "Unlink",
672 Self::DeepResearch(_) => "DeepResearch",
673 Self::Related(_) => "Related",
674 Self::Graph(_) => "Graph",
675 Self::Export(_) => "Export",
676 Self::Fts(_) => "Fts",
677 Self::Vec(_) => "Vec",
678 Self::CodexModels(_) => "CodexModels",
679 Self::PruneRelations(_) => "PruneRelations",
680 Self::PruneNer(_) => "PruneNer",
681 Self::Slots(_) => "Slots",
682 Self::Pending(_) => "Pending",
683 Self::Embedding(_) => "Embedding",
684 Self::PendingEmbeddings(_) => "PendingEmbeddings",
685 Self::CleanupOrphans(_) => "CleanupOrphans",
686 Self::MemoryEntities(_) => "MemoryEntities",
687 Self::Cache(_) => "Cache",
688 Self::DeleteEntity(_) => "DeleteEntity",
689 Self::Reclassify(_) => "Reclassify",
690 Self::RenameEntity(_) => "RenameEntity",
691 Self::ReclassifyRelation(_) => "ReclassifyRelation",
692 Self::NormalizeEntities(_) => "NormalizeEntities",
693 Self::MergeEntities(_) => "MergeEntities",
694 Self::NamespaceDetect(_) => "NamespaceDetect",
695 Self::Completions(_) => "Completions",
696 Self::DebugSchema(_) => "DebugSchema",
697 };
698 f.write_str(name)
699 }
700}
701
702#[derive(Copy, Clone, Debug, Default, clap::ValueEnum)]
703pub enum MemoryType {
704 User,
705 Feedback,
706 Project,
707 Reference,
708 Decision,
709 Incident,
710 Skill,
711 #[default]
712 Document,
713 Note,
714}
715
716#[cfg(test)]
717mod heavy_concurrency_tests {
718 use super::*;
719
720 #[test]
721 fn command_heavy_detects_init_and_embeddings() {
722 let init = Cli::try_parse_from(["sqlite-graphrag", "init"]).expect("parse init");
723 assert!(init
724 .command
725 .as_ref()
726 .is_some_and(|c| c.is_embedding_heavy()));
727
728 let remember = Cli::try_parse_from([
729 "sqlite-graphrag",
730 "remember",
731 "--name",
732 "test-memory",
733 "--type",
734 "project",
735 "--description",
736 "desc",
737 ])
738 .expect("parse remember");
739 assert!(remember
740 .command
741 .as_ref()
742 .is_some_and(|c| c.is_embedding_heavy()));
743
744 let recall =
745 Cli::try_parse_from(["sqlite-graphrag", "recall", "query"]).expect("parse recall");
746 assert!(recall
747 .command
748 .as_ref()
749 .is_some_and(|c| c.is_embedding_heavy()));
750
751 let hybrid = Cli::try_parse_from(["sqlite-graphrag", "hybrid-search", "query"])
752 .expect("parse hybrid");
753 assert!(hybrid
754 .command
755 .as_ref()
756 .is_some_and(|c| c.is_embedding_heavy()));
757 }
758
759 #[test]
760 fn command_light_does_not_mark_stats() {
761 let stats = Cli::try_parse_from(["sqlite-graphrag", "stats"]).expect("parse stats");
762 assert!(!stats
763 .command
764 .as_ref()
765 .is_some_and(|c| c.is_embedding_heavy()));
766 }
767}
768
769impl MemoryType {
770 pub fn as_str(&self) -> &'static str {
771 match self {
772 Self::User => "user",
773 Self::Feedback => "feedback",
774 Self::Project => "project",
775 Self::Reference => "reference",
776 Self::Decision => "decision",
777 Self::Incident => "incident",
778 Self::Skill => "skill",
779 Self::Document => "document",
780 Self::Note => "note",
781 }
782 }
783}