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
563fn fastembed_cache_dir(config: &CliConfig) -> PathBuf {
565 config.models_dir.clone()
566}
567
568fn llm_model_path(config: &CliConfig, model: LlmModel) -> PathBuf {
570 llm_models_dir(config)
571 .join(model.local_dir_name())
572 .join(model.hf_filename())
573}
574
575fn is_llm_model_installed(config: &CliConfig, model: LlmModel) -> bool {
577 let path = llm_model_path(config, model);
578 path.exists() && path.is_file()
579}
580
581fn clip_models_dir(config: &CliConfig) -> PathBuf {
583 config.models_dir.clone()
584}
585
586fn clip_vision_path(config: &CliConfig, model: ClipModel) -> PathBuf {
588 clip_models_dir(config).join(model.vision_filename())
589}
590
591fn clip_text_path(config: &CliConfig, model: ClipModel) -> PathBuf {
593 clip_models_dir(config).join(model.text_filename())
594}
595
596fn is_clip_model_installed(config: &CliConfig, model: ClipModel) -> bool {
598 let vision_path = clip_vision_path(config, model);
599 let text_path = clip_text_path(config, model);
600 vision_path.exists() && vision_path.is_file() && text_path.exists() && text_path.is_file()
601}
602
603fn clip_model_status(config: &CliConfig, model: ClipModel) -> (&'static str, bool, bool) {
605 let has_vision = clip_vision_path(config, model).exists();
606 let has_text = clip_text_path(config, model).exists();
607 let status = match (has_vision, has_text) {
608 (true, true) => "✓ installed",
609 (true, false) => "⚠ partial (missing text)",
610 (false, true) => "⚠ partial (missing vision)",
611 (false, false) => "○ available",
612 };
613 (status, has_vision, has_text)
614}
615
616fn ner_models_dir(config: &CliConfig) -> PathBuf {
618 config.models_dir.clone()
619}
620
621fn ner_model_path(config: &CliConfig, model: NerModel) -> PathBuf {
623 ner_models_dir(config)
624 .join(model.local_dir_name())
625 .join("model.onnx")
626}
627
628fn ner_tokenizer_path(config: &CliConfig, model: NerModel) -> PathBuf {
630 ner_models_dir(config)
631 .join(model.local_dir_name())
632 .join("tokenizer.json")
633}
634
635fn is_ner_model_installed(config: &CliConfig, model: NerModel) -> bool {
637 let model_path = ner_model_path(config, model);
638 let tokenizer_path = ner_tokenizer_path(config, model);
639 model_path.exists() && model_path.is_file() && tokenizer_path.exists() && tokenizer_path.is_file()
640}
641
642fn ner_model_status(config: &CliConfig, model: NerModel) -> (&'static str, bool, bool) {
644 let has_model = ner_model_path(config, model).exists();
645 let has_tokenizer = ner_tokenizer_path(config, model).exists();
646 let status = match (has_model, has_tokenizer) {
647 (true, true) => "✓ installed",
648 (true, false) => "⚠ partial (missing tokenizer)",
649 (false, true) => "⚠ partial (missing model)",
650 (false, false) => "○ available",
651 };
652 (status, has_model, has_tokenizer)
653}
654
655fn scan_fastembed_cache(config: &CliConfig) -> Vec<(String, PathBuf, u64)> {
660 let cache_dir = fastembed_cache_dir(config);
661 let mut installed = Vec::new();
662
663 if let Ok(entries) = fs::read_dir(&cache_dir) {
664 for entry in entries.flatten() {
665 let path = entry.path();
666 if path.is_dir() {
667 let name = path.file_name().unwrap_or_default().to_string_lossy();
668 if name.starts_with("models--") {
670 let size = dir_size(&path).unwrap_or(0);
671 let model_name = name.replace("models--", "").replace("--", "/");
672 installed.push((model_name, path, size));
673 }
674 }
675 }
676 }
677
678 installed
679}
680
681fn dir_size(path: &Path) -> io::Result<u64> {
683 let mut size = 0;
684 if path.is_dir() {
685 for entry in fs::read_dir(path)? {
686 let entry = entry?;
687 let path = entry.path();
688 if path.is_dir() {
689 size += dir_size(&path)?;
690 } else {
691 size += entry.metadata()?.len();
692 }
693 }
694 }
695 Ok(size)
696}
697
698pub fn handle_models(config: &CliConfig, args: ModelsArgs) -> Result<()> {
704 match args.command {
705 ModelsCommand::Install(install_args) => handle_models_install(config, install_args),
706 ModelsCommand::List(list_args) => handle_models_list(config, list_args),
707 ModelsCommand::Remove(remove_args) => handle_models_remove(config, remove_args),
708 ModelsCommand::Verify(verify_args) => handle_models_verify(config, verify_args),
709 }
710}
711
712pub fn handle_models_install(config: &CliConfig, args: ModelsInstallArgs) -> Result<()> {
714 if let Some(clip_model) = args.clip {
716 return handle_clip_install(config, clip_model, args.force);
717 }
718
719 if let Some(ner_model) = args.ner {
721 return handle_ner_install(config, ner_model, args.force);
722 }
723
724 if args.whisper.is_some() {
726 println!("ℹ️ Whisper models now auto-download from HuggingFace on first use.");
727 println!(" No manual installation required!");
728 println!();
729 println!(" Just use: memvid put file.mv2 --input audio.mp3 --transcribe");
730 println!();
731 println!(" The model will download automatically (~244 MB for whisper-small-en).");
732 return Ok(());
733 }
734
735 if let Some(llm_model) = args.model {
737 return handle_llm_install(config, llm_model, args.force);
738 }
739
740 bail!(
742 "Please specify a model to install:\n\
743 \n\
744 LLM models (for local inference):\n\
745 \x20 memvid models install phi-3.5-mini\n\
746 \x20 memvid models install phi-3.5-mini-q8\n\
747 \n\
748 CLIP models (for visual search):\n\
749 \x20 memvid models install --clip mobileclip-s2\n\
750 \x20 memvid models install --clip mobileclip-s2-fp16\n\
751 \x20 memvid models install --clip siglip-base\n\
752 \n\
753 NER models (for Logic-Mesh entity extraction):\n\
754 \x20 memvid models install --ner distilbert-ner\n\
755 \n\
756 Note: Whisper models auto-download on first use (no install needed)"
757 );
758}
759
760fn handle_clip_install(config: &CliConfig, model: ClipModel, force: bool) -> Result<()> {
762 let vision_path = clip_vision_path(config, model);
763 let text_path = clip_text_path(config, model);
764
765 if is_clip_model_installed(config, model) && !force {
766 println!(
767 "{} is already installed at {}",
768 model.display_name(),
769 clip_models_dir(config).display()
770 );
771 println!("Use --force to re-download.");
772 return Ok(());
773 }
774
775 if config.offline {
776 bail!(
777 "Cannot install models while offline (MEMVID_OFFLINE=1). \
778 Run without MEMVID_OFFLINE to download the model."
779 );
780 }
781
782 fs::create_dir_all(clip_models_dir(config))?;
784
785 println!("Installing {}...", model.display_name());
786 println!("Dimensions: {}", model.dimensions());
787 println!("Total size: {:.1} MB", model.total_size_mb());
788 println!();
789
790 println!("Downloading vision encoder...");
792 download_file(model.vision_url(), &vision_path)?;
793
794 println!();
796 println!("Downloading text encoder...");
797 download_file(model.text_url(), &text_path)?;
798
799 let vision_size = fs::metadata(&vision_path).map(|m| m.len()).unwrap_or(0);
801 let text_size = fs::metadata(&text_path).map(|m| m.len()).unwrap_or(0);
802 let total_size = vision_size + text_size;
803
804 println!();
805 println!(
806 "Successfully installed {} ({:.1} MB)",
807 model.display_name(),
808 total_size as f64 / 1_000_000.0
809 );
810 println!("Vision encoder: {}", vision_path.display());
811 println!("Text encoder: {}", text_path.display());
812 println!();
813 println!("Usage:");
814 println!(" memvid put photos.mv2 --input ./images/ --clip");
815 println!(" memvid find photos.mv2 --query \"sunset over ocean\" --mode clip");
816
817 Ok(())
818}
819
820fn handle_ner_install(config: &CliConfig, model: NerModel, force: bool) -> Result<()> {
822 let model_path = ner_model_path(config, model);
823 let tokenizer_path = ner_tokenizer_path(config, model);
824
825 if is_ner_model_installed(config, model) && !force {
826 println!(
827 "{} is already installed at {}",
828 model.display_name(),
829 model_path.parent().unwrap_or(&model_path).display()
830 );
831 println!("Use --force to re-download.");
832 return Ok(());
833 }
834
835 if config.offline {
836 bail!(
837 "Cannot install models while offline (MEMVID_OFFLINE=1). \
838 Run without MEMVID_OFFLINE to download the model."
839 );
840 }
841
842 if let Some(parent) = model_path.parent() {
844 fs::create_dir_all(parent)?;
845 }
846
847 println!("Installing {}...", model.display_name());
848 println!("Size: {:.1} MB", model.size_mb());
849 println!();
850
851 println!("Downloading model...");
853 download_file(model.model_url(), &model_path)?;
854
855 println!();
857 println!("Downloading tokenizer...");
858 download_file(model.tokenizer_url(), &tokenizer_path)?;
859
860 let model_size = fs::metadata(&model_path).map(|m| m.len()).unwrap_or(0);
862 let tokenizer_size = fs::metadata(&tokenizer_path).map(|m| m.len()).unwrap_or(0);
863 let total_size = model_size + tokenizer_size;
864
865 println!();
866 println!(
867 "Successfully installed {} ({:.1} MB)",
868 model.display_name(),
869 total_size as f64 / 1_000_000.0
870 );
871 println!("Model: {}", model_path.display());
872 println!("Tokenizer: {}", tokenizer_path.display());
873 println!();
874 println!("Usage:");
875 println!(" memvid enrich file.mv2 --logic-mesh");
876 println!(" memvid follow traverse file.mv2 --start \"John\" --link manager");
877
878 Ok(())
879}
880
881fn handle_llm_install(config: &CliConfig, model: LlmModel, force: bool) -> Result<()> {
886 let target_path = llm_model_path(config, model);
887
888 if is_llm_model_installed(config, model) && !force {
889 println!(
890 "{} is already installed at {}",
891 model.display_name(),
892 target_path.display()
893 );
894 println!("Use --force to re-download.");
895 return Ok(());
896 }
897
898 if config.offline {
899 bail!(
900 "Cannot install models while offline (MEMVID_OFFLINE=1). \
901 Run without MEMVID_OFFLINE to download the model."
902 );
903 }
904
905 if let Some(parent) = target_path.parent() {
907 fs::create_dir_all(parent)?;
908 }
909
910 println!("Installing {}...", model.display_name());
911 println!("Repository: {}", model.hf_repo());
912 println!("File: {}", model.hf_filename());
913 println!(
914 "Expected size: {:.1} GB",
915 model.expected_size_bytes() as f64 / 1_000_000_000.0
916 );
917 println!();
918
919 download_llm_model(model, &target_path)?;
921
922 let metadata = fs::metadata(&target_path)?;
924 let size = metadata.len();
925
926 let min_size = (model.expected_size_bytes() as f64 * 0.9) as u64;
928 let max_size = (model.expected_size_bytes() as f64 * 1.1) as u64;
929
930 if size < min_size || size > max_size {
931 eprintln!(
932 "Warning: Downloaded file size ({:.2} GB) differs significantly from expected ({:.2} GB)",
933 size as f64 / 1_000_000_000.0,
934 model.expected_size_bytes() as f64 / 1_000_000_000.0
935 );
936 }
937
938 println!();
939 println!(
940 "Successfully installed {} ({:.2} GB)",
941 model.display_name(),
942 size as f64 / 1_000_000_000.0
943 );
944 println!("Location: {}", target_path.display());
945
946 Ok(())
947}
948
949fn download_file(url: &str, target_path: &Path) -> Result<()> {
951 println!("URL: {}", url);
952
953 let status = std::process::Command::new("curl")
954 .args([
955 "-L", "--progress-bar", "-o",
958 target_path
959 .to_str()
960 .ok_or_else(|| anyhow!("Invalid target path"))?,
961 url,
962 ])
963 .status()?;
964
965 if !status.success() {
966 let _ = fs::remove_file(target_path);
968 bail!("Download failed. Please check your internet connection and try again.");
969 }
970
971 Ok(())
972}
973
974fn download_llm_model(model: LlmModel, target_path: &Path) -> Result<()> {
976 let url = format!(
977 "https://huggingface.co/{}/resolve/main/{}",
978 model.hf_repo(),
979 model.hf_filename()
980 );
981
982 println!("Downloading from Hugging Face...");
983 download_file(&url, target_path)
984}
985
986pub fn handle_models_list(config: &CliConfig, args: ModelsListArgs) -> Result<()> {
988 let fastembed_installed = scan_fastembed_cache(config);
989
990 if args.json {
991 return handle_models_list_json(config, &fastembed_installed);
992 }
993
994 let show_all = args.model_type.is_none();
996 let show_embedding = show_all || matches!(args.model_type, Some(ModelType::Embedding));
997 let show_reranker = show_all || matches!(args.model_type, Some(ModelType::Reranker));
998 let show_llm = show_all || matches!(args.model_type, Some(ModelType::Llm));
999 let show_clip = show_all || matches!(args.model_type, Some(ModelType::Clip));
1000 let show_ner = show_all || matches!(args.model_type, Some(ModelType::Ner));
1001 let show_whisper = show_all || matches!(args.model_type, Some(ModelType::Whisper));
1002 let show_external = show_all || matches!(args.model_type, Some(ModelType::External));
1003
1004 println!("╔══════════════════════════════════════════════════════════════════╗");
1005 println!("║ MEMVID MODEL CATALOG ║");
1006 println!("╚══════════════════════════════════════════════════════════════════╝");
1007 println!();
1008
1009 println!("Models Directory: {}", config.models_dir.display());
1011 println!();
1012
1013 if show_embedding {
1017 println!("┌──────────────────────────────────────────────────────────────────┐");
1018 println!("│ 📊 EMBEDDING MODELS (Semantic Search) │");
1019 println!("├──────────────────────────────────────────────────────────────────┤");
1020
1021 for model in EmbeddingModel::all() {
1022 let is_installed = fastembed_installed.iter().any(|(name, _, _)| {
1023 name.contains(&model.hf_repo().replace("/", "--").replace("--", "/"))
1024 });
1025
1026 let status = if is_installed {
1027 "✓ installed"
1028 } else {
1029 "○ available"
1030 };
1031 let default_marker = if model.is_default() { " (default)" } else { "" };
1032
1033 println!(
1034 "│ {:20} {:4}D {:>4} MB {:15}{}",
1035 model.cli_name(),
1036 model.dimensions(),
1037 model.size_mb(),
1038 status,
1039 default_marker
1040 );
1041 }
1042
1043 println!("│ │");
1044 println!("│ Usage: memvid put mem.mv2 --input doc.pdf --embedding │");
1045 println!("│ --embedding-model nomic │");
1046 println!("└──────────────────────────────────────────────────────────────────┘");
1047 println!();
1048 }
1049
1050 if show_reranker {
1054 println!("┌──────────────────────────────────────────────────────────────────┐");
1055 println!("│ 🔄 RERANKER MODELS (Result Reranking) │");
1056 println!("├──────────────────────────────────────────────────────────────────┤");
1057
1058 for model in RerankerModel::all() {
1059 let is_installed = fastembed_installed.iter().any(|(name, _, _)| {
1060 let repo = model.hf_repo();
1061 name.to_lowercase()
1062 .contains(&repo.to_lowercase().replace("/", "--").replace("--", "/"))
1063 || name
1064 .to_lowercase()
1065 .contains(&repo.split('/').last().unwrap_or("").to_lowercase())
1066 });
1067
1068 let status = if is_installed {
1069 "✓ installed"
1070 } else {
1071 "○ available"
1072 };
1073 let default_marker = if model.is_default() { " (default)" } else { "" };
1074
1075 println!(
1076 "│ {:25} {:>4} MB {:12} {:12}{}",
1077 model.cli_name(),
1078 model.size_mb(),
1079 model.language(),
1080 status,
1081 default_marker
1082 );
1083 }
1084
1085 println!("│ │");
1086 println!("│ Reranking is automatic in hybrid search mode (--mode auto) │");
1087 println!("└──────────────────────────────────────────────────────────────────┘");
1088 println!();
1089 }
1090
1091 if show_llm {
1095 println!("┌──────────────────────────────────────────────────────────────────┐");
1096 println!("│ 🤖 LLM MODELS (Local Inference) │");
1097 println!("├──────────────────────────────────────────────────────────────────┤");
1098
1099 for model in LlmModel::all() {
1100 let is_installed = is_llm_model_installed(config, model);
1101 let status = if is_installed {
1102 "✓ installed"
1103 } else {
1104 "○ available"
1105 };
1106 let default_marker = if model.is_default() { " (default)" } else { "" };
1107
1108 println!(
1109 "│ {:20} {:>5.1} GB {:15}{}",
1110 model.cli_name(),
1111 model.expected_size_bytes() as f64 / 1_000_000_000.0,
1112 status,
1113 default_marker
1114 );
1115
1116 if is_installed {
1117 println!("│ Path: {}", llm_model_path(config, model).display());
1118 }
1119 }
1120
1121 println!("│ │");
1122 println!("│ Install: memvid models install phi-3.5-mini │");
1123 println!("│ Usage: memvid ask file.mv2 --question \"...\" --model candle:phi │");
1124 println!("└──────────────────────────────────────────────────────────────────┘");
1125 println!();
1126 }
1127
1128 if show_clip {
1132 println!("┌──────────────────────────────────────────────────────────────────┐");
1133 println!("│ 🖼️ CLIP MODELS (Visual Search) │");
1134 println!("├──────────────────────────────────────────────────────────────────┤");
1135
1136 for model in ClipModel::all() {
1137 let (status, _, _) = clip_model_status(config, model);
1138 let default_marker = if model.is_default() { " (default)" } else { "" };
1139
1140 println!(
1141 "│ {:20} {:4}D {:>6.1} MB {:15}{}",
1142 model.cli_name(),
1143 model.dimensions(),
1144 model.total_size_mb(),
1145 status,
1146 default_marker
1147 );
1148 }
1149
1150 println!("│ │");
1151 println!("│ Install: memvid models install --clip mobileclip-s2 │");
1152 println!("│ Usage: memvid put photos.mv2 --input ./images/ --clip │");
1153 println!("│ memvid find photos.mv2 --query \"sunset\" --mode clip │");
1154 println!("└──────────────────────────────────────────────────────────────────┘");
1155 println!();
1156 }
1157
1158 if show_ner {
1162 println!("┌──────────────────────────────────────────────────────────────────┐");
1163 println!("│ 🔗 NER MODELS (Logic-Mesh Entity Extraction) │");
1164 println!("├──────────────────────────────────────────────────────────────────┤");
1165
1166 for model in NerModel::all() {
1167 let (status, _, _) = ner_model_status(config, model);
1168 let default_marker = if model.is_default() { " (default)" } else { "" };
1169
1170 println!(
1171 "│ {:20} {:>6.1} MB {:15}{}",
1172 model.cli_name(),
1173 model.size_mb(),
1174 status,
1175 default_marker
1176 );
1177 }
1178
1179 println!("│ │");
1180 println!("│ Install: memvid models install --ner distilbert-ner │");
1181 println!("│ Usage: memvid put file.mv2 --input doc.txt --logic-mesh │");
1182 println!("│ memvid follow traverse file.mv2 --start \"John\" │");
1183 println!("└──────────────────────────────────────────────────────────────────┘");
1184 println!();
1185 }
1186
1187 if show_whisper {
1191 println!("┌──────────────────────────────────────────────────────────────────┐");
1192 println!("│ 🎙️ WHISPER MODELS (Audio Transcription via Candle) │");
1193 println!("├──────────────────────────────────────────────────────────────────┤");
1194 println!("│ whisper-small-en 244 MB Auto-download (default) │");
1195 println!("│ whisper-small 244 MB Auto-download multilingual │");
1196 println!("│ whisper-tiny-en 75 MB Auto-download fastest │");
1197 println!("│ whisper-base-en 145 MB Auto-download │");
1198 println!("│ │");
1199 println!("│ Models download automatically from HuggingFace on first use. │");
1200 println!("│ GPU acceleration: --features metal (Mac) or --features cuda │");
1201 println!("│ │");
1202 println!("│ Usage: memvid put file.mv2 --input audio.mp3 --transcribe │");
1203 println!("└──────────────────────────────────────────────────────────────────┘");
1204 println!();
1205 }
1206
1207 if show_external {
1211 println!("┌──────────────────────────────────────────────────────────────────┐");
1212 println!("│ ☁️ EXTERNAL MODELS (API-based, no download required) │");
1213 println!("├──────────────────────────────────────────────────────────────────┤");
1214
1215 for provider in EXTERNAL_EMBEDDING_PROVIDERS {
1216 let api_key_set = std::env::var(provider.env_var).is_ok();
1217 let key_status = if api_key_set {
1218 format!("{} ✓", provider.env_var)
1219 } else {
1220 format!("{} ○", provider.env_var)
1221 };
1222
1223 println!("│ {} ({}):", provider.name, key_status);
1224
1225 for (model_name, dim, desc) in provider.models.iter() {
1226 println!("│ {:30} {:4}D {}", model_name, dim, desc);
1227 }
1228 println!("│");
1229 }
1230
1231 println!("│ Usage: export OPENAI_API_KEY=sk-... │");
1232 println!("│ memvid put mem.mv2 --input doc.pdf --embedding │");
1233 println!("│ --embedding-model openai-small │");
1234 println!("└──────────────────────────────────────────────────────────────────┘");
1235 println!();
1236 }
1237
1238 if !fastembed_installed.is_empty() {
1242 println!("┌──────────────────────────────────────────────────────────────────┐");
1243 println!(
1244 "│ 📦 INSTALLED MODELS (cached in {}) │",
1245 config.models_dir.display()
1246 );
1247 println!("├──────────────────────────────────────────────────────────────────┤");
1248
1249 let mut total_size: u64 = 0;
1250
1251 for (name, _path, size) in &fastembed_installed {
1252 total_size += size;
1253 println!(
1254 "│ {:40} {:>8.1} MB",
1255 if name.len() > 40 {
1256 format!("{}...", &name[..37])
1257 } else {
1258 name.clone()
1259 },
1260 *size as f64 / 1_000_000.0
1261 );
1262 }
1263
1264 for model in LlmModel::all() {
1266 if is_llm_model_installed(config, model) {
1267 let path = llm_model_path(config, model);
1268 if let Ok(meta) = fs::metadata(&path) {
1269 total_size += meta.len();
1270 println!(
1271 "│ {:40} {:>8.1} MB",
1272 model.display_name(),
1273 meta.len() as f64 / 1_000_000.0
1274 );
1275 }
1276 }
1277 }
1278
1279 println!("├──────────────────────────────────────────────────────────────────┤");
1280 println!("│ Total: {:>55.1} MB │", total_size as f64 / 1_000_000.0);
1281 println!("└──────────────────────────────────────────────────────────────────┘");
1282 println!();
1283 }
1284
1285 println!("╔══════════════════════════════════════════════════════════════════╗");
1289 println!("║ QUICK REFERENCE ║");
1290 println!("╟──────────────────────────────────────────────────────────────────╢");
1291 println!("║ memvid models list List all models ║");
1292 println!("║ memvid models list --model-type llm List only LLM models ║");
1293 println!("║ memvid models install phi-3.5-mini Install LLM model ║");
1294 println!("║ memvid models remove phi-3.5-mini Remove LLM model ║");
1295 println!("║ memvid models verify Verify installed models ║");
1296 println!("╚══════════════════════════════════════════════════════════════════╝");
1297
1298 Ok(())
1299}
1300
1301fn handle_models_list_json(
1303 config: &CliConfig,
1304 fastembed_installed: &[(String, PathBuf, u64)],
1305) -> Result<()> {
1306 let output = serde_json::json!({
1307 "models_dir": config.models_dir,
1308 "embedding_models": EmbeddingModel::all().map(|m| {
1309 let is_installed = fastembed_installed
1310 .iter()
1311 .any(|(name, _, _)| name.contains(m.hf_repo()));
1312 serde_json::json!({
1313 "name": m.cli_name(),
1314 "display_name": m.display_name(),
1315 "dimensions": m.dimensions(),
1316 "size_mb": m.size_mb(),
1317 "hf_repo": m.hf_repo(),
1318 "installed": is_installed,
1319 "is_default": m.is_default(),
1320 })
1321 }).collect::<Vec<_>>(),
1322 "reranker_models": RerankerModel::all().map(|m| {
1323 serde_json::json!({
1324 "name": m.cli_name(),
1325 "display_name": m.display_name(),
1326 "size_mb": m.size_mb(),
1327 "hf_repo": m.hf_repo(),
1328 "language": m.language(),
1329 "is_default": m.is_default(),
1330 })
1331 }).collect::<Vec<_>>(),
1332 "llm_models": LlmModel::all().map(|m| {
1333 serde_json::json!({
1334 "name": m.cli_name(),
1335 "display_name": m.display_name(),
1336 "size_gb": m.expected_size_bytes() as f64 / 1_000_000_000.0,
1337 "hf_repo": m.hf_repo(),
1338 "installed": is_llm_model_installed(config, m),
1339 "path": if is_llm_model_installed(config, m) {
1340 Some(llm_model_path(config, m))
1341 } else {
1342 None
1343 },
1344 "is_default": m.is_default(),
1345 })
1346 }).collect::<Vec<_>>(),
1347 "external_providers": EXTERNAL_EMBEDDING_PROVIDERS.iter().map(|p| {
1348 serde_json::json!({
1349 "name": p.name,
1350 "env_var": p.env_var,
1351 "configured": std::env::var(p.env_var).is_ok(),
1352 "models": p.models.iter().map(|(name, dim, desc)| {
1353 serde_json::json!({
1354 "name": name,
1355 "dimensions": dim,
1356 "description": desc,
1357 })
1358 }).collect::<Vec<_>>(),
1359 })
1360 }).collect::<Vec<_>>(),
1361 "installed_cache": fastembed_installed.iter().map(|(name, path, size)| {
1362 serde_json::json!({
1363 "name": name,
1364 "path": path,
1365 "size_bytes": size,
1366 })
1367 }).collect::<Vec<_>>(),
1368 });
1369
1370 println!("{}", serde_json::to_string_pretty(&output)?);
1371 Ok(())
1372}
1373
1374pub fn handle_models_remove(config: &CliConfig, args: ModelsRemoveArgs) -> Result<()> {
1376 let model = args.model;
1377 let path = llm_model_path(config, model);
1378
1379 if !path.exists() {
1380 println!("{} is not installed.", model.display_name());
1381 return Ok(());
1382 }
1383
1384 if !args.yes {
1385 print!(
1386 "Remove {} ({})? [y/N] ",
1387 model.display_name(),
1388 path.display()
1389 );
1390 io::stdout().flush()?;
1391
1392 let mut input = String::new();
1393 io::stdin().read_line(&mut input)?;
1394
1395 if !input.trim().eq_ignore_ascii_case("y") {
1396 println!("Aborted.");
1397 return Ok(());
1398 }
1399 }
1400
1401 fs::remove_file(&path)?;
1402
1403 if let Some(parent) = path.parent() {
1405 let _ = fs::remove_dir(parent);
1406 }
1407
1408 println!("Removed {}.", model.display_name());
1409 Ok(())
1410}
1411
1412pub fn handle_models_verify(config: &CliConfig, args: ModelsVerifyArgs) -> Result<()> {
1414 let models_to_verify: Vec<LlmModel> = match args.model {
1415 Some(m) => vec![m],
1416 None => LlmModel::all()
1417 .filter(|m| is_llm_model_installed(config, *m))
1418 .collect(),
1419 };
1420
1421 if models_to_verify.is_empty() {
1422 println!("No LLM models installed to verify.");
1423 return Ok(());
1424 }
1425
1426 let mut all_ok = true;
1427
1428 for model in models_to_verify {
1429 let path = llm_model_path(config, model);
1430 print!("Verifying {}... ", model.display_name());
1431 io::stdout().flush()?;
1432
1433 match verify_model_file(&path, model) {
1434 Ok(()) => println!("OK"),
1435 Err(err) => {
1436 println!("FAILED");
1437 eprintln!(" Error: {}", err);
1438 all_ok = false;
1439 }
1440 }
1441 }
1442
1443 if !all_ok {
1444 bail!("Some models failed verification.");
1445 }
1446
1447 Ok(())
1448}
1449
1450fn verify_model_file(path: &Path, model: LlmModel) -> Result<()> {
1452 if !path.exists() {
1453 bail!("Model file does not exist");
1454 }
1455
1456 let metadata = fs::metadata(path)?;
1457 let size = metadata.len();
1458
1459 let min_size = model.expected_size_bytes() / 2;
1461 if size < min_size {
1462 bail!(
1463 "Model file too small ({:.2} GB, expected at least {:.2} GB)",
1464 size as f64 / 1_000_000_000.0,
1465 min_size as f64 / 1_000_000_000.0
1466 );
1467 }
1468
1469 let mut file = fs::File::open(path)?;
1471 let mut magic = [0u8; 4];
1472 io::Read::read_exact(&mut file, &mut magic)?;
1473
1474 if &magic != b"GGUF" {
1476 bail!("Invalid GGUF file (bad magic bytes)");
1477 }
1478
1479 Ok(())
1480}
1481
1482pub fn get_installed_model_path(config: &CliConfig, model: LlmModel) -> Option<PathBuf> {
1484 let path = llm_model_path(config, model);
1485 if path.exists() && path.is_file() {
1486 Some(path)
1487 } else {
1488 None
1489 }
1490}
1491
1492pub fn default_enrichment_model() -> LlmModel {
1494 LlmModel::Phi35Mini
1495}