@@ -374,6 +374,128 @@ def _cmd_install(args: argparse.Namespace) -> int:
374374 return 0
375375
376376
377+ def _cmd_install_model (args : argparse .Namespace ) -> int :
378+ """Download mind-mem-4b GGUF from HF and import into Ollama.
379+
380+ Idempotent — safe to re-run. Skips download if file already
381+ present with matching size; skips Ollama import if tag already
382+ present (unless --force on the parent command).
383+ """
384+ import shutil
385+ import subprocess
386+ import urllib .request
387+
388+ hf_repo = "star-ga/mind-mem-4b"
389+ gguf_url = f"https://huggingface.co/{ hf_repo } /resolve/main/{ args .model } "
390+ dest = os .path .expanduser (args .dest )
391+
392+ output : dict [str , Any ] = {
393+ "model_file" : args .model ,
394+ "ollama_tag" : args .name ,
395+ "dest" : dest ,
396+ "keep_alive" : args .keep_alive ,
397+ "dry_run" : bool (args .dry_run ),
398+ }
399+
400+ if args .dry_run :
401+ output ["plan" ] = [
402+ f"would download { gguf_url } -> { dest } " ,
403+ f"would write Modelfile with FROM { dest } " ,
404+ f"would run `ollama create { args .name } -f Modelfile`" ,
405+ f"would set OLLAMA_KEEP_ALIVE={ args .keep_alive } " ,
406+ ]
407+ print (json .dumps (output , indent = 2 ))
408+ return 0
409+
410+ # 1. Check ollama on PATH (graceful skip if absent)
411+ if not shutil .which ("ollama" ):
412+ output ["error" ] = "ollama not found on PATH"
413+ output ["hint" ] = "install ollama from https://ollama.com/download then re-run `mm install-model`"
414+ print (json .dumps (output , indent = 2 ))
415+ return 2
416+
417+ # 2. Download GGUF (skip if dest already correct size)
418+ os .makedirs (os .path .dirname (dest ), exist_ok = True )
419+ expected_size = None
420+ try :
421+ with urllib .request .urlopen (gguf_url ) as resp :
422+ expected_size = int (resp .headers .get ("Content-Length" ) or 0 )
423+ except Exception as exc :
424+ output ["error" ] = f"could not query HF for { args .model } : { exc } "
425+ print (json .dumps (output , indent = 2 ))
426+ return 3
427+
428+ if os .path .exists (dest ) and expected_size and os .path .getsize (dest ) == expected_size :
429+ output ["downloaded" ] = False
430+ output ["reason" ] = "dest already present with matching size"
431+ else :
432+ try :
433+ urllib .request .urlretrieve (gguf_url , dest )
434+ output ["downloaded" ] = True
435+ output ["bytes" ] = os .path .getsize (dest )
436+ except Exception as exc :
437+ output ["error" ] = f"download failed: { exc } "
438+ print (json .dumps (output , indent = 2 ))
439+ return 4
440+
441+ # 3. Build Modelfile next to the GGUF (idempotent)
442+ modelfile = os .path .join (os .path .dirname (dest ), "Modelfile" )
443+ modelfile_body = (
444+ f"FROM { dest } \n "
445+ "PARAMETER temperature 0.6\n "
446+ "PARAMETER top_p 0.95\n "
447+ "PARAMETER num_ctx 8192\n "
448+ f'PARAMETER stop "<|im_end|>"\n '
449+ )
450+ with open (modelfile , "w" , encoding = "utf-8" ) as fh :
451+ fh .write (modelfile_body )
452+ output ["modelfile" ] = modelfile
453+
454+ # 4. Ollama import
455+ try :
456+ result = subprocess .run (
457+ ["ollama" , "create" , args .name , "-f" , modelfile ],
458+ capture_output = True ,
459+ text = True ,
460+ timeout = 180 ,
461+ check = False ,
462+ )
463+ output ["ollama_create_returncode" ] = result .returncode
464+ if result .returncode != 0 :
465+ output ["ollama_stderr" ] = result .stderr [- 500 :]
466+ print (json .dumps (output , indent = 2 ))
467+ return 5
468+ except subprocess .TimeoutExpired :
469+ output ["error" ] = "`ollama create` timed out after 180s"
470+ print (json .dumps (output , indent = 2 ))
471+ return 6
472+
473+ # 5. Smoke test (warm the model + keep-alive)
474+ try :
475+ smoke = subprocess .run (
476+ ["ollama" , "run" , args .name , "test" ],
477+ input = "hi\n " ,
478+ capture_output = True ,
479+ text = True ,
480+ timeout = 60 ,
481+ check = False ,
482+ env = {** os .environ , "OLLAMA_KEEP_ALIVE" : args .keep_alive },
483+ )
484+ output ["smoke_returncode" ] = smoke .returncode
485+ if smoke .returncode == 0 :
486+ output ["smoke_response_first_60" ] = smoke .stdout .strip ()[:60 ]
487+ except subprocess .TimeoutExpired :
488+ output ["smoke_response" ] = "(timeout — model likely importing in background; run `ollama list` to verify)"
489+
490+ output ["status" ] = "ok"
491+ output ["next_steps" ] = [
492+ f"ollama run { args .name } # test the model" ,
493+ "mm status # confirm mind-mem.json is configured" ,
494+ ]
495+ print (json .dumps (output , indent = 2 ))
496+ return 0
497+
498+
377499def _cmd_install_all (args : argparse .Namespace ) -> int :
378500 """Auto-detect installed AI clients and configure all of them."""
379501 from mind_mem .hook_installer import detect_installed_agents , install_all
@@ -1525,6 +1647,37 @@ def build_parser() -> argparse.ArgumentParser:
15251647 )
15261648 p_install_all .set_defaults (func = _cmd_install_all )
15271649
1650+ # install-model — pull mind-mem-4b GGUF from HF + import to Ollama
1651+ p_install_model = sub .add_parser (
1652+ "install-model" ,
1653+ help = (
1654+ "Download `mind-mem-4b` GGUF (~2.5GB) from HuggingFace and "
1655+ "import into Ollama as `mind-mem:4b`. Idempotent."
1656+ ),
1657+ )
1658+ p_install_model .add_argument (
1659+ "--model" ,
1660+ default = "mind-mem-4b-Q4_K_M.gguf" ,
1661+ help = "GGUF filename on HF. Default: mind-mem-4b-Q4_K_M.gguf" ,
1662+ )
1663+ p_install_model .add_argument (
1664+ "--name" ,
1665+ default = "mind-mem:4b" ,
1666+ help = "Ollama tag to register. Default: mind-mem:4b" ,
1667+ )
1668+ p_install_model .add_argument (
1669+ "--dest" ,
1670+ default = os .path .expanduser ("~/.cache/mind-mem/mind-mem-4b-Q4_K_M.gguf" ),
1671+ help = "Local path to download into. Default: ~/.cache/mind-mem/" ,
1672+ )
1673+ p_install_model .add_argument (
1674+ "--keep-alive" ,
1675+ default = "-1" ,
1676+ help = "Ollama keep-alive value. -1 = forever (default), 30m, etc." ,
1677+ )
1678+ p_install_model .add_argument ("--dry-run" , action = "store_true" )
1679+ p_install_model .set_defaults (func = _cmd_install_model )
1680+
15281681 # vault namespace
15291682 p_vault = sub .add_parser ("vault" , help = "Vault sync subcommands." )
15301683 vsub = p_vault .add_subparsers (dest = "vault_cmd" , required = True )
0 commit comments