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 None,
34}
35
36impl LlmBackendChoice {
37 pub fn to_chain(self) -> Vec<crate::embedder::LlmBackendKind> {
47 use crate::embedder::LlmBackendKind;
48 match self {
49 LlmBackendChoice::Codex => vec![LlmBackendKind::Codex, LlmBackendKind::None],
50 LlmBackendChoice::Claude => vec![LlmBackendKind::Claude, LlmBackendKind::None],
51 LlmBackendChoice::None => vec![LlmBackendKind::None],
52 LlmBackendChoice::Auto => vec![
53 LlmBackendKind::Codex,
54 LlmBackendKind::Claude,
55 LlmBackendKind::None,
56 ],
57 }
58 }
59}
60
61#[derive(Parser)]
62#[command(name = "sqlite-graphrag")]
63#[command(version)]
64#[command(about = "Local GraphRAG memory for LLMs in a single SQLite file")]
65#[command(arg_required_else_help = true)]
66pub struct Cli {
67 #[arg(long, global = true, value_name = "N")]
72 pub max_concurrency: Option<usize>,
73
74 #[arg(long, global = true, value_name = "SECONDS")]
79 pub wait_lock: Option<u64>,
80
81 #[arg(long, global = true, hide = true, default_value_t = false)]
85 pub skip_memory_guard: bool,
86
87 #[arg(
96 long,
97 global = true,
98 hide = true,
99 default_value_t = false,
100 env = "SQLITE_GRAPHRAG_STRICT_ENV_CLEAR"
101 )]
102 pub strict_env_clear: bool,
103
104 #[arg(
111 long,
112 global = true,
113 hide = true,
114 default_value_t = false,
115 env = "SQLITE_GRAPHRAG_DRY_RUN_BACKEND"
116 )]
117 pub dry_run_backend: bool,
118
119 #[arg(long, global = true, value_enum, value_name = "LANG")]
125 pub lang: Option<crate::i18n::Language>,
126
127 #[arg(long, global = true, value_name = "IANA")]
133 pub tz: Option<chrono_tz::Tz>,
134
135 #[arg(short = 'v', long, global = true, action = clap::ArgAction::Count)]
140 pub verbose: u8,
141
142 #[arg(long, global = true, value_name = "KIND", default_value = "llm")]
149 pub extraction_backend: Option<String>,
150
151 #[arg(long, global = true, value_name = "N", value_parser = clap::value_parser!(u64).range(8..=4096))]
159 pub embedding_dim: Option<u64>,
160
161 #[arg(long, global = true, value_enum, default_value_t = LlmBackendChoice::Auto, env = "SQLITE_GRAPHRAG_LLM_BACKEND")]
168 pub llm_backend: LlmBackendChoice,
169
170 #[arg(
174 long,
175 global = true,
176 value_name = "MODEL",
177 env = "SQLITE_GRAPHRAG_LLM_MODEL"
178 )]
179 pub llm_model: Option<String>,
180
181 #[arg(
184 long,
185 global = true,
186 value_name = "PATH",
187 env = "SQLITE_GRAPHRAG_CLAUDE_BINARY"
188 )]
189 pub claude_binary: Option<std::path::PathBuf>,
190
191 #[arg(
195 long,
196 global = true,
197 default_value = "codex,claude,none",
198 env = "SQLITE_GRAPHRAG_LLM_FALLBACK"
199 )]
200 pub llm_fallback: String,
201
202 #[arg(
207 long,
208 global = true,
209 default_value_t = false,
210 env = "SQLITE_GRAPHRAG_SKIP_EMBEDDING_ON_FAILURE"
211 )]
212 pub skip_embedding_on_failure: bool,
213
214 #[arg(
218 long,
219 global = true,
220 value_name = "N",
221 env = "SQLITE_GRAPHRAG_LLM_MAX_HOST_CONCURRENCY"
222 )]
223 pub llm_max_host_concurrency: Option<u32>,
224
225 #[arg(
229 long,
230 global = true,
231 value_name = "SECONDS",
232 env = "SQLITE_GRAPHRAG_LLM_SLOT_WAIT_SECS"
233 )]
234 pub llm_slot_wait_secs: Option<u64>,
235
236 #[arg(
240 long,
241 global = true,
242 default_value_t = false,
243 env = "SQLITE_GRAPHRAG_LLM_SLOT_NO_WAIT"
244 )]
245 pub llm_slot_no_wait: bool,
246
247 #[command(subcommand)]
248 pub command: Option<Commands>,
249}
250
251#[cfg(test)]
252mod json_only_format_tests {
253 use super::Cli;
254 use clap::Parser;
255
256 #[test]
257 fn restore_accepts_only_format_json() {
258 assert!(Cli::try_parse_from([
259 "sqlite-graphrag",
260 "restore",
261 "--name",
262 "mem",
263 "--version",
264 "1",
265 "--format",
266 "json",
267 ])
268 .is_ok());
269
270 assert!(Cli::try_parse_from([
271 "sqlite-graphrag",
272 "restore",
273 "--name",
274 "mem",
275 "--version",
276 "1",
277 "--format",
278 "text",
279 ])
280 .is_err());
281 }
282
283 #[test]
284 fn hybrid_search_accepts_only_format_json() {
285 assert!(Cli::try_parse_from([
286 "sqlite-graphrag",
287 "hybrid-search",
288 "query",
289 "--format",
290 "json",
291 ])
292 .is_ok());
293
294 assert!(Cli::try_parse_from([
295 "sqlite-graphrag",
296 "hybrid-search",
297 "query",
298 "--format",
299 "markdown",
300 ])
301 .is_err());
302 }
303
304 #[test]
305 fn remember_recall_rename_vacuum_json_only() {
306 assert!(Cli::try_parse_from([
307 "sqlite-graphrag",
308 "remember",
309 "--name",
310 "mem",
311 "--type",
312 "project",
313 "--description",
314 "desc",
315 "--format",
316 "json",
317 ])
318 .is_ok());
319 assert!(Cli::try_parse_from([
320 "sqlite-graphrag",
321 "remember",
322 "--name",
323 "mem",
324 "--type",
325 "project",
326 "--description",
327 "desc",
328 "--format",
329 "text",
330 ])
331 .is_err());
332
333 assert!(
334 Cli::try_parse_from(["sqlite-graphrag", "recall", "query", "--format", "json",])
335 .is_ok()
336 );
337 assert!(
338 Cli::try_parse_from(["sqlite-graphrag", "recall", "query", "--format", "text",])
339 .is_err()
340 );
341
342 assert!(Cli::try_parse_from([
343 "sqlite-graphrag",
344 "rename",
345 "--name",
346 "old",
347 "--new-name",
348 "new",
349 "--format",
350 "json",
351 ])
352 .is_ok());
353 assert!(Cli::try_parse_from([
354 "sqlite-graphrag",
355 "rename",
356 "--name",
357 "old",
358 "--new-name",
359 "new",
360 "--format",
361 "markdown",
362 ])
363 .is_err());
364
365 assert!(Cli::try_parse_from(["sqlite-graphrag", "vacuum", "--format", "json",]).is_ok());
366 assert!(Cli::try_parse_from(["sqlite-graphrag", "vacuum", "--format", "text",]).is_err());
367 }
368}
369
370impl Cli {
371 pub fn validate_flags(&self) -> Result<(), String> {
376 if let Some(n) = self.max_concurrency {
377 if n == 0 {
378 return Err(match current() {
379 Language::English => "--max-concurrency must be >= 1".to_string(),
380 Language::Portuguese => "--max-concurrency deve ser >= 1".to_string(),
381 });
382 }
383 let teto = max_concurrency_ceiling();
384 if n > teto {
385 return Err(match current() {
386 Language::English => format!(
387 "--max-concurrency {n} exceeds the ceiling of {teto} (2×nCPUs) on this system"
388 ),
389 Language::Portuguese => format!(
390 "--max-concurrency {n} excede o teto de {teto} (2×nCPUs) neste sistema"
391 ),
392 });
393 }
394 }
395 Ok(())
396 }
397}
398
399impl Commands {
400 pub fn is_embedding_heavy(&self) -> bool {
402 matches!(
403 self,
404 Self::Init(_)
405 | Self::Remember(_)
406 | Self::RememberBatch(_)
407 | Self::Recall(_)
408 | Self::HybridSearch(_)
409 | Self::DeepResearch(_)
410 )
411 }
412
413 pub fn uses_cli_slot(&self) -> bool {
414 true
415 }
416}
417
418#[derive(Debug, clap::Args)]
423pub struct CodexModelsArgs {
424 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
426 pub json: bool,
427}
428
429#[derive(Subcommand)]
430pub enum Commands {
431 #[command(after_long_help = "EXAMPLES:\n \
433 # Initialize in current directory (default behavior)\n \
434 sqlite-graphrag init\n\n \
435 # Initialize at a specific path\n \
436 sqlite-graphrag init --db /path/to/graphrag.sqlite\n\n \
437 # Initialize using SQLITE_GRAPHRAG_HOME env var\n \
438 SQLITE_GRAPHRAG_HOME=/data sqlite-graphrag init\n\n\
439 NOTES:\n \
440 - `init` is OPTIONAL: any subsequent CRUD command auto-initializes graphrag.sqlite if missing.\n \
441 - As a side effect, `init` warms a smoke-test embedding via the LLM-only one-shot pipeline.")]
442 Init(init::InitArgs),
443 #[command(after_long_help = "EXAMPLES:\n \
445 # Inline body\n \
446 sqlite-graphrag remember --name onboarding --type user --description \"intro\" --body \"hello\"\n\n \
447 # Body from file\n \
448 sqlite-graphrag remember --name doc1 --type document --description \"...\" --body-file ./README.md\n\n \
449 # Body from stdin (pipe)\n \
450 cat README.md | sqlite-graphrag remember --name doc1 --type document --description \"...\" --body-stdin\n\n \
451 # Enable automatic URL extraction (URL-regex only since v1.0.79; GLiNER removed)\n \
452 sqlite-graphrag remember --name rich --type note --description \"...\" --body \"...\" --enable-ner")]
453 Remember(remember::RememberArgs),
454 #[command(after_long_help = "EXAMPLES:\n \
456 # Batch create from NDJSON\n \
457 cat memories.ndjson | sqlite-graphrag remember-batch --force-merge --json\n\n \
458 # Atomic batch\n \
459 cat memories.ndjson | sqlite-graphrag remember-batch --transaction --json")]
460 RememberBatch(remember_batch::RememberBatchArgs),
461 Ingest(ingest::IngestArgs),
463 #[command(after_long_help = "EXAMPLES:\n \
465 # Top 10 semantic matches (default)\n \
466 sqlite-graphrag recall \"agent memory\"\n\n \
467 # Top 3 only\n \
468 sqlite-graphrag recall \"agent memory\" -k 3\n\n \
469 # Search across all namespaces\n \
470 sqlite-graphrag recall \"agent memory\" --all-namespaces\n\n \
471 # Disable graph traversal (vector-only)\n \
472 sqlite-graphrag recall \"agent memory\" --no-graph")]
473 Recall(recall::RecallArgs),
474 Read(read::ReadArgs),
476 List(list::ListArgs),
478 Forget(forget::ForgetArgs),
480 Purge(purge::PurgeArgs),
482 Rename(rename::RenameArgs),
484 Edit(edit::EditArgs),
486 History(history::HistoryArgs),
488 Restore(restore::RestoreArgs),
490 #[command(after_long_help = "EXAMPLES:\n \
492 # Hybrid search combining KNN + FTS5 BM25 with RRF\n \
493 sqlite-graphrag hybrid-search \"agent memory architecture\"\n\n \
494 # Custom weights for vector vs full-text components\n \
495 sqlite-graphrag hybrid-search \"agent\" --weight-vec 0.7 --weight-fts 0.3")]
496 HybridSearch(hybrid_search::HybridSearchArgs),
497 Health(health::HealthArgs),
499 Migrate(migrate::MigrateArgs),
501 NamespaceDetect(namespace_detect::NamespaceDetectArgs),
503 Optimize(optimize::OptimizeArgs),
505 Stats(stats::StatsArgs),
507 SyncSafeCopy(sync_safe_copy::SyncSafeCopyArgs),
509 Backup(backup::BackupArgs),
511 Vacuum(vacuum::VacuumArgs),
513 Link(link::LinkArgs),
515 Unlink(unlink::UnlinkArgs),
517 #[command(name = "deep-research")]
519 DeepResearch(deep_research::DeepResearchArgs),
520 Related(related::RelatedArgs),
522 Graph(graph_export::GraphArgs),
524 Export(export::ExportArgs),
526 Fts(fts::FtsArgs),
528 Vec(vec::VecArgs),
530 #[command(name = "codex-models")]
536 CodexModels(CodexModelsArgs),
537 PruneRelations(prune_relations::PruneRelationsArgs),
539 #[command(name = "prune-ner")]
541 PruneNer(prune_ner::PruneNerArgs),
542 Slots(slots::SlotsArgs),
544 Pending(pending::PendingArgs),
546 Embedding(embedding::EmbeddingArgs),
548 #[command(name = "pending-embeddings")]
550 PendingEmbeddings(pending_embeddings::PendingEmbeddingsArgs),
551 CleanupOrphans(cleanup_orphans::CleanupOrphansArgs),
553 MemoryEntities(memory_entities::MemoryEntitiesArgs),
555 Cache(cache::CacheArgs),
557 #[command(name = "delete-entity")]
559 DeleteEntity(delete_entity::DeleteEntityArgs),
560 Reclassify(reclassify::ReclassifyArgs),
562 #[command(name = "rename-entity")]
564 RenameEntity(rename_entity::RenameEntityArgs),
565 #[command(name = "merge-entities")]
567 MergeEntities(merge_entities::MergeEntitiesArgs),
568 Enrich(enrich::EnrichArgs),
570 #[command(name = "reclassify-relation")]
572 ReclassifyRelation(reclassify_relation::ReclassifyRelationArgs),
573 #[command(name = "normalize-entities")]
575 NormalizeEntities(normalize_entities::NormalizeEntitiesArgs),
576 Completions(completions::CompletionsArgs),
578 #[command(name = "debug-schema", hide = true)]
579 DebugSchema(debug_schema::DebugSchemaArgs),
580}
581impl std::fmt::Debug for Commands {
587 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
588 let name = match self {
589 Self::Init(_) => "Init",
590 Self::Health(_) => "Health",
591 Self::Stats(_) => "Stats",
592 Self::List(_) => "List",
593 Self::Read(_) => "Read",
594 Self::Edit(_) => "Edit",
595 Self::Rename(_) => "Rename",
596 Self::Restore(_) => "Restore",
597 Self::History(_) => "History",
598 Self::Forget(_) => "Forget",
599 Self::Purge(_) => "Purge",
600 Self::Remember(_) => "Remember",
601 Self::RememberBatch(_) => "RememberBatch",
602 Self::Recall(_) => "Recall",
603 Self::HybridSearch(_) => "HybridSearch",
604 Self::Enrich(_) => "Enrich",
605 Self::Ingest(_) => "Ingest",
606 Self::Optimize(_) => "Optimize",
607 Self::Migrate(_) => "Migrate",
608 Self::SyncSafeCopy(_) => "SyncSafeCopy",
609 Self::Backup(_) => "Backup",
610 Self::Vacuum(_) => "Vacuum",
611 Self::Link(_) => "Link",
612 Self::Unlink(_) => "Unlink",
613 Self::DeepResearch(_) => "DeepResearch",
614 Self::Related(_) => "Related",
615 Self::Graph(_) => "Graph",
616 Self::Export(_) => "Export",
617 Self::Fts(_) => "Fts",
618 Self::Vec(_) => "Vec",
619 Self::CodexModels(_) => "CodexModels",
620 Self::PruneRelations(_) => "PruneRelations",
621 Self::PruneNer(_) => "PruneNer",
622 Self::Slots(_) => "Slots",
623 Self::Pending(_) => "Pending",
624 Self::Embedding(_) => "Embedding",
625 Self::PendingEmbeddings(_) => "PendingEmbeddings",
626 Self::CleanupOrphans(_) => "CleanupOrphans",
627 Self::MemoryEntities(_) => "MemoryEntities",
628 Self::Cache(_) => "Cache",
629 Self::DeleteEntity(_) => "DeleteEntity",
630 Self::Reclassify(_) => "Reclassify",
631 Self::RenameEntity(_) => "RenameEntity",
632 Self::ReclassifyRelation(_) => "ReclassifyRelation",
633 Self::NormalizeEntities(_) => "NormalizeEntities",
634 Self::MergeEntities(_) => "MergeEntities",
635 Self::NamespaceDetect(_) => "NamespaceDetect",
636 Self::Completions(_) => "Completions",
637 Self::DebugSchema(_) => "DebugSchema",
638 };
639 f.write_str(name)
640 }
641}
642
643#[derive(Copy, Clone, Debug, Default, clap::ValueEnum)]
644pub enum MemoryType {
645 User,
646 Feedback,
647 Project,
648 Reference,
649 Decision,
650 Incident,
651 Skill,
652 #[default]
653 Document,
654 Note,
655}
656
657#[cfg(test)]
658mod heavy_concurrency_tests {
659 use super::*;
660
661 #[test]
662 fn command_heavy_detects_init_and_embeddings() {
663 let init = Cli::try_parse_from(["sqlite-graphrag", "init"]).expect("parse init");
664 assert!(init
665 .command
666 .as_ref()
667 .is_some_and(|c| c.is_embedding_heavy()));
668
669 let remember = Cli::try_parse_from([
670 "sqlite-graphrag",
671 "remember",
672 "--name",
673 "test-memory",
674 "--type",
675 "project",
676 "--description",
677 "desc",
678 ])
679 .expect("parse remember");
680 assert!(remember
681 .command
682 .as_ref()
683 .is_some_and(|c| c.is_embedding_heavy()));
684
685 let recall =
686 Cli::try_parse_from(["sqlite-graphrag", "recall", "query"]).expect("parse recall");
687 assert!(recall
688 .command
689 .as_ref()
690 .is_some_and(|c| c.is_embedding_heavy()));
691
692 let hybrid = Cli::try_parse_from(["sqlite-graphrag", "hybrid-search", "query"])
693 .expect("parse hybrid");
694 assert!(hybrid
695 .command
696 .as_ref()
697 .is_some_and(|c| c.is_embedding_heavy()));
698 }
699
700 #[test]
701 fn command_light_does_not_mark_stats() {
702 let stats = Cli::try_parse_from(["sqlite-graphrag", "stats"]).expect("parse stats");
703 assert!(!stats
704 .command
705 .as_ref()
706 .is_some_and(|c| c.is_embedding_heavy()));
707 }
708}
709
710impl MemoryType {
711 pub fn as_str(&self) -> &'static str {
712 match self {
713 Self::User => "user",
714 Self::Feedback => "feedback",
715 Self::Project => "project",
716 Self::Reference => "reference",
717 Self::Decision => "decision",
718 Self::Incident => "incident",
719 Self::Skill => "skill",
720 Self::Document => "document",
721 Self::Note => "note",
722 }
723 }
724}