1use std::fs;
7use std::io::{self, Write};
8use std::path::{Path, PathBuf};
9
10use anyhow::{anyhow, bail, Result};
11use clap::{Args, Subcommand, ValueEnum};
12
13use crate::config::CliConfig;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum EmbeddingModel {
22 BgeSmall,
24 BgeBase,
26 Nomic,
28 GteLarge,
30}
31
32impl EmbeddingModel {
33 fn all() -> impl Iterator<Item = EmbeddingModel> {
34 [
35 EmbeddingModel::BgeSmall,
36 EmbeddingModel::BgeBase,
37 EmbeddingModel::Nomic,
38 EmbeddingModel::GteLarge,
39 ]
40 .into_iter()
41 }
42
43 fn display_name(&self) -> &'static str {
44 match self {
45 EmbeddingModel::BgeSmall => "BGE-small-en-v1.5",
46 EmbeddingModel::BgeBase => "BGE-base-en-v1.5",
47 EmbeddingModel::Nomic => "Nomic-embed-text-v1.5",
48 EmbeddingModel::GteLarge => "GTE-large-en-v1.5",
49 }
50 }
51
52 fn cli_name(&self) -> &'static str {
53 match self {
54 EmbeddingModel::BgeSmall => "bge-small",
55 EmbeddingModel::BgeBase => "bge-base",
56 EmbeddingModel::Nomic => "nomic",
57 EmbeddingModel::GteLarge => "gte-large",
58 }
59 }
60
61 fn dimensions(&self) -> usize {
62 match self {
63 EmbeddingModel::BgeSmall => 384,
64 EmbeddingModel::BgeBase => 768,
65 EmbeddingModel::Nomic => 768,
66 EmbeddingModel::GteLarge => 1024,
67 }
68 }
69
70 fn size_mb(&self) -> usize {
71 match self {
72 EmbeddingModel::BgeSmall => 33,
73 EmbeddingModel::BgeBase => 110,
74 EmbeddingModel::Nomic => 137,
75 EmbeddingModel::GteLarge => 327,
76 }
77 }
78
79 fn hf_repo(&self) -> &'static str {
80 match self {
81 EmbeddingModel::BgeSmall => "BAAI/bge-small-en-v1.5",
82 EmbeddingModel::BgeBase => "BAAI/bge-base-en-v1.5",
83 EmbeddingModel::Nomic => "nomic-ai/nomic-embed-text-v1.5",
84 EmbeddingModel::GteLarge => "thenlper/gte-large",
85 }
86 }
87
88 fn is_default(&self) -> bool {
89 matches!(self, EmbeddingModel::BgeSmall)
90 }
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum RerankerModel {
100 JinaTurbo,
102 JinaMultilingual,
104 BgeRerankerBase,
106 BgeRerankerV2M3,
108}
109
110impl RerankerModel {
111 fn all() -> impl Iterator<Item = RerankerModel> {
112 [
113 RerankerModel::JinaTurbo,
114 RerankerModel::JinaMultilingual,
115 RerankerModel::BgeRerankerBase,
116 RerankerModel::BgeRerankerV2M3,
117 ]
118 .into_iter()
119 }
120
121 fn display_name(&self) -> &'static str {
122 match self {
123 RerankerModel::JinaTurbo => "Jina-reranker-v1-turbo-en",
124 RerankerModel::JinaMultilingual => "Jina-reranker-v2-base-multilingual",
125 RerankerModel::BgeRerankerBase => "BGE-reranker-base",
126 RerankerModel::BgeRerankerV2M3 => "BGE-reranker-v2-m3",
127 }
128 }
129
130 fn cli_name(&self) -> &'static str {
131 match self {
132 RerankerModel::JinaTurbo => "jina-turbo",
133 RerankerModel::JinaMultilingual => "jina-multilingual",
134 RerankerModel::BgeRerankerBase => "bge-reranker-base",
135 RerankerModel::BgeRerankerV2M3 => "bge-reranker-v2-m3",
136 }
137 }
138
139 fn size_mb(&self) -> usize {
140 match self {
141 RerankerModel::JinaTurbo => 86,
142 RerankerModel::JinaMultilingual => 200,
143 RerankerModel::BgeRerankerBase => 200,
144 RerankerModel::BgeRerankerV2M3 => 400,
145 }
146 }
147
148 fn hf_repo(&self) -> &'static str {
149 match self {
150 RerankerModel::JinaTurbo => "jinaai/jina-reranker-v1-turbo-en",
151 RerankerModel::JinaMultilingual => "jinaai/jina-reranker-v2-base-multilingual",
152 RerankerModel::BgeRerankerBase => "BAAI/bge-reranker-base",
153 RerankerModel::BgeRerankerV2M3 => "rozgo/bge-reranker-v2-m3",
154 }
155 }
156
157 fn language(&self) -> &'static str {
158 match self {
159 RerankerModel::JinaTurbo => "English",
160 RerankerModel::JinaMultilingual => "Multilingual",
161 RerankerModel::BgeRerankerBase => "English/Chinese",
162 RerankerModel::BgeRerankerV2M3 => "Multilingual",
163 }
164 }
165
166 fn is_default(&self) -> bool {
167 matches!(self, RerankerModel::JinaTurbo)
168 }
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
177pub enum LlmModel {
178 #[value(name = "phi-3.5-mini")]
180 Phi35Mini,
181 #[value(name = "phi-3.5-mini-q8")]
183 Phi35MiniQ8,
184}
185
186impl LlmModel {
187 fn hf_repo(&self) -> &'static str {
189 match self {
190 LlmModel::Phi35Mini | LlmModel::Phi35MiniQ8 => "bartowski/Phi-3.5-mini-instruct-GGUF",
191 }
192 }
193
194 fn hf_filename(&self) -> &'static str {
196 match self {
197 LlmModel::Phi35Mini => "Phi-3.5-mini-instruct-Q4_K_M.gguf",
198 LlmModel::Phi35MiniQ8 => "Phi-3.5-mini-instruct-Q8_0.gguf",
199 }
200 }
201
202 fn expected_size_bytes(&self) -> u64 {
204 match self {
205 LlmModel::Phi35Mini => 2_360_000_000, LlmModel::Phi35MiniQ8 => 3_860_000_000, }
208 }
209
210 fn local_dir_name(&self) -> &'static str {
212 match self {
213 LlmModel::Phi35Mini => "phi-3.5-mini-q4",
214 LlmModel::Phi35MiniQ8 => "phi-3.5-mini-q8",
215 }
216 }
217
218 fn display_name(&self) -> &'static str {
220 match self {
221 LlmModel::Phi35Mini => "Phi-3.5 Mini Instruct (Q4_K_M)",
222 LlmModel::Phi35MiniQ8 => "Phi-3.5 Mini Instruct (Q8_0)",
223 }
224 }
225
226 fn cli_name(&self) -> &'static str {
227 match self {
228 LlmModel::Phi35Mini => "phi-3.5-mini",
229 LlmModel::Phi35MiniQ8 => "phi-3.5-mini-q8",
230 }
231 }
232
233 fn all() -> impl Iterator<Item = LlmModel> {
235 [LlmModel::Phi35Mini, LlmModel::Phi35MiniQ8].into_iter()
236 }
237
238 fn is_default(&self) -> bool {
239 matches!(self, LlmModel::Phi35Mini)
240 }
241}
242
243#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
249pub enum ClipModel {
250 #[value(name = "mobileclip-s2")]
252 MobileClipS2,
253 #[value(name = "mobileclip-s2-fp16")]
255 MobileClipS2Fp16,
256 #[value(name = "siglip-base")]
258 SigLipBase,
259}
260
261impl ClipModel {
262 fn all() -> impl Iterator<Item = ClipModel> {
263 [
264 ClipModel::MobileClipS2,
265 ClipModel::MobileClipS2Fp16,
266 ClipModel::SigLipBase,
267 ]
268 .into_iter()
269 }
270
271 fn display_name(&self) -> &'static str {
272 match self {
273 ClipModel::MobileClipS2 => "MobileCLIP-S2 (int8 quantized)",
274 ClipModel::MobileClipS2Fp16 => "MobileCLIP-S2 (fp16)",
275 ClipModel::SigLipBase => "SigLIP-base (quantized)",
276 }
277 }
278
279 fn cli_name(&self) -> &'static str {
280 match self {
281 ClipModel::MobileClipS2 => "mobileclip-s2",
282 ClipModel::MobileClipS2Fp16 => "mobileclip-s2-fp16",
283 ClipModel::SigLipBase => "siglip-base",
284 }
285 }
286
287 fn dimensions(&self) -> usize {
288 match self {
289 ClipModel::MobileClipS2 | ClipModel::MobileClipS2Fp16 => 512,
290 ClipModel::SigLipBase => 768,
291 }
292 }
293
294 fn total_size_mb(&self) -> f32 {
295 match self {
296 ClipModel::MobileClipS2 => 100.8, ClipModel::MobileClipS2Fp16 => 198.7, ClipModel::SigLipBase => 210.5, }
300 }
301
302 fn vision_url(&self) -> &'static str {
303 match self {
304 ClipModel::MobileClipS2 => "https://huggingface.co/Xenova/mobileclip_s2/resolve/main/onnx/vision_model_int8.onnx",
305 ClipModel::MobileClipS2Fp16 => "https://huggingface.co/Xenova/mobileclip_s2/resolve/main/onnx/vision_model_fp16.onnx",
306 ClipModel::SigLipBase => "https://huggingface.co/Xenova/siglip-base-patch16-224/resolve/main/onnx/vision_model_quantized.onnx",
307 }
308 }
309
310 fn text_url(&self) -> &'static str {
311 match self {
312 ClipModel::MobileClipS2 => "https://huggingface.co/Xenova/mobileclip_s2/resolve/main/onnx/text_model_int8.onnx",
313 ClipModel::MobileClipS2Fp16 => "https://huggingface.co/Xenova/mobileclip_s2/resolve/main/onnx/text_model_fp16.onnx",
314 ClipModel::SigLipBase => "https://huggingface.co/Xenova/siglip-base-patch16-224/resolve/main/onnx/text_model_quantized.onnx",
315 }
316 }
317
318 fn vision_filename(&self) -> &'static str {
319 match self {
320 ClipModel::MobileClipS2 => "mobileclip-s2_vision.onnx",
321 ClipModel::MobileClipS2Fp16 => "mobileclip-s2-fp16_vision.onnx",
322 ClipModel::SigLipBase => "siglip-base_vision.onnx",
323 }
324 }
325
326 fn text_filename(&self) -> &'static str {
327 match self {
328 ClipModel::MobileClipS2 => "mobileclip-s2_text.onnx",
329 ClipModel::MobileClipS2Fp16 => "mobileclip-s2-fp16_text.onnx",
330 ClipModel::SigLipBase => "siglip-base_text.onnx",
331 }
332 }
333
334 fn is_default(&self) -> bool {
335 matches!(self, ClipModel::MobileClipS2)
336 }
337}
338
339#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
345pub enum NerModel {
346 #[value(name = "distilbert-ner")]
348 DistilbertNer,
349}
350
351impl NerModel {
352 fn all() -> impl Iterator<Item = NerModel> {
353 [NerModel::DistilbertNer].into_iter()
354 }
355
356 fn display_name(&self) -> &'static str {
357 match self {
358 NerModel::DistilbertNer => "DistilBERT-NER (dslim)",
359 }
360 }
361
362 fn cli_name(&self) -> &'static str {
363 match self {
364 NerModel::DistilbertNer => "distilbert-ner",
365 }
366 }
367
368 fn size_mb(&self) -> f32 {
369 match self {
370 NerModel::DistilbertNer => 261.0,
371 }
372 }
373
374 fn model_url(&self) -> &'static str {
375 match self {
376 NerModel::DistilbertNer => "https://huggingface.co/dslim/distilbert-NER/resolve/main/onnx/model.onnx",
377 }
378 }
379
380 fn tokenizer_url(&self) -> &'static str {
381 match self {
382 NerModel::DistilbertNer => "https://huggingface.co/dslim/distilbert-NER/resolve/main/tokenizer.json",
383 }
384 }
385
386 fn local_dir_name(&self) -> &'static str {
387 match self {
388 NerModel::DistilbertNer => "distilbert-ner",
389 }
390 }
391
392 fn is_default(&self) -> bool {
393 matches!(self, NerModel::DistilbertNer)
394 }
395}
396
397#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
404pub enum WhisperModel {
405 #[value(name = "whisper-small-en")]
407 WhisperSmallEn,
408}
409
410struct ExternalEmbeddingProvider {
418 name: &'static str,
419 models: &'static [(&'static str, usize, &'static str)], env_var: &'static str,
421}
422
423const EXTERNAL_EMBEDDING_PROVIDERS: &[ExternalEmbeddingProvider] = &[
424 ExternalEmbeddingProvider {
425 name: "OpenAI",
426 models: &[
427 ("text-embedding-3-large", 3072, "Highest quality"),
428 ("text-embedding-3-small", 1536, "Good balance"),
429 ("text-embedding-ada-002", 1536, "Legacy"),
430 ],
431 env_var: "OPENAI_API_KEY",
432 },
433 ExternalEmbeddingProvider {
434 name: "Cohere",
435 models: &[
436 ("embed-english-v3.0", 1024, "English"),
437 ("embed-multilingual-v3.0", 1024, "Multilingual"),
438 ],
439 env_var: "COHERE_API_KEY",
440 },
441 ExternalEmbeddingProvider {
442 name: "Voyage",
443 models: &[
444 ("voyage-3", 1024, "Code & technical docs"),
445 ("voyage-3-lite", 512, "Lightweight"),
446 ],
447 env_var: "VOYAGE_API_KEY",
448 },
449];
450
451#[derive(Args)]
457pub struct ModelsArgs {
458 #[command(subcommand)]
459 pub command: ModelsCommand,
460}
461
462#[derive(Subcommand)]
463pub enum ModelsCommand {
464 Install(ModelsInstallArgs),
466 List(ModelsListArgs),
468 Remove(ModelsRemoveArgs),
470 Verify(ModelsVerifyArgs),
472}
473
474#[derive(Args)]
475pub struct ModelsInstallArgs {
476 #[arg(value_enum, group = "model_choice")]
478 pub model: Option<LlmModel>,
479
480 #[arg(long, value_enum, group = "model_choice")]
482 pub clip: Option<ClipModel>,
483
484 #[arg(long, value_enum, group = "model_choice")]
486 pub ner: Option<NerModel>,
487
488 #[arg(long, value_enum, group = "model_choice", hide = true)]
490 pub whisper: Option<WhisperModel>,
491
492 #[arg(long, short)]
494 pub force: bool,
495}
496
497#[derive(Args)]
498pub struct ModelsListArgs {
499 #[arg(long)]
501 pub json: bool,
502
503 #[arg(long, value_enum)]
505 pub model_type: Option<ModelType>,
506}
507
508#[derive(Debug, Clone, Copy, ValueEnum)]
509pub enum ModelType {
510 Embedding,
512 Reranker,
514 Llm,
516 Clip,
518 Ner,
520 Whisper,
522 External,
524}
525
526#[derive(Args)]
527pub struct ModelsRemoveArgs {
528 #[arg(value_enum)]
530 pub model: LlmModel,
531
532 #[arg(long, short)]
534 pub yes: bool,
535}
536
537#[derive(Args)]
538pub struct ModelsVerifyArgs {
539 #[arg(value_enum)]
541 pub model: Option<LlmModel>,
542}
543
544#[derive(Debug, Clone, serde::Serialize)]
546pub struct InstalledModel {
547 pub name: String,
548 pub model_type: String,
549 pub path: PathBuf,
550 pub size_bytes: u64,
551 pub verified: bool,
552}
553
554fn llm_models_dir(config: &CliConfig) -> PathBuf {
560 config.models_dir.join("llm")
561}
562
563#[cfg(feature = "local-embeddings")]
565fn fastembed_cache_dir(config: &CliConfig) -> PathBuf {
566 config.models_dir.clone()
567}
568
569fn llm_model_path(config: &CliConfig, model: LlmModel) -> PathBuf {
571 llm_models_dir(config)
572 .join(model.local_dir_name())
573 .join(model.hf_filename())
574}
575
576fn is_llm_model_installed(config: &CliConfig, model: LlmModel) -> bool {
578 let path = llm_model_path(config, model);
579 path.exists() && path.is_file()
580}
581
582fn clip_models_dir(config: &CliConfig) -> PathBuf {
584 config.models_dir.clone()
585}
586
587fn clip_vision_path(config: &CliConfig, model: ClipModel) -> PathBuf {
589 clip_models_dir(config).join(model.vision_filename())
590}
591
592fn clip_text_path(config: &CliConfig, model: ClipModel) -> PathBuf {
594 clip_models_dir(config).join(model.text_filename())
595}
596
597fn is_clip_model_installed(config: &CliConfig, model: ClipModel) -> bool {
599 let vision_path = clip_vision_path(config, model);
600 let text_path = clip_text_path(config, model);
601 vision_path.exists() && vision_path.is_file() && text_path.exists() && text_path.is_file()
602}
603
604fn clip_model_status(config: &CliConfig, model: ClipModel) -> (&'static str, bool, bool) {
606 let has_vision = clip_vision_path(config, model).exists();
607 let has_text = clip_text_path(config, model).exists();
608 let status = match (has_vision, has_text) {
609 (true, true) => "✓ installed",
610 (true, false) => "⚠ partial (missing text)",
611 (false, true) => "⚠ partial (missing vision)",
612 (false, false) => "○ available",
613 };
614 (status, has_vision, has_text)
615}
616
617fn ner_models_dir(config: &CliConfig) -> PathBuf {
619 config.models_dir.clone()
620}
621
622fn ner_model_path(config: &CliConfig, model: NerModel) -> PathBuf {
624 ner_models_dir(config)
625 .join(model.local_dir_name())
626 .join("model.onnx")
627}
628
629fn ner_tokenizer_path(config: &CliConfig, model: NerModel) -> PathBuf {
631 ner_models_dir(config)
632 .join(model.local_dir_name())
633 .join("tokenizer.json")
634}
635
636fn is_ner_model_installed(config: &CliConfig, model: NerModel) -> bool {
638 let model_path = ner_model_path(config, model);
639 let tokenizer_path = ner_tokenizer_path(config, model);
640 model_path.exists() && model_path.is_file() && tokenizer_path.exists() && tokenizer_path.is_file()
641}
642
643fn ner_model_status(config: &CliConfig, model: NerModel) -> (&'static str, bool, bool) {
645 let has_model = ner_model_path(config, model).exists();
646 let has_tokenizer = ner_tokenizer_path(config, model).exists();
647 let status = match (has_model, has_tokenizer) {
648 (true, true) => "✓ installed",
649 (true, false) => "⚠ partial (missing tokenizer)",
650 (false, true) => "⚠ partial (missing model)",
651 (false, false) => "○ available",
652 };
653 (status, has_model, has_tokenizer)
654}
655
656#[cfg(feature = "local-embeddings")]
661fn scan_fastembed_cache(config: &CliConfig) -> Vec<(String, PathBuf, u64)> {
662 let cache_dir = fastembed_cache_dir(config);
663 let mut installed = Vec::new();
664
665 if let Ok(entries) = fs::read_dir(&cache_dir) {
666 for entry in entries.flatten() {
667 let path = entry.path();
668 if path.is_dir() {
669 let name = path.file_name().unwrap_or_default().to_string_lossy();
670 if name.starts_with("models--") {
672 let size = dir_size(&path).unwrap_or(0);
673 let model_name = name.replace("models--", "").replace("--", "/");
674 installed.push((model_name, path, size));
675 }
676 }
677 }
678 }
679
680 installed
681}
682
683#[cfg(not(feature = "local-embeddings"))]
685fn scan_fastembed_cache(_config: &CliConfig) -> Vec<(String, PathBuf, u64)> {
686 Vec::new()
687}
688
689fn dir_size(path: &Path) -> io::Result<u64> {
691 let mut size = 0;
692 if path.is_dir() {
693 for entry in fs::read_dir(path)? {
694 let entry = entry?;
695 let path = entry.path();
696 if path.is_dir() {
697 size += dir_size(&path)?;
698 } else {
699 size += entry.metadata()?.len();
700 }
701 }
702 }
703 Ok(size)
704}
705
706pub fn handle_models(config: &CliConfig, args: ModelsArgs) -> Result<()> {
712 match args.command {
713 ModelsCommand::Install(install_args) => handle_models_install(config, install_args),
714 ModelsCommand::List(list_args) => handle_models_list(config, list_args),
715 ModelsCommand::Remove(remove_args) => handle_models_remove(config, remove_args),
716 ModelsCommand::Verify(verify_args) => handle_models_verify(config, verify_args),
717 }
718}
719
720pub fn handle_models_install(config: &CliConfig, args: ModelsInstallArgs) -> Result<()> {
722 if let Some(clip_model) = args.clip {
724 return handle_clip_install(config, clip_model, args.force);
725 }
726
727 if let Some(ner_model) = args.ner {
729 return handle_ner_install(config, ner_model, args.force);
730 }
731
732 if args.whisper.is_some() {
734 println!("ℹ️ Whisper models now auto-download from HuggingFace on first use.");
735 println!(" No manual installation required!");
736 println!();
737 println!(" Just use: memvid put file.mv2 --input audio.mp3 --transcribe");
738 println!();
739 println!(" The model will download automatically (~244 MB for whisper-small-en).");
740 return Ok(());
741 }
742
743 if let Some(llm_model) = args.model {
745 return handle_llm_install(config, llm_model, args.force);
746 }
747
748 bail!(
750 "Please specify a model to install:\n\
751 \n\
752 LLM models (for local inference):\n\
753 \x20 memvid models install phi-3.5-mini\n\
754 \x20 memvid models install phi-3.5-mini-q8\n\
755 \n\
756 CLIP models (for visual search):\n\
757 \x20 memvid models install --clip mobileclip-s2\n\
758 \x20 memvid models install --clip mobileclip-s2-fp16\n\
759 \x20 memvid models install --clip siglip-base\n\
760 \n\
761 NER models (for Logic-Mesh entity extraction):\n\
762 \x20 memvid models install --ner distilbert-ner\n\
763 \n\
764 Note: Whisper models auto-download on first use (no install needed)"
765 );
766}
767
768fn handle_clip_install(config: &CliConfig, model: ClipModel, force: bool) -> Result<()> {
770 let vision_path = clip_vision_path(config, model);
771 let text_path = clip_text_path(config, model);
772
773 if is_clip_model_installed(config, model) && !force {
774 println!(
775 "{} is already installed at {}",
776 model.display_name(),
777 clip_models_dir(config).display()
778 );
779 println!("Use --force to re-download.");
780 return Ok(());
781 }
782
783 if config.offline {
784 bail!(
785 "Cannot install models while offline (MEMVID_OFFLINE=1). \
786 Run without MEMVID_OFFLINE to download the model."
787 );
788 }
789
790 fs::create_dir_all(clip_models_dir(config))?;
792
793 println!("Installing {}...", model.display_name());
794 println!("Dimensions: {}", model.dimensions());
795 println!("Total size: {:.1} MB", model.total_size_mb());
796 println!();
797
798 println!("Downloading vision encoder...");
800 download_file(model.vision_url(), &vision_path)?;
801
802 println!();
804 println!("Downloading text encoder...");
805 download_file(model.text_url(), &text_path)?;
806
807 let vision_size = fs::metadata(&vision_path).map(|m| m.len()).unwrap_or(0);
809 let text_size = fs::metadata(&text_path).map(|m| m.len()).unwrap_or(0);
810 let total_size = vision_size + text_size;
811
812 println!();
813 println!(
814 "Successfully installed {} ({:.1} MB)",
815 model.display_name(),
816 total_size as f64 / 1_000_000.0
817 );
818 println!("Vision encoder: {}", vision_path.display());
819 println!("Text encoder: {}", text_path.display());
820 println!();
821 println!("Usage:");
822 println!(" memvid put photos.mv2 --input ./images/ --clip");
823 println!(" memvid find photos.mv2 --query \"sunset over ocean\" --mode clip");
824
825 Ok(())
826}
827
828fn handle_ner_install(config: &CliConfig, model: NerModel, force: bool) -> Result<()> {
830 let model_path = ner_model_path(config, model);
831 let tokenizer_path = ner_tokenizer_path(config, model);
832
833 if is_ner_model_installed(config, model) && !force {
834 println!(
835 "{} is already installed at {}",
836 model.display_name(),
837 model_path.parent().unwrap_or(&model_path).display()
838 );
839 println!("Use --force to re-download.");
840 return Ok(());
841 }
842
843 if config.offline {
844 bail!(
845 "Cannot install models while offline (MEMVID_OFFLINE=1). \
846 Run without MEMVID_OFFLINE to download the model."
847 );
848 }
849
850 if let Some(parent) = model_path.parent() {
852 fs::create_dir_all(parent)?;
853 }
854
855 println!("Installing {}...", model.display_name());
856 println!("Size: {:.1} MB", model.size_mb());
857 println!();
858
859 println!("Downloading model...");
861 download_file(model.model_url(), &model_path)?;
862
863 println!();
865 println!("Downloading tokenizer...");
866 download_file(model.tokenizer_url(), &tokenizer_path)?;
867
868 let model_size = fs::metadata(&model_path).map(|m| m.len()).unwrap_or(0);
870 let tokenizer_size = fs::metadata(&tokenizer_path).map(|m| m.len()).unwrap_or(0);
871 let total_size = model_size + tokenizer_size;
872
873 println!();
874 println!(
875 "Successfully installed {} ({:.1} MB)",
876 model.display_name(),
877 total_size as f64 / 1_000_000.0
878 );
879 println!("Model: {}", model_path.display());
880 println!("Tokenizer: {}", tokenizer_path.display());
881 println!();
882 println!("Usage:");
883 println!(" memvid enrich file.mv2 --logic-mesh");
884 println!(" memvid follow traverse file.mv2 --start \"John\" --link manager");
885
886 Ok(())
887}
888
889fn handle_llm_install(config: &CliConfig, model: LlmModel, force: bool) -> Result<()> {
894 let target_path = llm_model_path(config, model);
895
896 if is_llm_model_installed(config, model) && !force {
897 println!(
898 "{} is already installed at {}",
899 model.display_name(),
900 target_path.display()
901 );
902 println!("Use --force to re-download.");
903 return Ok(());
904 }
905
906 if config.offline {
907 bail!(
908 "Cannot install models while offline (MEMVID_OFFLINE=1). \
909 Run without MEMVID_OFFLINE to download the model."
910 );
911 }
912
913 if let Some(parent) = target_path.parent() {
915 fs::create_dir_all(parent)?;
916 }
917
918 println!("Installing {}...", model.display_name());
919 println!("Repository: {}", model.hf_repo());
920 println!("File: {}", model.hf_filename());
921 println!(
922 "Expected size: {:.1} GB",
923 model.expected_size_bytes() as f64 / 1_000_000_000.0
924 );
925 println!();
926
927 download_llm_model(model, &target_path)?;
929
930 let metadata = fs::metadata(&target_path)?;
932 let size = metadata.len();
933
934 let min_size = (model.expected_size_bytes() as f64 * 0.9) as u64;
936 let max_size = (model.expected_size_bytes() as f64 * 1.1) as u64;
937
938 if size < min_size || size > max_size {
939 eprintln!(
940 "Warning: Downloaded file size ({:.2} GB) differs significantly from expected ({:.2} GB)",
941 size as f64 / 1_000_000_000.0,
942 model.expected_size_bytes() as f64 / 1_000_000_000.0
943 );
944 }
945
946 println!();
947 println!(
948 "Successfully installed {} ({:.2} GB)",
949 model.display_name(),
950 size as f64 / 1_000_000_000.0
951 );
952 println!("Location: {}", target_path.display());
953
954 Ok(())
955}
956
957fn download_file(url: &str, target_path: &Path) -> Result<()> {
959 println!("URL: {}", url);
960
961 let status = std::process::Command::new("curl")
962 .args([
963 "-L", "--progress-bar", "-o",
966 target_path
967 .to_str()
968 .ok_or_else(|| anyhow!("Invalid target path"))?,
969 url,
970 ])
971 .status()?;
972
973 if !status.success() {
974 let _ = fs::remove_file(target_path);
976 bail!("Download failed. Please check your internet connection and try again.");
977 }
978
979 Ok(())
980}
981
982fn download_llm_model(model: LlmModel, target_path: &Path) -> Result<()> {
984 let url = format!(
985 "https://huggingface.co/{}/resolve/main/{}",
986 model.hf_repo(),
987 model.hf_filename()
988 );
989
990 println!("Downloading from Hugging Face...");
991 download_file(&url, target_path)
992}
993
994pub fn handle_models_list(config: &CliConfig, args: ModelsListArgs) -> Result<()> {
996 let fastembed_installed = scan_fastembed_cache(config);
997
998 if args.json {
999 return handle_models_list_json(config, &fastembed_installed);
1000 }
1001
1002 let show_all = args.model_type.is_none();
1004 let show_embedding = show_all || matches!(args.model_type, Some(ModelType::Embedding));
1005 let show_reranker = show_all || matches!(args.model_type, Some(ModelType::Reranker));
1006 let show_llm = show_all || matches!(args.model_type, Some(ModelType::Llm));
1007 let show_clip = show_all || matches!(args.model_type, Some(ModelType::Clip));
1008 let show_ner = show_all || matches!(args.model_type, Some(ModelType::Ner));
1009 let show_whisper = show_all || matches!(args.model_type, Some(ModelType::Whisper));
1010 let show_external = show_all || matches!(args.model_type, Some(ModelType::External));
1011
1012 println!("╔══════════════════════════════════════════════════════════════════╗");
1013 println!("║ MEMVID MODEL CATALOG ║");
1014 println!("╚══════════════════════════════════════════════════════════════════╝");
1015 println!();
1016
1017 println!("Models Directory: {}", config.models_dir.display());
1019 println!();
1020
1021 if show_embedding {
1025 println!("┌──────────────────────────────────────────────────────────────────┐");
1026 println!("│ 📊 EMBEDDING MODELS (Semantic Search) │");
1027 println!("├──────────────────────────────────────────────────────────────────┤");
1028
1029 for model in EmbeddingModel::all() {
1030 let is_installed = fastembed_installed.iter().any(|(name, _, _)| {
1031 name.contains(&model.hf_repo().replace("/", "--").replace("--", "/"))
1032 });
1033
1034 let status = if is_installed {
1035 "✓ installed"
1036 } else {
1037 "○ available"
1038 };
1039 let default_marker = if model.is_default() { " (default)" } else { "" };
1040
1041 println!(
1042 "│ {:20} {:4}D {:>4} MB {:15}{}",
1043 model.cli_name(),
1044 model.dimensions(),
1045 model.size_mb(),
1046 status,
1047 default_marker
1048 );
1049 }
1050
1051 println!("│ │");
1052 println!("│ Usage: memvid put mem.mv2 --input doc.pdf --embedding │");
1053 println!("│ --embedding-model nomic │");
1054 println!("└──────────────────────────────────────────────────────────────────┘");
1055 println!();
1056 }
1057
1058 if show_reranker {
1062 println!("┌──────────────────────────────────────────────────────────────────┐");
1063 println!("│ 🔄 RERANKER MODELS (Result Reranking) │");
1064 println!("├──────────────────────────────────────────────────────────────────┤");
1065
1066 for model in RerankerModel::all() {
1067 let is_installed = fastembed_installed.iter().any(|(name, _, _)| {
1068 let repo = model.hf_repo();
1069 name.to_lowercase()
1070 .contains(&repo.to_lowercase().replace("/", "--").replace("--", "/"))
1071 || name
1072 .to_lowercase()
1073 .contains(&repo.split('/').last().unwrap_or("").to_lowercase())
1074 });
1075
1076 let status = if is_installed {
1077 "✓ installed"
1078 } else {
1079 "○ available"
1080 };
1081 let default_marker = if model.is_default() { " (default)" } else { "" };
1082
1083 println!(
1084 "│ {:25} {:>4} MB {:12} {:12}{}",
1085 model.cli_name(),
1086 model.size_mb(),
1087 model.language(),
1088 status,
1089 default_marker
1090 );
1091 }
1092
1093 println!("│ │");
1094 println!("│ Reranking is automatic in hybrid search mode (--mode auto) │");
1095 println!("└──────────────────────────────────────────────────────────────────┘");
1096 println!();
1097 }
1098
1099 if show_llm {
1103 println!("┌──────────────────────────────────────────────────────────────────┐");
1104 println!("│ 🤖 LLM MODELS (Local Inference) │");
1105 println!("├──────────────────────────────────────────────────────────────────┤");
1106
1107 for model in LlmModel::all() {
1108 let is_installed = is_llm_model_installed(config, model);
1109 let status = if is_installed {
1110 "✓ installed"
1111 } else {
1112 "○ available"
1113 };
1114 let default_marker = if model.is_default() { " (default)" } else { "" };
1115
1116 println!(
1117 "│ {:20} {:>5.1} GB {:15}{}",
1118 model.cli_name(),
1119 model.expected_size_bytes() as f64 / 1_000_000_000.0,
1120 status,
1121 default_marker
1122 );
1123
1124 if is_installed {
1125 println!("│ Path: {}", llm_model_path(config, model).display());
1126 }
1127 }
1128
1129 println!("│ │");
1130 println!("│ Install: memvid models install phi-3.5-mini │");
1131 println!("│ Usage: memvid ask file.mv2 --question \"...\" --model candle:phi │");
1132 println!("└──────────────────────────────────────────────────────────────────┘");
1133 println!();
1134 }
1135
1136 if show_clip {
1140 println!("┌──────────────────────────────────────────────────────────────────┐");
1141 println!("│ 🖼️ CLIP MODELS (Visual Search) │");
1142 println!("├──────────────────────────────────────────────────────────────────┤");
1143
1144 for model in ClipModel::all() {
1145 let (status, _, _) = clip_model_status(config, model);
1146 let default_marker = if model.is_default() { " (default)" } else { "" };
1147
1148 println!(
1149 "│ {:20} {:4}D {:>6.1} MB {:15}{}",
1150 model.cli_name(),
1151 model.dimensions(),
1152 model.total_size_mb(),
1153 status,
1154 default_marker
1155 );
1156 }
1157
1158 println!("│ │");
1159 println!("│ Install: memvid models install --clip mobileclip-s2 │");
1160 println!("│ Usage: memvid put photos.mv2 --input ./images/ --clip │");
1161 println!("│ memvid find photos.mv2 --query \"sunset\" --mode clip │");
1162 println!("└──────────────────────────────────────────────────────────────────┘");
1163 println!();
1164 }
1165
1166 if show_ner {
1170 println!("┌──────────────────────────────────────────────────────────────────┐");
1171 println!("│ 🔗 NER MODELS (Logic-Mesh Entity Extraction) │");
1172 println!("├──────────────────────────────────────────────────────────────────┤");
1173
1174 for model in NerModel::all() {
1175 let (status, _, _) = ner_model_status(config, model);
1176 let default_marker = if model.is_default() { " (default)" } else { "" };
1177
1178 println!(
1179 "│ {:20} {:>6.1} MB {:15}{}",
1180 model.cli_name(),
1181 model.size_mb(),
1182 status,
1183 default_marker
1184 );
1185 }
1186
1187 println!("│ │");
1188 println!("│ Install: memvid models install --ner distilbert-ner │");
1189 println!("│ Usage: memvid put file.mv2 --input doc.txt --logic-mesh │");
1190 println!("│ memvid follow traverse file.mv2 --start \"John\" │");
1191 println!("└──────────────────────────────────────────────────────────────────┘");
1192 println!();
1193 }
1194
1195 if show_whisper {
1199 println!("┌──────────────────────────────────────────────────────────────────┐");
1200 println!("│ 🎙️ WHISPER MODELS (Audio Transcription via Candle) │");
1201 println!("├──────────────────────────────────────────────────────────────────┤");
1202 println!("│ whisper-small-en 244 MB Auto-download (default) │");
1203 println!("│ whisper-small 244 MB Auto-download multilingual │");
1204 println!("│ whisper-tiny-en 75 MB Auto-download fastest │");
1205 println!("│ whisper-base-en 145 MB Auto-download │");
1206 println!("│ │");
1207 println!("│ Models download automatically from HuggingFace on first use. │");
1208 println!("│ GPU acceleration: --features metal (Mac) or --features cuda │");
1209 println!("│ │");
1210 println!("│ Usage: memvid put file.mv2 --input audio.mp3 --transcribe │");
1211 println!("└──────────────────────────────────────────────────────────────────┘");
1212 println!();
1213 }
1214
1215 if show_external {
1219 println!("┌──────────────────────────────────────────────────────────────────┐");
1220 println!("│ ☁️ EXTERNAL MODELS (API-based, no download required) │");
1221 println!("├──────────────────────────────────────────────────────────────────┤");
1222
1223 for provider in EXTERNAL_EMBEDDING_PROVIDERS {
1224 let api_key_set = std::env::var(provider.env_var).is_ok();
1225 let key_status = if api_key_set {
1226 format!("{} ✓", provider.env_var)
1227 } else {
1228 format!("{} ○", provider.env_var)
1229 };
1230
1231 println!("│ {} ({}):", provider.name, key_status);
1232
1233 for (model_name, dim, desc) in provider.models.iter() {
1234 println!("│ {:30} {:4}D {}", model_name, dim, desc);
1235 }
1236 println!("│");
1237 }
1238
1239 println!("│ Usage: export OPENAI_API_KEY=sk-... │");
1240 println!("│ memvid put mem.mv2 --input doc.pdf --embedding │");
1241 println!("│ --embedding-model openai-small │");
1242 println!("└──────────────────────────────────────────────────────────────────┘");
1243 println!();
1244 }
1245
1246 if !fastembed_installed.is_empty() {
1250 println!("┌──────────────────────────────────────────────────────────────────┐");
1251 println!(
1252 "│ 📦 INSTALLED MODELS (cached in {}) │",
1253 config.models_dir.display()
1254 );
1255 println!("├──────────────────────────────────────────────────────────────────┤");
1256
1257 let mut total_size: u64 = 0;
1258
1259 for (name, _path, size) in &fastembed_installed {
1260 total_size += size;
1261 println!(
1262 "│ {:40} {:>8.1} MB",
1263 if name.len() > 40 {
1264 format!("{}...", &name[..37])
1265 } else {
1266 name.clone()
1267 },
1268 *size as f64 / 1_000_000.0
1269 );
1270 }
1271
1272 for model in LlmModel::all() {
1274 if is_llm_model_installed(config, model) {
1275 let path = llm_model_path(config, model);
1276 if let Ok(meta) = fs::metadata(&path) {
1277 total_size += meta.len();
1278 println!(
1279 "│ {:40} {:>8.1} MB",
1280 model.display_name(),
1281 meta.len() as f64 / 1_000_000.0
1282 );
1283 }
1284 }
1285 }
1286
1287 println!("├──────────────────────────────────────────────────────────────────┤");
1288 println!("│ Total: {:>55.1} MB │", total_size as f64 / 1_000_000.0);
1289 println!("└──────────────────────────────────────────────────────────────────┘");
1290 println!();
1291 }
1292
1293 println!("╔══════════════════════════════════════════════════════════════════╗");
1297 println!("║ QUICK REFERENCE ║");
1298 println!("╟──────────────────────────────────────────────────────────────────╢");
1299 println!("║ memvid models list List all models ║");
1300 println!("║ memvid models list --model-type llm List only LLM models ║");
1301 println!("║ memvid models install phi-3.5-mini Install LLM model ║");
1302 println!("║ memvid models remove phi-3.5-mini Remove LLM model ║");
1303 println!("║ memvid models verify Verify installed models ║");
1304 println!("╚══════════════════════════════════════════════════════════════════╝");
1305
1306 Ok(())
1307}
1308
1309fn handle_models_list_json(
1311 config: &CliConfig,
1312 fastembed_installed: &[(String, PathBuf, u64)],
1313) -> Result<()> {
1314 let output = serde_json::json!({
1315 "models_dir": config.models_dir,
1316 "embedding_models": EmbeddingModel::all().map(|m| {
1317 let is_installed = fastembed_installed
1318 .iter()
1319 .any(|(name, _, _)| name.contains(m.hf_repo()));
1320 serde_json::json!({
1321 "name": m.cli_name(),
1322 "display_name": m.display_name(),
1323 "dimensions": m.dimensions(),
1324 "size_mb": m.size_mb(),
1325 "hf_repo": m.hf_repo(),
1326 "installed": is_installed,
1327 "is_default": m.is_default(),
1328 })
1329 }).collect::<Vec<_>>(),
1330 "reranker_models": RerankerModel::all().map(|m| {
1331 serde_json::json!({
1332 "name": m.cli_name(),
1333 "display_name": m.display_name(),
1334 "size_mb": m.size_mb(),
1335 "hf_repo": m.hf_repo(),
1336 "language": m.language(),
1337 "is_default": m.is_default(),
1338 })
1339 }).collect::<Vec<_>>(),
1340 "llm_models": LlmModel::all().map(|m| {
1341 serde_json::json!({
1342 "name": m.cli_name(),
1343 "display_name": m.display_name(),
1344 "size_gb": m.expected_size_bytes() as f64 / 1_000_000_000.0,
1345 "hf_repo": m.hf_repo(),
1346 "installed": is_llm_model_installed(config, m),
1347 "path": if is_llm_model_installed(config, m) {
1348 Some(llm_model_path(config, m))
1349 } else {
1350 None
1351 },
1352 "is_default": m.is_default(),
1353 })
1354 }).collect::<Vec<_>>(),
1355 "external_providers": EXTERNAL_EMBEDDING_PROVIDERS.iter().map(|p| {
1356 serde_json::json!({
1357 "name": p.name,
1358 "env_var": p.env_var,
1359 "configured": std::env::var(p.env_var).is_ok(),
1360 "models": p.models.iter().map(|(name, dim, desc)| {
1361 serde_json::json!({
1362 "name": name,
1363 "dimensions": dim,
1364 "description": desc,
1365 })
1366 }).collect::<Vec<_>>(),
1367 })
1368 }).collect::<Vec<_>>(),
1369 "installed_cache": fastembed_installed.iter().map(|(name, path, size)| {
1370 serde_json::json!({
1371 "name": name,
1372 "path": path,
1373 "size_bytes": size,
1374 })
1375 }).collect::<Vec<_>>(),
1376 });
1377
1378 println!("{}", serde_json::to_string_pretty(&output)?);
1379 Ok(())
1380}
1381
1382pub fn handle_models_remove(config: &CliConfig, args: ModelsRemoveArgs) -> Result<()> {
1384 let model = args.model;
1385 let path = llm_model_path(config, model);
1386
1387 if !path.exists() {
1388 println!("{} is not installed.", model.display_name());
1389 return Ok(());
1390 }
1391
1392 if !args.yes {
1393 print!(
1394 "Remove {} ({})? [y/N] ",
1395 model.display_name(),
1396 path.display()
1397 );
1398 io::stdout().flush()?;
1399
1400 let mut input = String::new();
1401 io::stdin().read_line(&mut input)?;
1402
1403 if !input.trim().eq_ignore_ascii_case("y") {
1404 println!("Aborted.");
1405 return Ok(());
1406 }
1407 }
1408
1409 fs::remove_file(&path)?;
1410
1411 if let Some(parent) = path.parent() {
1413 let _ = fs::remove_dir(parent);
1414 }
1415
1416 println!("Removed {}.", model.display_name());
1417 Ok(())
1418}
1419
1420pub fn handle_models_verify(config: &CliConfig, args: ModelsVerifyArgs) -> Result<()> {
1422 let models_to_verify: Vec<LlmModel> = match args.model {
1423 Some(m) => vec![m],
1424 None => LlmModel::all()
1425 .filter(|m| is_llm_model_installed(config, *m))
1426 .collect(),
1427 };
1428
1429 if models_to_verify.is_empty() {
1430 println!("No LLM models installed to verify.");
1431 return Ok(());
1432 }
1433
1434 let mut all_ok = true;
1435
1436 for model in models_to_verify {
1437 let path = llm_model_path(config, model);
1438 print!("Verifying {}... ", model.display_name());
1439 io::stdout().flush()?;
1440
1441 match verify_model_file(&path, model) {
1442 Ok(()) => println!("OK"),
1443 Err(err) => {
1444 println!("FAILED");
1445 eprintln!(" Error: {}", err);
1446 all_ok = false;
1447 }
1448 }
1449 }
1450
1451 if !all_ok {
1452 bail!("Some models failed verification.");
1453 }
1454
1455 Ok(())
1456}
1457
1458fn verify_model_file(path: &Path, model: LlmModel) -> Result<()> {
1460 if !path.exists() {
1461 bail!("Model file does not exist");
1462 }
1463
1464 let metadata = fs::metadata(path)?;
1465 let size = metadata.len();
1466
1467 let min_size = model.expected_size_bytes() / 2;
1469 if size < min_size {
1470 bail!(
1471 "Model file too small ({:.2} GB, expected at least {:.2} GB)",
1472 size as f64 / 1_000_000_000.0,
1473 min_size as f64 / 1_000_000_000.0
1474 );
1475 }
1476
1477 let mut file = fs::File::open(path)?;
1479 let mut magic = [0u8; 4];
1480 io::Read::read_exact(&mut file, &mut magic)?;
1481
1482 if &magic != b"GGUF" {
1484 bail!("Invalid GGUF file (bad magic bytes)");
1485 }
1486
1487 Ok(())
1488}
1489
1490pub fn get_installed_model_path(config: &CliConfig, model: LlmModel) -> Option<PathBuf> {
1492 let path = llm_model_path(config, model);
1493 if path.exists() && path.is_file() {
1494 Some(path)
1495 } else {
1496 None
1497 }
1498}
1499
1500pub fn default_enrichment_model() -> LlmModel {
1502 LlmModel::Phi35Mini
1503}