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 OpenRouter,
35 None,
36}
37
38#[derive(Copy, Clone, Debug, PartialEq, Eq, clap::ValueEnum)]
44pub enum EmbeddingBackendChoice {
45 Auto,
46 Openrouter,
47 Llm,
48}
49
50impl EmbeddingBackendChoice {
51 pub fn to_chain(self, llm_choice: LlmBackendChoice) -> Vec<crate::embedder::LlmBackendKind> {
54 use crate::embedder::LlmBackendKind;
55 match self {
56 EmbeddingBackendChoice::Openrouter => vec![LlmBackendKind::OpenRouter],
57 EmbeddingBackendChoice::Llm => llm_choice.to_chain(),
58 EmbeddingBackendChoice::Auto => {
59 if crate::embedder::is_openrouter_initialized() {
60 let mut chain = vec![LlmBackendKind::OpenRouter];
61 chain.extend(llm_choice.to_chain());
62 chain
63 } else {
64 llm_choice.to_chain()
65 }
66 }
67 }
68 }
69}
70
71impl LlmBackendChoice {
72 pub fn to_chain(self) -> Vec<crate::embedder::LlmBackendKind> {
82 use crate::embedder::LlmBackendKind;
83 match self {
84 LlmBackendChoice::Codex => vec![LlmBackendKind::Codex, LlmBackendKind::None],
85 LlmBackendChoice::Claude => vec![LlmBackendKind::Claude, LlmBackendKind::None],
86 LlmBackendChoice::Opencode => vec![
87 LlmBackendKind::Opencode,
88 LlmBackendKind::Codex,
89 LlmBackendKind::Claude,
90 LlmBackendKind::None,
91 ],
92 LlmBackendChoice::OpenRouter => vec![
93 LlmBackendKind::OpenRouter,
94 LlmBackendKind::Codex,
95 LlmBackendKind::None,
96 ],
97 LlmBackendChoice::None => vec![LlmBackendKind::None],
98 LlmBackendChoice::Auto => parse_fallback_chain(
99 &std::env::var("SQLITE_GRAPHRAG_LLM_FALLBACK")
100 .unwrap_or_else(|_| "codex,claude,none".to_string()),
101 ),
102 }
103 }
104}
105
106fn parse_fallback_chain(s: &str) -> Vec<crate::embedder::LlmBackendKind> {
107 use crate::embedder::LlmBackendKind;
108 let mut chain: Vec<LlmBackendKind> = s
109 .split(',')
110 .filter_map(|tok| match tok.trim().to_ascii_lowercase().as_str() {
111 "codex" => Some(LlmBackendKind::Codex),
112 "claude" | "claude-code" => Some(LlmBackendKind::Claude),
113 "opencode" => Some(LlmBackendKind::Opencode),
114 "openrouter" => Some(LlmBackendKind::OpenRouter),
115 "none" => Some(LlmBackendKind::None),
116 _ => {
117 tracing::warn!(
118 token = tok.trim(),
119 "unknown backend in --llm-fallback, skipping"
120 );
121 Option::None
122 }
123 })
124 .collect();
125 if chain.is_empty() {
126 chain = vec![
127 LlmBackendKind::Codex,
128 LlmBackendKind::Claude,
129 LlmBackendKind::None,
130 ];
131 }
132 chain
133}
134
135#[derive(Parser)]
136#[command(name = "sqlite-graphrag")]
137#[command(version)]
138#[command(about = "Local GraphRAG memory for LLMs in a single SQLite file")]
139#[command(arg_required_else_help = true)]
140pub struct Cli {
141 #[arg(long, global = true, value_name = "N")]
146 pub max_concurrency: Option<usize>,
147
148 #[arg(long, global = true, value_name = "SECONDS")]
153 pub wait_lock: Option<u64>,
154
155 #[arg(long, global = true, hide = true, default_value_t = false)]
159 pub skip_memory_guard: bool,
160
161 #[arg(
170 long,
171 global = true,
172 hide = true,
173 default_value_t = false,
174 value_parser = clap::builder::BoolishValueParser::new(),
175 env = "SQLITE_GRAPHRAG_STRICT_ENV_CLEAR"
176 )]
177 pub strict_env_clear: bool,
178
179 #[arg(
186 long,
187 global = true,
188 hide = true,
189 default_value_t = false,
190 value_parser = clap::builder::BoolishValueParser::new(),
191 env = "SQLITE_GRAPHRAG_DRY_RUN_BACKEND"
192 )]
193 pub dry_run_backend: bool,
194
195 #[arg(long, global = true, value_enum, value_name = "LANG")]
201 pub lang: Option<crate::i18n::Language>,
202
203 #[arg(long, global = true, value_name = "IANA")]
209 pub tz: Option<chrono_tz::Tz>,
210
211 #[arg(short = 'v', long, global = true, action = clap::ArgAction::Count)]
216 pub verbose: u8,
217
218 #[arg(long, global = true, value_name = "KIND", default_value = "llm")]
225 pub extraction_backend: Option<String>,
226
227 #[arg(long, global = true, value_name = "N", value_parser = clap::value_parser!(u64).range(8..=4096))]
235 pub embedding_dim: Option<u64>,
236
237 #[arg(long, global = true, value_enum, default_value_t = LlmBackendChoice::Auto, env = "SQLITE_GRAPHRAG_LLM_BACKEND")]
245 pub llm_backend: LlmBackendChoice,
246
247 #[arg(
251 long,
252 global = true,
253 value_name = "MODEL",
254 env = "SQLITE_GRAPHRAG_LLM_MODEL"
255 )]
256 pub llm_model: Option<String>,
257
258 #[arg(
261 long,
262 global = true,
263 value_name = "PATH",
264 env = "SQLITE_GRAPHRAG_CLAUDE_BINARY"
265 )]
266 pub claude_binary: Option<std::path::PathBuf>,
267
268 #[arg(
271 long,
272 global = true,
273 value_name = "PATH",
274 env = "SQLITE_GRAPHRAG_CODEX_BINARY"
275 )]
276 pub codex_binary: Option<std::path::PathBuf>,
277
278 #[arg(
281 long,
282 global = true,
283 value_name = "PATH",
284 env = "SQLITE_GRAPHRAG_OPENCODE_BINARY"
285 )]
286 pub opencode_binary: Option<std::path::PathBuf>,
287
288 #[arg(
292 long,
293 global = true,
294 default_value = "codex,claude,none",
295 env = "SQLITE_GRAPHRAG_LLM_FALLBACK"
296 )]
297 pub llm_fallback: String,
298
299 #[arg(
304 long,
305 global = true,
306 default_value_t = false,
307 value_parser = clap::builder::BoolishValueParser::new(),
308 env = "SQLITE_GRAPHRAG_SKIP_EMBEDDING_ON_FAILURE"
309 )]
310 pub skip_embedding_on_failure: bool,
311
312 #[arg(
316 long,
317 global = true,
318 value_name = "N",
319 env = "SQLITE_GRAPHRAG_LLM_MAX_HOST_CONCURRENCY"
320 )]
321 pub llm_max_host_concurrency: Option<u32>,
322
323 #[arg(
327 long,
328 global = true,
329 value_name = "SECONDS",
330 env = "SQLITE_GRAPHRAG_LLM_SLOT_WAIT_SECS"
331 )]
332 pub llm_slot_wait_secs: Option<u64>,
333
334 #[arg(
338 long,
339 global = true,
340 default_value_t = false,
341 value_parser = clap::builder::BoolishValueParser::new(),
342 env = "SQLITE_GRAPHRAG_LLM_SLOT_NO_WAIT"
343 )]
344 pub llm_slot_no_wait: bool,
345
346 #[arg(long, global = true, value_enum, default_value_t = EmbeddingBackendChoice::Auto, env = "SQLITE_GRAPHRAG_EMBEDDING_BACKEND")]
350 pub embedding_backend: EmbeddingBackendChoice,
351
352 #[arg(
355 long,
356 global = true,
357 value_name = "MODEL",
358 env = "SQLITE_GRAPHRAG_EMBEDDING_MODEL"
359 )]
360 pub embedding_model: Option<String>,
361
362 #[arg(
365 long,
366 global = true,
367 value_name = "KEY",
368 hide = true,
369 env = "OPENROUTER_API_KEY"
370 )]
371 pub openrouter_api_key: Option<String>,
372
373 #[command(subcommand)]
374 pub command: Option<Commands>,
375}
376
377#[cfg(test)]
378mod json_only_format_tests {
379 use super::Cli;
380 use clap::Parser;
381
382 #[test]
383 fn restore_accepts_only_format_json() {
384 assert!(Cli::try_parse_from([
385 "sqlite-graphrag",
386 "restore",
387 "--name",
388 "mem",
389 "--version",
390 "1",
391 "--format",
392 "json",
393 ])
394 .is_ok());
395
396 assert!(Cli::try_parse_from([
397 "sqlite-graphrag",
398 "restore",
399 "--name",
400 "mem",
401 "--version",
402 "1",
403 "--format",
404 "text",
405 ])
406 .is_err());
407 }
408
409 #[test]
410 fn hybrid_search_accepts_only_format_json() {
411 assert!(Cli::try_parse_from([
412 "sqlite-graphrag",
413 "hybrid-search",
414 "query",
415 "--format",
416 "json",
417 ])
418 .is_ok());
419
420 assert!(Cli::try_parse_from([
421 "sqlite-graphrag",
422 "hybrid-search",
423 "query",
424 "--format",
425 "markdown",
426 ])
427 .is_err());
428 }
429
430 #[test]
431 fn remember_recall_rename_vacuum_json_only() {
432 assert!(Cli::try_parse_from([
433 "sqlite-graphrag",
434 "remember",
435 "--name",
436 "mem",
437 "--type",
438 "project",
439 "--description",
440 "desc",
441 "--format",
442 "json",
443 ])
444 .is_ok());
445 assert!(Cli::try_parse_from([
446 "sqlite-graphrag",
447 "remember",
448 "--name",
449 "mem",
450 "--type",
451 "project",
452 "--description",
453 "desc",
454 "--format",
455 "text",
456 ])
457 .is_err());
458
459 assert!(
460 Cli::try_parse_from(["sqlite-graphrag", "recall", "query", "--format", "json",])
461 .is_ok()
462 );
463 assert!(
464 Cli::try_parse_from(["sqlite-graphrag", "recall", "query", "--format", "text",])
465 .is_err()
466 );
467
468 assert!(Cli::try_parse_from([
469 "sqlite-graphrag",
470 "rename",
471 "--name",
472 "old",
473 "--new-name",
474 "new",
475 "--format",
476 "json",
477 ])
478 .is_ok());
479 assert!(Cli::try_parse_from([
480 "sqlite-graphrag",
481 "rename",
482 "--name",
483 "old",
484 "--new-name",
485 "new",
486 "--format",
487 "markdown",
488 ])
489 .is_err());
490
491 assert!(Cli::try_parse_from(["sqlite-graphrag", "vacuum", "--format", "json",]).is_ok());
492 assert!(Cli::try_parse_from(["sqlite-graphrag", "vacuum", "--format", "text",]).is_err());
493 }
494}
495
496impl Cli {
497 pub fn validate_flags(&self) -> Result<(), String> {
502 if let Some(n) = self.max_concurrency {
503 if n == 0 {
504 return Err(match current() {
505 Language::English => "--max-concurrency must be >= 1".to_string(),
506 Language::Portuguese => "--max-concurrency deve ser >= 1".to_string(),
507 });
508 }
509 let teto = max_concurrency_ceiling();
510 if n > teto {
511 return Err(match current() {
512 Language::English => format!(
513 "--max-concurrency {n} exceeds the ceiling of {teto} (2×nCPUs) on this system"
514 ),
515 Language::Portuguese => format!(
516 "--max-concurrency {n} excede o teto de {teto} (2×nCPUs) neste sistema"
517 ),
518 });
519 }
520 }
521 Ok(())
522 }
523}
524
525impl Commands {
526 pub fn is_embedding_heavy(&self) -> bool {
528 matches!(
529 self,
530 Self::Init(_)
531 | Self::Remember(_)
532 | Self::RememberBatch(_)
533 | Self::Recall(_)
534 | Self::HybridSearch(_)
535 | Self::DeepResearch(_)
536 )
537 }
538
539 pub fn uses_cli_slot(&self) -> bool {
540 true
541 }
542}
543
544#[derive(Debug, clap::Args)]
549pub struct CodexModelsArgs {
550 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
552 pub json: bool,
553}
554
555#[derive(Subcommand)]
556pub enum Commands {
557 #[command(after_long_help = "EXAMPLES:\n \
559 # Initialize in current directory (default behavior)\n \
560 sqlite-graphrag init\n\n \
561 # Initialize at a specific path\n \
562 sqlite-graphrag init --db /path/to/graphrag.sqlite\n\n \
563 # Initialize using SQLITE_GRAPHRAG_HOME env var\n \
564 SQLITE_GRAPHRAG_HOME=/data sqlite-graphrag init\n\n\
565 NOTES:\n \
566 - `init` is OPTIONAL: any subsequent CRUD command auto-initializes graphrag.sqlite if missing.\n \
567 - As a side effect, `init` warms a smoke-test embedding via the LLM-only one-shot pipeline.")]
568 Init(init::InitArgs),
569 #[command(after_long_help = "EXAMPLES:\n \
571 # Inline body\n \
572 sqlite-graphrag remember --name onboarding --type user --description \"intro\" --body \"hello\"\n\n \
573 # Body from file\n \
574 sqlite-graphrag remember --name doc1 --type document --description \"...\" --body-file ./README.md\n\n \
575 # Body from stdin (pipe)\n \
576 cat README.md | sqlite-graphrag remember --name doc1 --type document --description \"...\" --body-stdin\n\n \
577 # Enable automatic URL extraction (URL-regex only since v1.0.79; GLiNER removed)\n \
578 sqlite-graphrag remember --name rich --type note --description \"...\" --body \"...\" --enable-ner")]
579 Remember(remember::RememberArgs),
580 #[command(after_long_help = "EXAMPLES:\n \
582 # Batch create from NDJSON\n \
583 cat memories.ndjson | sqlite-graphrag remember-batch --force-merge --json\n\n \
584 # Atomic batch\n \
585 cat memories.ndjson | sqlite-graphrag remember-batch --transaction --json")]
586 RememberBatch(remember_batch::RememberBatchArgs),
587 Ingest(ingest::IngestArgs),
589 #[command(after_long_help = "EXAMPLES:\n \
591 # Top 10 semantic matches (default)\n \
592 sqlite-graphrag recall \"agent memory\"\n\n \
593 # Top 3 only\n \
594 sqlite-graphrag recall \"agent memory\" -k 3\n\n \
595 # Search across all namespaces\n \
596 sqlite-graphrag recall \"agent memory\" --all-namespaces\n\n \
597 # Disable graph traversal (vector-only)\n \
598 sqlite-graphrag recall \"agent memory\" --no-graph")]
599 Recall(recall::RecallArgs),
600 Read(read::ReadArgs),
602 List(list::ListArgs),
604 Forget(forget::ForgetArgs),
606 Purge(purge::PurgeArgs),
608 Rename(rename::RenameArgs),
610 Edit(edit::EditArgs),
612 History(history::HistoryArgs),
614 Restore(restore::RestoreArgs),
616 #[command(after_long_help = "EXAMPLES:\n \
618 # Hybrid search combining KNN + FTS5 BM25 with RRF\n \
619 sqlite-graphrag hybrid-search \"agent memory architecture\"\n\n \
620 # Custom weights for vector vs full-text components\n \
621 sqlite-graphrag hybrid-search \"agent\" --weight-vec 0.7 --weight-fts 0.3")]
622 HybridSearch(hybrid_search::HybridSearchArgs),
623 Health(health::HealthArgs),
625 Migrate(migrate::MigrateArgs),
627 NamespaceDetect(namespace_detect::NamespaceDetectArgs),
629 Optimize(optimize::OptimizeArgs),
631 Stats(stats::StatsArgs),
633 SyncSafeCopy(sync_safe_copy::SyncSafeCopyArgs),
635 Backup(backup::BackupArgs),
637 Vacuum(vacuum::VacuumArgs),
639 Link(link::LinkArgs),
641 Unlink(unlink::UnlinkArgs),
643 #[command(name = "deep-research")]
645 DeepResearch(deep_research::DeepResearchArgs),
646 Related(related::RelatedArgs),
648 Graph(graph_export::GraphArgs),
650 Export(export::ExportArgs),
652 Fts(fts::FtsArgs),
654 Vec(vec::VecArgs),
656 #[command(name = "codex-models")]
662 CodexModels(CodexModelsArgs),
663 PruneRelations(prune_relations::PruneRelationsArgs),
665 #[command(name = "prune-ner")]
667 PruneNer(prune_ner::PruneNerArgs),
668 Slots(slots::SlotsArgs),
670 Pending(pending::PendingArgs),
672 Embedding(embedding::EmbeddingArgs),
674 #[command(name = "pending-embeddings")]
676 PendingEmbeddings(pending_embeddings::PendingEmbeddingsArgs),
677 CleanupOrphans(cleanup_orphans::CleanupOrphansArgs),
679 MemoryEntities(memory_entities::MemoryEntitiesArgs),
681 Cache(cache::CacheArgs),
683 #[command(name = "delete-entity")]
685 DeleteEntity(delete_entity::DeleteEntityArgs),
686 Reclassify(reclassify::ReclassifyArgs),
688 #[command(name = "rename-entity")]
690 RenameEntity(rename_entity::RenameEntityArgs),
691 #[command(name = "merge-entities")]
693 MergeEntities(merge_entities::MergeEntitiesArgs),
694 Enrich(enrich::EnrichArgs),
696 #[command(name = "reclassify-relation")]
698 ReclassifyRelation(reclassify_relation::ReclassifyRelationArgs),
699 #[command(name = "normalize-entities")]
701 NormalizeEntities(normalize_entities::NormalizeEntitiesArgs),
702 Completions(completions::CompletionsArgs),
704 #[command(name = "debug-schema", hide = true)]
705 DebugSchema(debug_schema::DebugSchemaArgs),
706 Config(config_cmd::ConfigArgs),
708}
709impl std::fmt::Debug for Commands {
715 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
716 let name = match self {
717 Self::Init(_) => "Init",
718 Self::Health(_) => "Health",
719 Self::Stats(_) => "Stats",
720 Self::List(_) => "List",
721 Self::Read(_) => "Read",
722 Self::Edit(_) => "Edit",
723 Self::Rename(_) => "Rename",
724 Self::Restore(_) => "Restore",
725 Self::History(_) => "History",
726 Self::Forget(_) => "Forget",
727 Self::Purge(_) => "Purge",
728 Self::Remember(_) => "Remember",
729 Self::RememberBatch(_) => "RememberBatch",
730 Self::Recall(_) => "Recall",
731 Self::HybridSearch(_) => "HybridSearch",
732 Self::Enrich(_) => "Enrich",
733 Self::Ingest(_) => "Ingest",
734 Self::Optimize(_) => "Optimize",
735 Self::Migrate(_) => "Migrate",
736 Self::SyncSafeCopy(_) => "SyncSafeCopy",
737 Self::Backup(_) => "Backup",
738 Self::Vacuum(_) => "Vacuum",
739 Self::Link(_) => "Link",
740 Self::Unlink(_) => "Unlink",
741 Self::DeepResearch(_) => "DeepResearch",
742 Self::Related(_) => "Related",
743 Self::Graph(_) => "Graph",
744 Self::Export(_) => "Export",
745 Self::Fts(_) => "Fts",
746 Self::Vec(_) => "Vec",
747 Self::CodexModels(_) => "CodexModels",
748 Self::PruneRelations(_) => "PruneRelations",
749 Self::PruneNer(_) => "PruneNer",
750 Self::Slots(_) => "Slots",
751 Self::Pending(_) => "Pending",
752 Self::Embedding(_) => "Embedding",
753 Self::PendingEmbeddings(_) => "PendingEmbeddings",
754 Self::CleanupOrphans(_) => "CleanupOrphans",
755 Self::MemoryEntities(_) => "MemoryEntities",
756 Self::Cache(_) => "Cache",
757 Self::DeleteEntity(_) => "DeleteEntity",
758 Self::Reclassify(_) => "Reclassify",
759 Self::RenameEntity(_) => "RenameEntity",
760 Self::ReclassifyRelation(_) => "ReclassifyRelation",
761 Self::NormalizeEntities(_) => "NormalizeEntities",
762 Self::MergeEntities(_) => "MergeEntities",
763 Self::NamespaceDetect(_) => "NamespaceDetect",
764 Self::Completions(_) => "Completions",
765 Self::DebugSchema(_) => "DebugSchema",
766 Self::Config(_) => "Config",
767 };
768 f.write_str(name)
769 }
770}
771
772#[derive(Copy, Clone, Debug, Default, clap::ValueEnum)]
773pub enum MemoryType {
774 User,
775 Feedback,
776 Project,
777 Reference,
778 Decision,
779 Incident,
780 Skill,
781 #[default]
782 Document,
783 Note,
784}
785
786#[cfg(test)]
787mod heavy_concurrency_tests {
788 use super::*;
789
790 #[test]
791 fn command_heavy_detects_init_and_embeddings() {
792 let init = Cli::try_parse_from(["sqlite-graphrag", "init"]).expect("parse init");
793 assert!(init
794 .command
795 .as_ref()
796 .is_some_and(|c| c.is_embedding_heavy()));
797
798 let remember = Cli::try_parse_from([
799 "sqlite-graphrag",
800 "remember",
801 "--name",
802 "test-memory",
803 "--type",
804 "project",
805 "--description",
806 "desc",
807 ])
808 .expect("parse remember");
809 assert!(remember
810 .command
811 .as_ref()
812 .is_some_and(|c| c.is_embedding_heavy()));
813
814 let recall =
815 Cli::try_parse_from(["sqlite-graphrag", "recall", "query"]).expect("parse recall");
816 assert!(recall
817 .command
818 .as_ref()
819 .is_some_and(|c| c.is_embedding_heavy()));
820
821 let hybrid = Cli::try_parse_from(["sqlite-graphrag", "hybrid-search", "query"])
822 .expect("parse hybrid");
823 assert!(hybrid
824 .command
825 .as_ref()
826 .is_some_and(|c| c.is_embedding_heavy()));
827 }
828
829 #[test]
830 fn command_light_does_not_mark_stats() {
831 let stats = Cli::try_parse_from(["sqlite-graphrag", "stats"]).expect("parse stats");
832 assert!(!stats
833 .command
834 .as_ref()
835 .is_some_and(|c| c.is_embedding_heavy()));
836 }
837}
838
839impl MemoryType {
840 pub fn as_str(&self) -> &'static str {
841 match self {
842 Self::User => "user",
843 Self::Feedback => "feedback",
844 Self::Project => "project",
845 Self::Reference => "reference",
846 Self::Decision => "decision",
847 Self::Incident => "incident",
848 Self::Skill => "skill",
849 Self::Document => "document",
850 Self::Note => "note",
851 }
852 }
853}