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 => {
377 "https://huggingface.co/dslim/distilbert-NER/resolve/main/onnx/model.onnx"
378 }
379 }
380 }
381
382 fn tokenizer_url(&self) -> &'static str {
383 match self {
384 NerModel::DistilbertNer => {
385 "https://huggingface.co/dslim/distilbert-NER/resolve/main/tokenizer.json"
386 }
387 }
388 }
389
390 fn local_dir_name(&self) -> &'static str {
391 match self {
392 NerModel::DistilbertNer => "distilbert-ner",
393 }
394 }
395
396 fn is_default(&self) -> bool {
397 matches!(self, NerModel::DistilbertNer)
398 }
399}
400
401#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
408pub enum WhisperModel {
409 #[value(name = "whisper-small-en")]
411 WhisperSmallEn,
412}
413
414struct ExternalEmbeddingProvider {
422 name: &'static str,
423 models: &'static [(&'static str, usize, &'static str)], env_var: &'static str,
425}
426
427const EXTERNAL_EMBEDDING_PROVIDERS: &[ExternalEmbeddingProvider] = &[
428 ExternalEmbeddingProvider {
429 name: "OpenAI",
430 models: &[
431 ("text-embedding-3-large", 3072, "Highest quality"),
432 ("text-embedding-3-small", 1536, "Good balance"),
433 ("text-embedding-ada-002", 1536, "Legacy"),
434 ],
435 env_var: "OPENAI_API_KEY",
436 },
437 ExternalEmbeddingProvider {
438 name: "Cohere",
439 models: &[
440 ("embed-english-v3.0", 1024, "English"),
441 ("embed-multilingual-v3.0", 1024, "Multilingual"),
442 ],
443 env_var: "COHERE_API_KEY",
444 },
445 ExternalEmbeddingProvider {
446 name: "Voyage",
447 models: &[
448 ("voyage-3", 1024, "Code & technical docs"),
449 ("voyage-3-lite", 512, "Lightweight"),
450 ],
451 env_var: "VOYAGE_API_KEY",
452 },
453];
454
455#[derive(Args)]
461pub struct ModelsArgs {
462 #[command(subcommand)]
463 pub command: ModelsCommand,
464}
465
466#[derive(Subcommand)]
467pub enum ModelsCommand {
468 Install(ModelsInstallArgs),
470 List(ModelsListArgs),
472 Remove(ModelsRemoveArgs),
474 Verify(ModelsVerifyArgs),
476}
477
478#[derive(Args)]
479pub struct ModelsInstallArgs {
480 #[arg(value_enum, group = "model_choice")]
482 pub model: Option<LlmModel>,
483
484 #[arg(long, value_enum, group = "model_choice")]
486 pub clip: Option<ClipModel>,
487
488 #[arg(long, value_enum, group = "model_choice")]
490 pub ner: Option<NerModel>,
491
492 #[arg(long, value_enum, group = "model_choice", hide = true)]
494 pub whisper: Option<WhisperModel>,
495
496 #[arg(long, short)]
498 pub force: bool,
499}
500
501#[derive(Args)]
502pub struct ModelsListArgs {
503 #[arg(long)]
505 pub json: bool,
506
507 #[arg(long, value_enum)]
509 pub model_type: Option<ModelType>,
510}
511
512#[derive(Debug, Clone, Copy, ValueEnum)]
513pub enum ModelType {
514 Embedding,
516 Reranker,
518 Llm,
520 Clip,
522 Ner,
524 Whisper,
526 External,
528}
529
530#[derive(Args)]
531pub struct ModelsRemoveArgs {
532 #[arg(value_enum)]
534 pub model: LlmModel,
535
536 #[arg(long, short)]
538 pub yes: bool,
539}
540
541#[derive(Args)]
542pub struct ModelsVerifyArgs {
543 #[arg(value_enum)]
545 pub model: Option<LlmModel>,
546}
547
548#[derive(Debug, Clone, serde::Serialize)]
550pub struct InstalledModel {
551 pub name: String,
552 pub model_type: String,
553 pub path: PathBuf,
554 pub size_bytes: u64,
555 pub verified: bool,
556}
557
558fn llm_models_dir(config: &CliConfig) -> PathBuf {
564 config.models_dir.join("llm")
565}
566
567#[cfg(feature = "local-embeddings")]
569fn fastembed_cache_dir(config: &CliConfig) -> PathBuf {
570 config.models_dir.clone()
571}
572
573fn llm_model_path(config: &CliConfig, model: LlmModel) -> PathBuf {
575 llm_models_dir(config)
576 .join(model.local_dir_name())
577 .join(model.hf_filename())
578}
579
580fn is_llm_model_installed(config: &CliConfig, model: LlmModel) -> bool {
582 let path = llm_model_path(config, model);
583 path.exists() && path.is_file()
584}
585
586fn clip_models_dir(config: &CliConfig) -> PathBuf {
588 config.models_dir.clone()
589}
590
591fn clip_vision_path(config: &CliConfig, model: ClipModel) -> PathBuf {
593 clip_models_dir(config).join(model.vision_filename())
594}
595
596fn clip_text_path(config: &CliConfig, model: ClipModel) -> PathBuf {
598 clip_models_dir(config).join(model.text_filename())
599}
600
601fn is_clip_model_installed(config: &CliConfig, model: ClipModel) -> bool {
603 let vision_path = clip_vision_path(config, model);
604 let text_path = clip_text_path(config, model);
605 vision_path.exists() && vision_path.is_file() && text_path.exists() && text_path.is_file()
606}
607
608fn clip_model_status(config: &CliConfig, model: ClipModel) -> (&'static str, bool, bool) {
610 let has_vision = clip_vision_path(config, model).exists();
611 let has_text = clip_text_path(config, model).exists();
612 let status = match (has_vision, has_text) {
613 (true, true) => "✓ installed",
614 (true, false) => "⚠ partial (missing text)",
615 (false, true) => "⚠ partial (missing vision)",
616 (false, false) => "○ available",
617 };
618 (status, has_vision, has_text)
619}
620
621fn ner_models_dir(config: &CliConfig) -> PathBuf {
623 config.models_dir.clone()
624}
625
626fn ner_model_path(config: &CliConfig, model: NerModel) -> PathBuf {
628 ner_models_dir(config)
629 .join(model.local_dir_name())
630 .join("model.onnx")
631}
632
633fn ner_tokenizer_path(config: &CliConfig, model: NerModel) -> PathBuf {
635 ner_models_dir(config)
636 .join(model.local_dir_name())
637 .join("tokenizer.json")
638}
639
640fn is_ner_model_installed(config: &CliConfig, model: NerModel) -> bool {
642 let model_path = ner_model_path(config, model);
643 let tokenizer_path = ner_tokenizer_path(config, model);
644 model_path.exists()
645 && model_path.is_file()
646 && tokenizer_path.exists()
647 && tokenizer_path.is_file()
648}
649
650fn ner_model_status(config: &CliConfig, model: NerModel) -> (&'static str, bool, bool) {
652 let has_model = ner_model_path(config, model).exists();
653 let has_tokenizer = ner_tokenizer_path(config, model).exists();
654 let status = match (has_model, has_tokenizer) {
655 (true, true) => "✓ installed",
656 (true, false) => "⚠ partial (missing tokenizer)",
657 (false, true) => "⚠ partial (missing model)",
658 (false, false) => "○ available",
659 };
660 (status, has_model, has_tokenizer)
661}
662
663#[cfg(feature = "local-embeddings")]
668fn scan_fastembed_cache(config: &CliConfig) -> Vec<(String, PathBuf, u64)> {
669 let cache_dir = fastembed_cache_dir(config);
670 let mut installed = Vec::new();
671
672 if let Ok(entries) = fs::read_dir(&cache_dir) {
673 for entry in entries.flatten() {
674 let path = entry.path();
675 if path.is_dir() {
676 let name = path.file_name().unwrap_or_default().to_string_lossy();
677 if name.starts_with("models--") {
679 let size = dir_size(&path).unwrap_or(0);
680 let model_name = name.replace("models--", "").replace("--", "/");
681 installed.push((model_name, path, size));
682 }
683 }
684 }
685 }
686
687 installed
688}
689
690#[cfg(not(feature = "local-embeddings"))]
692fn scan_fastembed_cache(_config: &CliConfig) -> Vec<(String, PathBuf, u64)> {
693 Vec::new()
694}
695
696fn dir_size(path: &Path) -> io::Result<u64> {
698 let mut size = 0;
699 if path.is_dir() {
700 for entry in fs::read_dir(path)? {
701 let entry = entry?;
702 let path = entry.path();
703 if path.is_dir() {
704 size += dir_size(&path)?;
705 } else {
706 size += entry.metadata()?.len();
707 }
708 }
709 }
710 Ok(size)
711}
712
713pub fn handle_models(config: &CliConfig, args: ModelsArgs) -> Result<()> {
719 match args.command {
720 ModelsCommand::Install(install_args) => handle_models_install(config, install_args),
721 ModelsCommand::List(list_args) => handle_models_list(config, list_args),
722 ModelsCommand::Remove(remove_args) => handle_models_remove(config, remove_args),
723 ModelsCommand::Verify(verify_args) => handle_models_verify(config, verify_args),
724 }
725}
726
727pub fn handle_models_install(config: &CliConfig, args: ModelsInstallArgs) -> Result<()> {
729 if let Some(clip_model) = args.clip {
731 return handle_clip_install(config, clip_model, args.force);
732 }
733
734 if let Some(ner_model) = args.ner {
736 return handle_ner_install(config, ner_model, args.force);
737 }
738
739 if args.whisper.is_some() {
741 println!("ℹ️ Whisper models now auto-download from HuggingFace on first use.");
742 println!(" No manual installation required!");
743 println!();
744 println!(" Just use: memvid put file.mv2 --input audio.mp3 --transcribe");
745 println!();
746 println!(" The model will download automatically (~244 MB for whisper-small-en).");
747 return Ok(());
748 }
749
750 if let Some(llm_model) = args.model {
752 return handle_llm_install(config, llm_model, args.force);
753 }
754
755 bail!(
757 "Please specify a model to install:\n\
758 \n\
759 LLM models (for local inference):\n\
760 \x20 memvid models install phi-3.5-mini\n\
761 \x20 memvid models install phi-3.5-mini-q8\n\
762 \n\
763 CLIP models (for visual search):\n\
764 \x20 memvid models install --clip mobileclip-s2\n\
765 \x20 memvid models install --clip mobileclip-s2-fp16\n\
766 \x20 memvid models install --clip siglip-base\n\
767 \n\
768 NER models (for Logic-Mesh entity extraction):\n\
769 \x20 memvid models install --ner distilbert-ner\n\
770 \n\
771 Note: Whisper models auto-download on first use (no install needed)"
772 );
773}
774
775fn handle_clip_install(config: &CliConfig, model: ClipModel, force: bool) -> Result<()> {
777 let vision_path = clip_vision_path(config, model);
778 let text_path = clip_text_path(config, model);
779
780 if is_clip_model_installed(config, model) && !force {
781 println!(
782 "{} is already installed at {}",
783 model.display_name(),
784 clip_models_dir(config).display()
785 );
786 println!("Use --force to re-download.");
787 return Ok(());
788 }
789
790 if config.offline {
791 bail!(
792 "Cannot install models while offline (MEMVID_OFFLINE=1). \
793 Run without MEMVID_OFFLINE to download the model."
794 );
795 }
796
797 fs::create_dir_all(clip_models_dir(config))?;
799
800 println!("Installing {}...", model.display_name());
801 println!("Dimensions: {}", model.dimensions());
802 println!("Total size: {:.1} MB", model.total_size_mb());
803 println!();
804
805 println!("Downloading vision encoder...");
807 download_file(model.vision_url(), &vision_path)?;
808
809 println!();
811 println!("Downloading text encoder...");
812 download_file(model.text_url(), &text_path)?;
813
814 let vision_size = fs::metadata(&vision_path).map(|m| m.len()).unwrap_or(0);
816 let text_size = fs::metadata(&text_path).map(|m| m.len()).unwrap_or(0);
817 let total_size = vision_size + text_size;
818
819 println!();
820 println!(
821 "Successfully installed {} ({:.1} MB)",
822 model.display_name(),
823 total_size as f64 / 1_000_000.0
824 );
825 println!("Vision encoder: {}", vision_path.display());
826 println!("Text encoder: {}", text_path.display());
827 println!();
828 println!("Usage:");
829 println!(" memvid put photos.mv2 --input ./images/ --clip");
830 println!(" memvid find photos.mv2 --query \"sunset over ocean\" --mode clip");
831
832 Ok(())
833}
834
835fn handle_ner_install(config: &CliConfig, model: NerModel, force: bool) -> Result<()> {
837 let model_path = ner_model_path(config, model);
838 let tokenizer_path = ner_tokenizer_path(config, model);
839
840 if is_ner_model_installed(config, model) && !force {
841 println!(
842 "{} is already installed at {}",
843 model.display_name(),
844 model_path.parent().unwrap_or(&model_path).display()
845 );
846 println!("Use --force to re-download.");
847 return Ok(());
848 }
849
850 if config.offline {
851 bail!(
852 "Cannot install models while offline (MEMVID_OFFLINE=1). \
853 Run without MEMVID_OFFLINE to download the model."
854 );
855 }
856
857 if let Some(parent) = model_path.parent() {
859 fs::create_dir_all(parent)?;
860 }
861
862 println!("Installing {}...", model.display_name());
863 println!("Size: {:.1} MB", model.size_mb());
864 println!();
865
866 println!("Downloading model...");
868 download_file(model.model_url(), &model_path)?;
869
870 println!();
872 println!("Downloading tokenizer...");
873 download_file(model.tokenizer_url(), &tokenizer_path)?;
874
875 let model_size = fs::metadata(&model_path).map(|m| m.len()).unwrap_or(0);
877 let tokenizer_size = fs::metadata(&tokenizer_path).map(|m| m.len()).unwrap_or(0);
878 let total_size = model_size + tokenizer_size;
879
880 println!();
881 println!(
882 "Successfully installed {} ({:.1} MB)",
883 model.display_name(),
884 total_size as f64 / 1_000_000.0
885 );
886 println!("Model: {}", model_path.display());
887 println!("Tokenizer: {}", tokenizer_path.display());
888 println!();
889 println!("Usage:");
890 println!(" memvid enrich file.mv2 --logic-mesh");
891 println!(" memvid follow traverse file.mv2 --start \"John\" --link manager");
892
893 Ok(())
894}
895
896fn handle_llm_install(config: &CliConfig, model: LlmModel, force: bool) -> Result<()> {
901 let target_path = llm_model_path(config, model);
902
903 if is_llm_model_installed(config, model) && !force {
904 println!(
905 "{} is already installed at {}",
906 model.display_name(),
907 target_path.display()
908 );
909 println!("Use --force to re-download.");
910 return Ok(());
911 }
912
913 if config.offline {
914 bail!(
915 "Cannot install models while offline (MEMVID_OFFLINE=1). \
916 Run without MEMVID_OFFLINE to download the model."
917 );
918 }
919
920 if let Some(parent) = target_path.parent() {
922 fs::create_dir_all(parent)?;
923 }
924
925 println!("Installing {}...", model.display_name());
926 println!("Repository: {}", model.hf_repo());
927 println!("File: {}", model.hf_filename());
928 println!(
929 "Expected size: {:.1} GB",
930 model.expected_size_bytes() as f64 / 1_000_000_000.0
931 );
932 println!();
933
934 download_llm_model(model, &target_path)?;
936
937 let metadata = fs::metadata(&target_path)?;
939 let size = metadata.len();
940
941 let min_size = (model.expected_size_bytes() as f64 * 0.9) as u64;
943 let max_size = (model.expected_size_bytes() as f64 * 1.1) as u64;
944
945 if size < min_size || size > max_size {
946 eprintln!(
947 "Warning: Downloaded file size ({:.2} GB) differs significantly from expected ({:.2} GB)",
948 size as f64 / 1_000_000_000.0,
949 model.expected_size_bytes() as f64 / 1_000_000_000.0
950 );
951 }
952
953 println!();
954 println!(
955 "Successfully installed {} ({:.2} GB)",
956 model.display_name(),
957 size as f64 / 1_000_000_000.0
958 );
959 println!("Location: {}", target_path.display());
960
961 Ok(())
962}
963
964fn download_file(url: &str, target_path: &Path) -> Result<()> {
966 println!("URL: {}", url);
967
968 let status = std::process::Command::new("curl")
969 .args([
970 "-L", "--progress-bar", "-o",
973 target_path
974 .to_str()
975 .ok_or_else(|| anyhow!("Invalid target path"))?,
976 url,
977 ])
978 .status()?;
979
980 if !status.success() {
981 let _ = fs::remove_file(target_path);
983 bail!("Download failed. Please check your internet connection and try again.");
984 }
985
986 Ok(())
987}
988
989fn download_llm_model(model: LlmModel, target_path: &Path) -> Result<()> {
991 let url = format!(
992 "https://huggingface.co/{}/resolve/main/{}",
993 model.hf_repo(),
994 model.hf_filename()
995 );
996
997 println!("Downloading from Hugging Face...");
998 download_file(&url, target_path)
999}
1000
1001pub fn handle_models_list(config: &CliConfig, args: ModelsListArgs) -> Result<()> {
1003 let fastembed_installed = scan_fastembed_cache(config);
1004
1005 if args.json {
1006 return handle_models_list_json(config, &fastembed_installed);
1007 }
1008
1009 let show_all = args.model_type.is_none();
1011 let show_embedding = show_all || matches!(args.model_type, Some(ModelType::Embedding));
1012 let show_reranker = show_all || matches!(args.model_type, Some(ModelType::Reranker));
1013 let show_llm = show_all || matches!(args.model_type, Some(ModelType::Llm));
1014 let show_clip = show_all || matches!(args.model_type, Some(ModelType::Clip));
1015 let show_ner = show_all || matches!(args.model_type, Some(ModelType::Ner));
1016 let show_whisper = show_all || matches!(args.model_type, Some(ModelType::Whisper));
1017 let show_external = show_all || matches!(args.model_type, Some(ModelType::External));
1018
1019 println!("╔══════════════════════════════════════════════════════════════════╗");
1020 println!("║ MEMVID MODEL CATALOG ║");
1021 println!("╚══════════════════════════════════════════════════════════════════╝");
1022 println!();
1023
1024 println!("Models Directory: {}", config.models_dir.display());
1026 println!();
1027
1028 if show_embedding {
1032 println!("┌──────────────────────────────────────────────────────────────────┐");
1033 println!("│ 📊 EMBEDDING MODELS (Semantic Search) │");
1034 println!("├──────────────────────────────────────────────────────────────────┤");
1035
1036 for model in EmbeddingModel::all() {
1037 let is_installed = fastembed_installed.iter().any(|(name, _, _)| {
1038 name.contains(&model.hf_repo().replace("/", "--").replace("--", "/"))
1039 });
1040
1041 let status = if is_installed {
1042 "✓ installed"
1043 } else {
1044 "○ available"
1045 };
1046 let default_marker = if model.is_default() { " (default)" } else { "" };
1047
1048 println!(
1049 "│ {:20} {:4}D {:>4} MB {:15}{}",
1050 model.cli_name(),
1051 model.dimensions(),
1052 model.size_mb(),
1053 status,
1054 default_marker
1055 );
1056 }
1057
1058 println!("│ │");
1059 println!("│ Usage: memvid put mem.mv2 --input doc.pdf --embedding │");
1060 println!("│ --embedding-model nomic │");
1061 println!("└──────────────────────────────────────────────────────────────────┘");
1062 println!();
1063 }
1064
1065 if show_reranker {
1069 println!("┌──────────────────────────────────────────────────────────────────┐");
1070 println!("│ 🔄 RERANKER MODELS (Result Reranking) │");
1071 println!("├──────────────────────────────────────────────────────────────────┤");
1072
1073 for model in RerankerModel::all() {
1074 let is_installed = fastembed_installed.iter().any(|(name, _, _)| {
1075 let repo = model.hf_repo();
1076 name.to_lowercase()
1077 .contains(&repo.to_lowercase().replace("/", "--").replace("--", "/"))
1078 || name
1079 .to_lowercase()
1080 .contains(&repo.split('/').last().unwrap_or("").to_lowercase())
1081 });
1082
1083 let status = if is_installed {
1084 "✓ installed"
1085 } else {
1086 "○ available"
1087 };
1088 let default_marker = if model.is_default() { " (default)" } else { "" };
1089
1090 println!(
1091 "│ {:25} {:>4} MB {:12} {:12}{}",
1092 model.cli_name(),
1093 model.size_mb(),
1094 model.language(),
1095 status,
1096 default_marker
1097 );
1098 }
1099
1100 println!("│ │");
1101 println!("│ Reranking is automatic in hybrid search mode (--mode auto) │");
1102 println!("└──────────────────────────────────────────────────────────────────┘");
1103 println!();
1104 }
1105
1106 if show_llm {
1110 println!("┌──────────────────────────────────────────────────────────────────┐");
1111 println!("│ 🤖 LLM MODELS (Local Inference) │");
1112 println!("├──────────────────────────────────────────────────────────────────┤");
1113
1114 for model in LlmModel::all() {
1115 let is_installed = is_llm_model_installed(config, model);
1116 let status = if is_installed {
1117 "✓ installed"
1118 } else {
1119 "○ available"
1120 };
1121 let default_marker = if model.is_default() { " (default)" } else { "" };
1122
1123 println!(
1124 "│ {:20} {:>5.1} GB {:15}{}",
1125 model.cli_name(),
1126 model.expected_size_bytes() as f64 / 1_000_000_000.0,
1127 status,
1128 default_marker
1129 );
1130
1131 if is_installed {
1132 println!("│ Path: {}", llm_model_path(config, model).display());
1133 }
1134 }
1135
1136 println!("│ │");
1137 println!("│ Install: memvid models install phi-3.5-mini │");
1138 println!("│ Usage: memvid ask file.mv2 --question \"...\" --model candle:phi │");
1139 println!("└──────────────────────────────────────────────────────────────────┘");
1140 println!();
1141 }
1142
1143 if show_clip {
1147 println!("┌──────────────────────────────────────────────────────────────────┐");
1148 println!("│ 🖼️ CLIP MODELS (Visual Search) │");
1149 println!("├──────────────────────────────────────────────────────────────────┤");
1150
1151 for model in ClipModel::all() {
1152 let (status, _, _) = clip_model_status(config, model);
1153 let default_marker = if model.is_default() { " (default)" } else { "" };
1154
1155 println!(
1156 "│ {:20} {:4}D {:>6.1} MB {:15}{}",
1157 model.cli_name(),
1158 model.dimensions(),
1159 model.total_size_mb(),
1160 status,
1161 default_marker
1162 );
1163 }
1164
1165 println!("│ │");
1166 println!("│ Install: memvid models install --clip mobileclip-s2 │");
1167 println!("│ Usage: memvid put photos.mv2 --input ./images/ --clip │");
1168 println!("│ memvid find photos.mv2 --query \"sunset\" --mode clip │");
1169 println!("└──────────────────────────────────────────────────────────────────┘");
1170 println!();
1171 }
1172
1173 if show_ner {
1177 println!("┌──────────────────────────────────────────────────────────────────┐");
1178 println!("│ 🔗 NER MODELS (Logic-Mesh Entity Extraction) │");
1179 println!("├──────────────────────────────────────────────────────────────────┤");
1180
1181 for model in NerModel::all() {
1182 let (status, _, _) = ner_model_status(config, model);
1183 let default_marker = if model.is_default() { " (default)" } else { "" };
1184
1185 println!(
1186 "│ {:20} {:>6.1} MB {:15}{}",
1187 model.cli_name(),
1188 model.size_mb(),
1189 status,
1190 default_marker
1191 );
1192 }
1193
1194 println!("│ │");
1195 println!("│ Install: memvid models install --ner distilbert-ner │");
1196 println!("│ Usage: memvid put file.mv2 --input doc.txt --logic-mesh │");
1197 println!("│ memvid follow traverse file.mv2 --start \"John\" │");
1198 println!("└──────────────────────────────────────────────────────────────────┘");
1199 println!();
1200 }
1201
1202 if show_whisper {
1206 println!("┌──────────────────────────────────────────────────────────────────┐");
1207 println!("│ 🎙️ WHISPER MODELS (Audio Transcription via Candle) │");
1208 println!("├──────────────────────────────────────────────────────────────────┤");
1209 println!("│ whisper-small-en 244 MB Auto-download (default) │");
1210 println!("│ whisper-small 244 MB Auto-download multilingual │");
1211 println!("│ whisper-tiny-en 75 MB Auto-download fastest │");
1212 println!("│ whisper-base-en 145 MB Auto-download │");
1213 println!("│ │");
1214 println!("│ Models download automatically from HuggingFace on first use. │");
1215 println!("│ GPU acceleration: --features metal (Mac) or --features cuda │");
1216 println!("│ │");
1217 println!("│ Usage: memvid put file.mv2 --input audio.mp3 --transcribe │");
1218 println!("└──────────────────────────────────────────────────────────────────┘");
1219 println!();
1220 }
1221
1222 if show_external {
1226 println!("┌──────────────────────────────────────────────────────────────────┐");
1227 println!("│ ☁️ EXTERNAL MODELS (API-based, no download required) │");
1228 println!("├──────────────────────────────────────────────────────────────────┤");
1229
1230 for provider in EXTERNAL_EMBEDDING_PROVIDERS {
1231 let api_key_set = std::env::var(provider.env_var).is_ok();
1232 let key_status = if api_key_set {
1233 format!("{} ✓", provider.env_var)
1234 } else {
1235 format!("{} ○", provider.env_var)
1236 };
1237
1238 println!("│ {} ({}):", provider.name, key_status);
1239
1240 for (model_name, dim, desc) in provider.models.iter() {
1241 println!("│ {:30} {:4}D {}", model_name, dim, desc);
1242 }
1243 println!("│");
1244 }
1245
1246 println!("│ Usage: export OPENAI_API_KEY=sk-... │");
1247 println!("│ memvid put mem.mv2 --input doc.pdf --embedding │");
1248 println!("│ --embedding-model openai-small │");
1249 println!("└──────────────────────────────────────────────────────────────────┘");
1250 println!();
1251 }
1252
1253 if !fastembed_installed.is_empty() {
1257 println!("┌──────────────────────────────────────────────────────────────────┐");
1258 println!(
1259 "│ 📦 INSTALLED MODELS (cached in {}) │",
1260 config.models_dir.display()
1261 );
1262 println!("├──────────────────────────────────────────────────────────────────┤");
1263
1264 let mut total_size: u64 = 0;
1265
1266 for (name, _path, size) in &fastembed_installed {
1267 total_size += size;
1268 println!(
1269 "│ {:40} {:>8.1} MB",
1270 if name.len() > 40 {
1271 format!("{}...", &name[..37])
1272 } else {
1273 name.clone()
1274 },
1275 *size as f64 / 1_000_000.0
1276 );
1277 }
1278
1279 for model in LlmModel::all() {
1281 if is_llm_model_installed(config, model) {
1282 let path = llm_model_path(config, model);
1283 if let Ok(meta) = fs::metadata(&path) {
1284 total_size += meta.len();
1285 println!(
1286 "│ {:40} {:>8.1} MB",
1287 model.display_name(),
1288 meta.len() as f64 / 1_000_000.0
1289 );
1290 }
1291 }
1292 }
1293
1294 println!("├──────────────────────────────────────────────────────────────────┤");
1295 println!("│ Total: {:>55.1} MB │", total_size as f64 / 1_000_000.0);
1296 println!("└──────────────────────────────────────────────────────────────────┘");
1297 println!();
1298 }
1299
1300 println!("╔══════════════════════════════════════════════════════════════════╗");
1304 println!("║ QUICK REFERENCE ║");
1305 println!("╟──────────────────────────────────────────────────────────────────╢");
1306 println!("║ memvid models list List all models ║");
1307 println!("║ memvid models list --model-type llm List only LLM models ║");
1308 println!("║ memvid models install phi-3.5-mini Install LLM model ║");
1309 println!("║ memvid models remove phi-3.5-mini Remove LLM model ║");
1310 println!("║ memvid models verify Verify installed models ║");
1311 println!("╚══════════════════════════════════════════════════════════════════╝");
1312
1313 Ok(())
1314}
1315
1316fn handle_models_list_json(
1318 config: &CliConfig,
1319 fastembed_installed: &[(String, PathBuf, u64)],
1320) -> Result<()> {
1321 let output = serde_json::json!({
1322 "models_dir": config.models_dir,
1323 "embedding_models": EmbeddingModel::all().map(|m| {
1324 let is_installed = fastembed_installed
1325 .iter()
1326 .any(|(name, _, _)| name.contains(m.hf_repo()));
1327 serde_json::json!({
1328 "name": m.cli_name(),
1329 "display_name": m.display_name(),
1330 "dimensions": m.dimensions(),
1331 "size_mb": m.size_mb(),
1332 "hf_repo": m.hf_repo(),
1333 "installed": is_installed,
1334 "is_default": m.is_default(),
1335 })
1336 }).collect::<Vec<_>>(),
1337 "reranker_models": RerankerModel::all().map(|m| {
1338 serde_json::json!({
1339 "name": m.cli_name(),
1340 "display_name": m.display_name(),
1341 "size_mb": m.size_mb(),
1342 "hf_repo": m.hf_repo(),
1343 "language": m.language(),
1344 "is_default": m.is_default(),
1345 })
1346 }).collect::<Vec<_>>(),
1347 "llm_models": LlmModel::all().map(|m| {
1348 serde_json::json!({
1349 "name": m.cli_name(),
1350 "display_name": m.display_name(),
1351 "size_gb": m.expected_size_bytes() as f64 / 1_000_000_000.0,
1352 "hf_repo": m.hf_repo(),
1353 "installed": is_llm_model_installed(config, m),
1354 "path": if is_llm_model_installed(config, m) {
1355 Some(llm_model_path(config, m))
1356 } else {
1357 None
1358 },
1359 "is_default": m.is_default(),
1360 })
1361 }).collect::<Vec<_>>(),
1362 "external_providers": EXTERNAL_EMBEDDING_PROVIDERS.iter().map(|p| {
1363 serde_json::json!({
1364 "name": p.name,
1365 "env_var": p.env_var,
1366 "configured": std::env::var(p.env_var).is_ok(),
1367 "models": p.models.iter().map(|(name, dim, desc)| {
1368 serde_json::json!({
1369 "name": name,
1370 "dimensions": dim,
1371 "description": desc,
1372 })
1373 }).collect::<Vec<_>>(),
1374 })
1375 }).collect::<Vec<_>>(),
1376 "installed_cache": fastembed_installed.iter().map(|(name, path, size)| {
1377 serde_json::json!({
1378 "name": name,
1379 "path": path,
1380 "size_bytes": size,
1381 })
1382 }).collect::<Vec<_>>(),
1383 });
1384
1385 println!("{}", serde_json::to_string_pretty(&output)?);
1386 Ok(())
1387}
1388
1389pub fn handle_models_remove(config: &CliConfig, args: ModelsRemoveArgs) -> Result<()> {
1391 let model = args.model;
1392 let path = llm_model_path(config, model);
1393
1394 if !path.exists() {
1395 println!("{} is not installed.", model.display_name());
1396 return Ok(());
1397 }
1398
1399 if !args.yes {
1400 print!(
1401 "Remove {} ({})? [y/N] ",
1402 model.display_name(),
1403 path.display()
1404 );
1405 io::stdout().flush()?;
1406
1407 let mut input = String::new();
1408 io::stdin().read_line(&mut input)?;
1409
1410 if !input.trim().eq_ignore_ascii_case("y") {
1411 println!("Aborted.");
1412 return Ok(());
1413 }
1414 }
1415
1416 fs::remove_file(&path)?;
1417
1418 if let Some(parent) = path.parent() {
1420 let _ = fs::remove_dir(parent);
1421 }
1422
1423 println!("Removed {}.", model.display_name());
1424 Ok(())
1425}
1426
1427pub fn handle_models_verify(config: &CliConfig, args: ModelsVerifyArgs) -> Result<()> {
1429 let models_to_verify: Vec<LlmModel> = match args.model {
1430 Some(m) => vec![m],
1431 None => LlmModel::all()
1432 .filter(|m| is_llm_model_installed(config, *m))
1433 .collect(),
1434 };
1435
1436 if models_to_verify.is_empty() {
1437 println!("No LLM models installed to verify.");
1438 return Ok(());
1439 }
1440
1441 let mut all_ok = true;
1442
1443 for model in models_to_verify {
1444 let path = llm_model_path(config, model);
1445 print!("Verifying {}... ", model.display_name());
1446 io::stdout().flush()?;
1447
1448 match verify_model_file(&path, model) {
1449 Ok(()) => println!("OK"),
1450 Err(err) => {
1451 println!("FAILED");
1452 eprintln!(" Error: {}", err);
1453 all_ok = false;
1454 }
1455 }
1456 }
1457
1458 if !all_ok {
1459 bail!("Some models failed verification.");
1460 }
1461
1462 Ok(())
1463}
1464
1465fn verify_model_file(path: &Path, model: LlmModel) -> Result<()> {
1467 if !path.exists() {
1468 bail!("Model file does not exist");
1469 }
1470
1471 let metadata = fs::metadata(path)?;
1472 let size = metadata.len();
1473
1474 let min_size = model.expected_size_bytes() / 2;
1476 if size < min_size {
1477 bail!(
1478 "Model file too small ({:.2} GB, expected at least {:.2} GB)",
1479 size as f64 / 1_000_000_000.0,
1480 min_size as f64 / 1_000_000_000.0
1481 );
1482 }
1483
1484 let mut file = fs::File::open(path)?;
1486 let mut magic = [0u8; 4];
1487 io::Read::read_exact(&mut file, &mut magic)?;
1488
1489 if &magic != b"GGUF" {
1491 bail!("Invalid GGUF file (bad magic bytes)");
1492 }
1493
1494 Ok(())
1495}
1496
1497pub fn get_installed_model_path(config: &CliConfig, model: LlmModel) -> Option<PathBuf> {
1499 let path = llm_model_path(config, model);
1500 if path.exists() && path.is_file() {
1501 Some(path)
1502 } else {
1503 None
1504 }
1505}
1506
1507pub fn default_enrichment_model() -> LlmModel {
1509 LlmModel::Phi35Mini
1510}