nock-cli 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. {nock_cli-0.2.0 → nock_cli-0.3.0}/PKG-INFO +18 -1
  2. {nock_cli-0.2.0 → nock_cli-0.3.0}/README.md +114 -97
  3. {nock_cli-0.2.0 → nock_cli-0.3.0}/pyproject.toml +1 -1
  4. {nock_cli-0.2.0 → nock_cli-0.3.0}/src/nock_cli/__init__.py +1 -1
  5. {nock_cli-0.2.0 → nock_cli-0.3.0}/src/nock_cli/main.py +251 -222
  6. nock_cli-0.3.0/src/nock_cli/serve.py +181 -0
  7. {nock_cli-0.2.0 → nock_cli-0.3.0}/src/nock_cli.egg-info/PKG-INFO +18 -1
  8. {nock_cli-0.2.0 → nock_cli-0.3.0}/src/nock_cli.egg-info/SOURCES.txt +1 -0
  9. {nock_cli-0.2.0 → nock_cli-0.3.0}/tests/test_cli.py +83 -1
  10. {nock_cli-0.2.0 → nock_cli-0.3.0}/LICENSE +0 -0
  11. {nock_cli-0.2.0 → nock_cli-0.3.0}/setup.cfg +0 -0
  12. {nock_cli-0.2.0 → nock_cli-0.3.0}/src/nock_cli/__main__.py +0 -0
  13. {nock_cli-0.2.0 → nock_cli-0.3.0}/src/nock_cli/agent.py +0 -0
  14. {nock_cli-0.2.0 → nock_cli-0.3.0}/src/nock_cli/chat.py +0 -0
  15. {nock_cli-0.2.0 → nock_cli-0.3.0}/src/nock_cli/code.py +0 -0
  16. {nock_cli-0.2.0 → nock_cli-0.3.0}/src/nock_cli/download.py +0 -0
  17. {nock_cli-0.2.0 → nock_cli-0.3.0}/src/nock_cli/engine.py +0 -0
  18. {nock_cli-0.2.0 → nock_cli-0.3.0}/src/nock_cli/models.py +0 -0
  19. {nock_cli-0.2.0 → nock_cli-0.3.0}/src/nock_cli/paths.py +0 -0
  20. {nock_cli-0.2.0 → nock_cli-0.3.0}/src/nock_cli/video.py +0 -0
  21. {nock_cli-0.2.0 → nock_cli-0.3.0}/src/nock_cli.egg-info/dependency_links.txt +0 -0
  22. {nock_cli-0.2.0 → nock_cli-0.3.0}/src/nock_cli.egg-info/entry_points.txt +0 -0
  23. {nock_cli-0.2.0 → nock_cli-0.3.0}/src/nock_cli.egg-info/requires.txt +0 -0
  24. {nock_cli-0.2.0 → nock_cli-0.3.0}/src/nock_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nock-cli
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Open models that live on your machine: code, video and a coding agent, offline.
5
5
  Author: NockAI
6
6
  License-Expression: Apache-2.0
@@ -101,6 +101,23 @@ and edited a test file 0 times. It works on your own files, so **every edit is s
101
101
  before it runs, and each one needs your yes**. `--yes` skips the questions; use it only in a copy or a git branch.
102
102
  It refuses to start on GPUs under about 20 GB (`--force` to try anyway, very slowly).
103
103
 
104
+ ## Use it from your apps: `nock serve`
105
+
106
+ ```bash
107
+ nock serve
108
+ ```
109
+
110
+ One local, OpenAI-compatible address for every skill you have installed. In your editor or tool, set the base URL to
111
+ `http://127.0.0.1:8080/v1`, any API key, and the model to `nock-coder`, `nock-video` or `nock-agent`.
112
+
113
+ ```bash
114
+ curl http://127.0.0.1:8080/v1/chat/completions -H "Content-Type: application/json" -d '{"model": "nock-coder", "messages": [{"role": "user", "content": "regex for an EVM address"}]}'
115
+ ```
116
+
117
+ The model starts on first use, and one model is in memory at a time, so asking for another swaps them (it can take
118
+ a while). Streaming works. It listens on this machine only, refuses requests from websites, and logs no questions or
119
+ answers. `--port` changes the port.
120
+
104
121
  ## Speed and GPUs
105
122
 
106
123
  `nock doctor` shows which device the engine uses. If it says **software device** or **no GPU device**, the
@@ -1,97 +1,114 @@
1
- # Nock CLI
2
-
3
- Open models that live on your machine. One tool for the NockAI family: code, video, and a coding agent, offline.
4
-
5
- ```bash
6
- nock add coder video
7
- nock code "regex for an EVM address"
8
- nock video find meeting.mp4 "when do they talk about the budget"
9
- nock fix ./my-project "the total is off by one" # experimental, big GPU
10
- ```
11
-
12
- Everything runs on your computer. The models run in a local llama.cpp server on `127.0.0.1`. Nothing is uploaded.
13
-
14
- ## Install
15
-
16
- You need Python 3.9 or newer.
17
-
18
- ```bash
19
- pipx install nock-cli
20
- nock add coder # or: video, agent
21
- nock doctor
22
- ```
23
-
24
- `nock add` downloads, once:
25
- - the engine: an official [llama.cpp](https://github.com/ggml-org/llama.cpp) build (about 35 MB; Vulkan on Windows
26
- and Linux, Metal on Apple Silicon). Already have `llama-server`? Nock uses it.
27
- - the skill's model from Hugging Face.
28
-
29
- | Skill | Model | Download | Needs |
30
- |---|---|---|---|
31
- | `coder` | [Nock Coder 1.5B](https://huggingface.co/Nock-AI/nock-coder-1.5b-GGUF) | 1 GB | any laptop |
32
- | `video` | [Nock Video 4B](https://huggingface.co/Nock-AI/nock-video-4b-GGUF) (preview) | 3.4 GB | a GPU with 6 GB for 64 frames; [ffmpeg](https://ffmpeg.org/download.html) |
33
- | `agent` | [Nock Agent 31B](https://huggingface.co/Nock-AI/nock-agent-31b-GGUF) (experimental) | 18.7 GB | a GPU with about 20 GB (a Mac with 32 GB or more may work; not tested) |
34
-
35
- `nock list` shows what is installed, `nock remove <skill>` deletes a skill's files. Files go to `~/.nock`
36
- (set `NOCK_HOME` to use another folder).
37
-
38
- ## Nock Coder: `nock code`
39
-
40
- ```bash
41
- nock code "write a Solidity ERC-20 with a cap"
42
- cat error.log | nock code "why does this fail?"
43
- nock code # a chat; type exit to quit
44
- ```
45
-
46
- Code first, one short line of explanation. The same model and prompt as `ollama run nockai/nock-coder`. It is a
47
- 1.5B model, so review code before you run it. On HumanEval it passes 118 of 164.
48
-
49
- ## Nock Video: `nock video`
50
-
51
- ```bash
52
- nock video summarize meeting.mp4
53
- nock video ask clip.mp4 "what error message shows on screen?"
54
- nock video find clip.mp4 "when the cake comes out"
55
- ```
56
-
57
- Files or links. Links are downloaded to your own machine with [yt-dlp](https://github.com/yt-dlp/yt-dlp), which
58
- you install yourself (`pipx inject nock-cli yt-dlp`). Only use videos you have the right to use.
59
-
60
- Nock Video 4B is a **preview**. On our test of 20 videos, through llama.cpp at 64 frames, it found the moment in
61
- 43 of 56 questions, answered 59 of 60 questions about what is on screen, and gave 6 of 7 good summaries. Full
62
- results, including the misses, are on the [model card](https://huggingface.co/Nock-AI/nock-video-4b-GGUF).
63
-
64
- - It reads frames only, not sound.
65
- - A long video is read as at most 64 frames, so short events in long videos can be missed.
66
- - 64 frames used about 5.5 GB of GPU memory in our test. On GPUs under 6 GB, Nock uses 32 frames (the first
67
- preview dropped from 42 to 35 of 56 moments at 32 frames; the current model was not measured at 32).
68
- `--frames` overrides it.
69
-
70
- ## Nock Agent: `nock fix` (experimental)
71
-
72
- ```bash
73
- nock fix ./my-project "the total is off by one"
74
- ```
75
-
76
- Nock Agent reads the code, makes the smallest edit and runs the tests. On our agent test it solved 145 of 150 runs
77
- and edited a test file 0 times. It works on your own files, so **every edit is shown as a diff and every command
78
- before it runs, and each one needs your yes**. `--yes` skips the questions; use it only in a copy or a git branch.
79
- It refuses to start on GPUs under about 20 GB (`--force` to try anyway, very slowly).
80
-
81
- ## Speed and GPUs
82
-
83
- `nock doctor` shows which device the engine uses. If it says **software device** or **no GPU device**, the
84
- engine runs on the CPU and answers take many minutes. This happens inside most containers and VMs. On a machine
85
- with an NVIDIA GPU, `nock setup --backend cuda` installs the CUDA build instead (a bigger download: about 630 MB on Windows, 740 MB on Linux).
86
-
87
- | Option | Meaning |
88
- |---|---|
89
- | `--backend vulkan\|cuda\|cpu\|metal` | which llama.cpp build `nock add` / `nock setup` installs |
90
- | `NOCK_LLAMA_SERVER=/path/llama-server` | use your own llama.cpp build |
91
- | `NOCK_HOME=/path` | where models and the engine are stored |
92
-
93
- Server logs are in `~/.nock/logs/` if something goes wrong.
94
-
95
- ## License
96
-
97
- Apache-2.0. Each model has its own license on its model card (all Apache-2.0).
1
+ # Nock CLI
2
+
3
+ Open models that live on your machine. One tool for the NockAI family: code, video, and a coding agent, offline.
4
+
5
+ ```bash
6
+ nock add coder video
7
+ nock code "regex for an EVM address"
8
+ nock video find meeting.mp4 "when do they talk about the budget"
9
+ nock fix ./my-project "the total is off by one" # experimental, big GPU
10
+ ```
11
+
12
+ Everything runs on your computer. The models run in a local llama.cpp server on `127.0.0.1`. Nothing is uploaded.
13
+
14
+ ## Install
15
+
16
+ You need Python 3.9 or newer.
17
+
18
+ ```bash
19
+ pipx install nock-cli
20
+ nock add coder # or: video, agent
21
+ nock doctor
22
+ ```
23
+
24
+ `nock add` downloads, once:
25
+ - the engine: an official [llama.cpp](https://github.com/ggml-org/llama.cpp) build (about 35 MB; Vulkan on Windows
26
+ and Linux, Metal on Apple Silicon). Already have `llama-server`? Nock uses it.
27
+ - the skill's model from Hugging Face.
28
+
29
+ | Skill | Model | Download | Needs |
30
+ |---|---|---|---|
31
+ | `coder` | [Nock Coder 1.5B](https://huggingface.co/Nock-AI/nock-coder-1.5b-GGUF) | 1 GB | any laptop |
32
+ | `video` | [Nock Video 4B](https://huggingface.co/Nock-AI/nock-video-4b-GGUF) (preview) | 3.4 GB | a GPU with 6 GB for 64 frames; [ffmpeg](https://ffmpeg.org/download.html) |
33
+ | `agent` | [Nock Agent 31B](https://huggingface.co/Nock-AI/nock-agent-31b-GGUF) (experimental) | 18.7 GB | a GPU with about 20 GB (a Mac with 32 GB or more may work; not tested) |
34
+
35
+ `nock list` shows what is installed, `nock remove <skill>` deletes a skill's files. Files go to `~/.nock`
36
+ (set `NOCK_HOME` to use another folder).
37
+
38
+ ## Nock Coder: `nock code`
39
+
40
+ ```bash
41
+ nock code "write a Solidity ERC-20 with a cap"
42
+ cat error.log | nock code "why does this fail?"
43
+ nock code # a chat; type exit to quit
44
+ ```
45
+
46
+ Code first, one short line of explanation. The same model and prompt as `ollama run nockai/nock-coder`. It is a
47
+ 1.5B model, so review code before you run it. On HumanEval it passes 118 of 164.
48
+
49
+ ## Nock Video: `nock video`
50
+
51
+ ```bash
52
+ nock video summarize meeting.mp4
53
+ nock video ask clip.mp4 "what error message shows on screen?"
54
+ nock video find clip.mp4 "when the cake comes out"
55
+ ```
56
+
57
+ Files or links. Links are downloaded to your own machine with [yt-dlp](https://github.com/yt-dlp/yt-dlp), which
58
+ you install yourself (`pipx inject nock-cli yt-dlp`). Only use videos you have the right to use.
59
+
60
+ Nock Video 4B is a **preview**. On our test of 20 videos, through llama.cpp at 64 frames, it found the moment in
61
+ 43 of 56 questions, answered 59 of 60 questions about what is on screen, and gave 6 of 7 good summaries. Full
62
+ results, including the misses, are on the [model card](https://huggingface.co/Nock-AI/nock-video-4b-GGUF).
63
+
64
+ - It reads frames only, not sound.
65
+ - A long video is read as at most 64 frames, so short events in long videos can be missed.
66
+ - 64 frames used about 5.5 GB of GPU memory in our test. On GPUs under 6 GB, Nock uses 32 frames (the first
67
+ preview dropped from 42 to 35 of 56 moments at 32 frames; the current model was not measured at 32).
68
+ `--frames` overrides it.
69
+
70
+ ## Nock Agent: `nock fix` (experimental)
71
+
72
+ ```bash
73
+ nock fix ./my-project "the total is off by one"
74
+ ```
75
+
76
+ Nock Agent reads the code, makes the smallest edit and runs the tests. On our agent test it solved 145 of 150 runs
77
+ and edited a test file 0 times. It works on your own files, so **every edit is shown as a diff and every command
78
+ before it runs, and each one needs your yes**. `--yes` skips the questions; use it only in a copy or a git branch.
79
+ It refuses to start on GPUs under about 20 GB (`--force` to try anyway, very slowly).
80
+
81
+ ## Use it from your apps: `nock serve`
82
+
83
+ ```bash
84
+ nock serve
85
+ ```
86
+
87
+ One local, OpenAI-compatible address for every skill you have installed. In your editor or tool, set the base URL to
88
+ `http://127.0.0.1:8080/v1`, any API key, and the model to `nock-coder`, `nock-video` or `nock-agent`.
89
+
90
+ ```bash
91
+ curl http://127.0.0.1:8080/v1/chat/completions -H "Content-Type: application/json" -d '{"model": "nock-coder", "messages": [{"role": "user", "content": "regex for an EVM address"}]}'
92
+ ```
93
+
94
+ The model starts on first use, and one model is in memory at a time, so asking for another swaps them (it can take
95
+ a while). Streaming works. It listens on this machine only, refuses requests from websites, and logs no questions or
96
+ answers. `--port` changes the port.
97
+
98
+ ## Speed and GPUs
99
+
100
+ `nock doctor` shows which device the engine uses. If it says **software device** or **no GPU device**, the
101
+ engine runs on the CPU and answers take many minutes. This happens inside most containers and VMs. On a machine
102
+ with an NVIDIA GPU, `nock setup --backend cuda` installs the CUDA build instead (a bigger download: about 630 MB on Windows, 740 MB on Linux).
103
+
104
+ | Option | Meaning |
105
+ |---|---|
106
+ | `--backend vulkan\|cuda\|cpu\|metal` | which llama.cpp build `nock add` / `nock setup` installs |
107
+ | `NOCK_LLAMA_SERVER=/path/llama-server` | use your own llama.cpp build |
108
+ | `NOCK_HOME=/path` | where models and the engine are stored |
109
+
110
+ Server logs are in `~/.nock/logs/` if something goes wrong.
111
+
112
+ ## License
113
+
114
+ Apache-2.0. Each model has its own license on its model card (all Apache-2.0).
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "nock-cli"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Open models that live on your machine: code, video and a coding agent, offline."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -1,3 +1,3 @@
1
1
  """Nock CLI: open models that live on your machine."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.0"
@@ -1,222 +1,251 @@
1
- """Command line entry point: `nock add`, `nock code`, `nock video ...`, `nock fix`, `nock doctor`."""
2
-
3
- import argparse
4
- import shutil
5
- import subprocess
6
- import sys
7
- import urllib.error
8
-
9
- from nock_cli import __version__, agent, code, engine, models, paths, video
10
-
11
- SKILLS = list(models.MODELS)
12
-
13
-
14
- def ensure_engine(settings, backend=None):
15
- """Installs Nock's llama.cpp build when there is none yet (or when a backend is asked for), and records whether
16
- it can use a GPU, so every later command can warn without asking the engine again."""
17
- existing = engine.locate_server(settings)
18
- if existing and not backend:
19
- if "uses_gpu" not in settings:
20
- record_devices(settings, existing)
21
- return
22
- print("Installing the engine (llama.cpp, from github.com/ggml-org/llama.cpp)...")
23
- server, chosen = engine.install(backend)
24
- settings.update(llama_server=str(server), llama_cpp_build=engine.LLAMA_CPP_BUILD, backend=chosen)
25
- record_devices(settings, server)
26
-
27
-
28
- def record_devices(settings, server):
29
- settings["uses_gpu"], settings["device"], settings["device_id"] = engine.gpu_check(engine.list_devices(server))
30
- if not settings["uses_gpu"] and settings.get("backend") != "cpu":
31
- warn_no_gpu(settings)
32
-
33
-
34
- def warn_no_gpu(settings):
35
- print(f"\nWarning: {settings.get('device')}.", file=sys.stderr)
36
- if video.gpu_memory_mb() is not None:
37
- print(" This machine has an NVIDIA GPU. Try: nock setup --backend cuda", file=sys.stderr)
38
- print("", file=sys.stderr)
39
-
40
-
41
- def install_skills(skills, backend=None):
42
- settings = paths.load_settings()
43
- print(f"Nock home: {paths.nock_home()}")
44
- ensure_engine(settings, backend)
45
- for skill in skills:
46
- spec = models.MODELS[skill]
47
- print(f"Adding {spec['title']} ({spec['size']}) from huggingface.co/{spec['repo']}...")
48
- settings.setdefault("models", {})[skill] = models.install(skill)
49
- paths.save_settings(settings)
50
- print(f"Ready. Try: {spec['try']}")
51
- paths.save_settings(settings)
52
-
53
-
54
- def command_add(args):
55
- install_skills(args.skills, args.backend)
56
-
57
-
58
- def command_setup(args):
59
- # Kept from 0.1: `nock setup` installs the engine and Nock Video, or the skills named after it.
60
- install_skills(args.skills or ["video"], args.backend)
61
-
62
-
63
- def command_remove(args):
64
- settings = paths.load_settings()
65
- for skill in args.skills:
66
- models.remove(skill)
67
- settings.get("models", {}).pop(skill, None)
68
- print(f"Removed {models.MODELS[skill]['title']}.")
69
- paths.save_settings(settings)
70
-
71
-
72
- def command_list(args):
73
- installed = paths.load_settings().get("models", {})
74
- for skill, spec in models.MODELS.items():
75
- if skill in installed:
76
- state = f"installed ({models.folder_size_gb(skill):.1f} GB, {installed[skill]['revision'][:7]})"
77
- else:
78
- state = f"not installed ({spec['size']}): nock add {skill}"
79
- print(f" {skill:<6} {spec['title']:<30} {state}")
80
-
81
-
82
- def check(label, ok, detail):
83
- print(f" [{'ok' if ok else '--'}] {label}: {detail}")
84
- return ok
85
-
86
-
87
- def command_doctor(args):
88
- settings = paths.load_settings()
89
- print(f"nock {__version__}, home {paths.nock_home()}")
90
- server = engine.locate_server(settings)
91
- ready = check("engine", bool(server), f"{server} ({settings.get('backend', 'own build')})" if server else "missing: run `nock add video`")
92
- if server:
93
- uses_gpu, device, _ = engine.gpu_check(engine.list_devices(server))
94
- check("GPU in the engine", uses_gpu, device)
95
- if not uses_gpu and video.gpu_memory_mb() is not None:
96
- print(" This machine has an NVIDIA GPU. Try: nock setup --backend cuda")
97
- ready &= check("ffmpeg (for video)", bool(shutil.which("ffmpeg") and shutil.which("ffprobe")),
98
- shutil.which("ffmpeg") or "missing: install ffmpeg (https://ffmpeg.org/download.html)")
99
- check("yt-dlp (optional, for links)", bool(shutil.which("yt-dlp")), shutil.which("yt-dlp") or "not installed: pipx inject nock-cli yt-dlp")
100
- installed = settings.get("models", {})
101
- for skill in SKILLS:
102
- item = installed.get(skill)
103
- check(f"skill {skill}", bool(item), f"{item['repo']}@{item['revision'][:7]}" if item else f"not installed: nock add {skill}")
104
- memory = video.gpu_memory_mb()
105
- check("NVIDIA GPU", memory is not None,
106
- f"{memory} MB" if memory is not None else "not found (other GPUs work through Vulkan or Metal; CPU only is slow)")
107
- free_gb = shutil.disk_usage(paths.nock_home().anchor or "/").free / 1e9
108
- check("free disk", free_gb > 5, f"{free_gb:.0f} GB")
109
- return 0 if ready else 1
110
-
111
-
112
- def start_server_factory(skill):
113
- settings = paths.load_settings()
114
- server_binary = engine.locate_server(settings)
115
- installed = settings.get("models", {}).get(skill)
116
- if not server_binary or not installed:
117
- sys.exit(f"{models.MODELS[skill]['title']} is not installed yet. Run: nock add {skill}")
118
- if settings.get("uses_gpu") is False and settings.get("backend") != "cpu":
119
- warn_no_gpu(settings)
120
- log_path = paths.nock_home() / "logs" / f"{skill}-server.log"
121
- default_context = models.MODELS[skill].get("context")
122
-
123
- def start(context=None):
124
- return engine.LocalServer(server_binary, installed["model"], installed.get("mmproj"), context or default_context,
125
- 99, log_path, settings.get("device_id"))
126
-
127
- return start
128
-
129
-
130
- def command_video(args):
131
- kind = {"summarize": "summary", "ask": "ask", "find": "moment"}[args.action]
132
- question = video.SUMMARY_QUESTION if kind == "summary" else args.question
133
- frames = video.choose_frames(args.frames)
134
- print(video.run(args.source, question, kind, frames, start_server_factory("video")))
135
-
136
-
137
- def command_code(args):
138
- code.run(" ".join(args.prompt).strip(), start_server_factory("coder"))
139
-
140
-
141
- def command_fix(args):
142
- need = models.MODELS["agent"]["min_gpu_mb"]
143
- memory = video.gpu_memory_mb()
144
- if memory is not None and memory < need and not args.force:
145
- sys.exit(f"Nock Agent 31B needs a GPU with about {need // 1000} GB; this one has {memory // 1000} GB. "
146
- "Use --force to try anyway (it will be very slow).")
147
- if memory is None:
148
- print(f"Note: Nock Agent 31B needs about {need // 1000} GB of GPU memory (a Mac with 32 GB or more may work; not tested).", file=sys.stderr)
149
- approve = agent.Approver(always=args.yes)
150
- print(agent.run(args.project, args.task, approve, start_server_factory("agent")))
151
-
152
-
153
- def build_parser():
154
- parser = argparse.ArgumentParser(prog="nock", description="Open models that live on your machine.")
155
- parser.add_argument("--version", action="version", version=f"nock {__version__}")
156
- commands = parser.add_subparsers(dest="command", required=True)
157
-
158
- add = commands.add_parser("add", help="add a skill: coder (1 GB), video (3.4 GB), agent (18.7 GB)")
159
- add.add_argument("skills", nargs="+", choices=SKILLS)
160
- add.add_argument("--backend", choices=engine.BACKENDS, help="llama.cpp build: vulkan (default), cuda, cpu, metal (Mac)")
161
- add.set_defaults(handler=command_add)
162
-
163
- remove = commands.add_parser("remove", help="delete a skill's files")
164
- remove.add_argument("skills", nargs="+", choices=SKILLS)
165
- remove.set_defaults(handler=command_remove)
166
-
167
- listing = commands.add_parser("list", help="show the skills and what is installed")
168
- listing.set_defaults(handler=command_list)
169
-
170
- setup = commands.add_parser("setup", help="install the engine and Nock Video (or the skills you name)")
171
- setup.add_argument("skills", nargs="*", choices=SKILLS)
172
- setup.add_argument("--backend", choices=engine.BACKENDS, help="llama.cpp build: vulkan (default), cuda, cpu, metal (Mac)")
173
- setup.set_defaults(handler=command_setup)
174
-
175
- doctor = commands.add_parser("doctor", help="check that everything is in place")
176
- doctor.set_defaults(handler=command_doctor)
177
-
178
- code_parser = commands.add_parser("code", help="ask Nock Coder; no question starts a chat; pipe a file or log in")
179
- code_parser.add_argument("prompt", nargs="*")
180
- code_parser.set_defaults(handler=command_code)
181
-
182
- video_parser = commands.add_parser("video", help="ask about a video file or link, offline")
183
- actions = video_parser.add_subparsers(dest="action", required=True)
184
- summarize = actions.add_parser("summarize", help="a few short points about the video")
185
- summarize.add_argument("source", help="video file or link")
186
- ask = actions.add_parser("ask", help="a question about what the video shows")
187
- ask.add_argument("source", help="video file or link")
188
- ask.add_argument("question")
189
- find = actions.add_parser("find", help="when does something happen")
190
- find.add_argument("source", help="video file or link")
191
- find.add_argument("question", help='what to find, for example "the error message shows up"')
192
- for action in (summarize, ask, find):
193
- action.add_argument("--frames", type=int, help=f"frames to read (default {video.MAX_FRAMES}, "
194
- f"{video.LOW_MEMORY_FRAMES} on GPUs under 6 GB)")
195
- video_parser.set_defaults(handler=command_video)
196
-
197
- fix = commands.add_parser("fix", help="Nock Agent (experimental, big GPU): fix an issue in a project folder")
198
- fix.add_argument("project", help="the project folder")
199
- fix.add_argument("task", help='what to fix, for example "the total is off by one"')
200
- fix.add_argument("--yes", action="store_true", help="approve every edit and command without asking")
201
- fix.add_argument("--force", action="store_true", help="start even on a GPU that is too small")
202
- fix.set_defaults(handler=command_fix)
203
- return parser
204
-
205
-
206
- def main(argv=None):
207
- args = build_parser().parse_args(argv)
208
- try:
209
- result = args.handler(args)
210
- except KeyboardInterrupt:
211
- sys.exit("Stopped.")
212
- except (engine.EngineError, models.ModelError, video.VideoError, ValueError) as error:
213
- sys.exit(f"nock: {error}")
214
- except subprocess.CalledProcessError as error:
215
- sys.exit(f"nock: {error.cmd[0]} failed (exit code {error.returncode})")
216
- except urllib.error.URLError as error:
217
- sys.exit(f"nock: network error: {error.reason}")
218
- sys.exit(result or 0)
219
-
220
-
221
- if __name__ == "__main__":
222
- main()
1
+ """Command line entry point: `nock add`, `nock code`, `nock video ...`, `nock fix`, `nock doctor`."""
2
+
3
+ import argparse
4
+ import shutil
5
+ import subprocess
6
+ import sys
7
+ import urllib.error
8
+
9
+ from nock_cli import __version__, agent, code, engine, models, paths, serve, video
10
+
11
+ SKILLS = list(models.MODELS)
12
+
13
+
14
+ def ensure_engine(settings, backend=None):
15
+ """Installs Nock's llama.cpp build when there is none yet (or when a backend is asked for), and records whether
16
+ it can use a GPU, so every later command can warn without asking the engine again."""
17
+ existing = engine.locate_server(settings)
18
+ if existing and not backend:
19
+ if "uses_gpu" not in settings:
20
+ record_devices(settings, existing)
21
+ return
22
+ print("Installing the engine (llama.cpp, from github.com/ggml-org/llama.cpp)...")
23
+ server, chosen = engine.install(backend)
24
+ settings.update(llama_server=str(server), llama_cpp_build=engine.LLAMA_CPP_BUILD, backend=chosen)
25
+ record_devices(settings, server)
26
+
27
+
28
+ def record_devices(settings, server):
29
+ settings["uses_gpu"], settings["device"], settings["device_id"] = engine.gpu_check(engine.list_devices(server))
30
+ if not settings["uses_gpu"] and settings.get("backend") != "cpu":
31
+ warn_no_gpu(settings)
32
+
33
+
34
+ def warn_no_gpu(settings):
35
+ print(f"\nWarning: {settings.get('device')}.", file=sys.stderr)
36
+ if video.gpu_memory_mb() is not None:
37
+ print(" This machine has an NVIDIA GPU. Try: nock setup --backend cuda", file=sys.stderr)
38
+ print("", file=sys.stderr)
39
+
40
+
41
+ def install_skills(skills, backend=None):
42
+ settings = paths.load_settings()
43
+ print(f"Nock home: {paths.nock_home()}")
44
+ ensure_engine(settings, backend)
45
+ for skill in skills:
46
+ spec = models.MODELS[skill]
47
+ print(f"Adding {spec['title']} ({spec['size']}) from huggingface.co/{spec['repo']}...")
48
+ settings.setdefault("models", {})[skill] = models.install(skill)
49
+ paths.save_settings(settings)
50
+ print(f"Ready. Try: {spec['try']}")
51
+ paths.save_settings(settings)
52
+
53
+
54
+ def command_add(args):
55
+ install_skills(args.skills, args.backend)
56
+
57
+
58
+ def command_setup(args):
59
+ # Kept from 0.1: `nock setup` installs the engine and Nock Video, or the skills named after it.
60
+ install_skills(args.skills or ["video"], args.backend)
61
+
62
+
63
+ def command_remove(args):
64
+ settings = paths.load_settings()
65
+ for skill in args.skills:
66
+ models.remove(skill)
67
+ settings.get("models", {}).pop(skill, None)
68
+ print(f"Removed {models.MODELS[skill]['title']}.")
69
+ paths.save_settings(settings)
70
+
71
+
72
+ def command_list(args):
73
+ installed = paths.load_settings().get("models", {})
74
+ for skill, spec in models.MODELS.items():
75
+ if skill in installed:
76
+ state = f"installed ({models.folder_size_gb(skill):.1f} GB, {installed[skill]['revision'][:7]})"
77
+ else:
78
+ state = f"not installed ({spec['size']}): nock add {skill}"
79
+ print(f" {skill:<6} {spec['title']:<30} {state}")
80
+
81
+
82
+ def check(label, ok, detail):
83
+ print(f" [{'ok' if ok else '--'}] {label}: {detail}")
84
+ return ok
85
+
86
+
87
+ def command_doctor(args):
88
+ settings = paths.load_settings()
89
+ print(f"nock {__version__}, home {paths.nock_home()}")
90
+ server = engine.locate_server(settings)
91
+ ready = check("engine", bool(server), f"{server} ({settings.get('backend', 'own build')})" if server else "missing: run `nock add video`")
92
+ if server:
93
+ uses_gpu, device, _ = engine.gpu_check(engine.list_devices(server))
94
+ check("GPU in the engine", uses_gpu, device)
95
+ if not uses_gpu and video.gpu_memory_mb() is not None:
96
+ print(" This machine has an NVIDIA GPU. Try: nock setup --backend cuda")
97
+ ready &= check("ffmpeg (for video)", bool(shutil.which("ffmpeg") and shutil.which("ffprobe")),
98
+ shutil.which("ffmpeg") or "missing: install ffmpeg (https://ffmpeg.org/download.html)")
99
+ check("yt-dlp (optional, for links)", bool(shutil.which("yt-dlp")), shutil.which("yt-dlp") or "not installed: pipx inject nock-cli yt-dlp")
100
+ installed = settings.get("models", {})
101
+ for skill in SKILLS:
102
+ item = installed.get(skill)
103
+ check(f"skill {skill}", bool(item), f"{item['repo']}@{item['revision'][:7]}" if item else f"not installed: nock add {skill}")
104
+ memory = video.gpu_memory_mb()
105
+ check("NVIDIA GPU", memory is not None,
106
+ f"{memory} MB" if memory is not None else "not found (other GPUs work through Vulkan or Metal; CPU only is slow)")
107
+ free_gb = shutil.disk_usage(paths.nock_home().anchor or "/").free / 1e9
108
+ check("free disk", free_gb > 5, f"{free_gb:.0f} GB")
109
+ return 0 if ready else 1
110
+
111
+
112
+ def start_server_factory(skill):
113
+ settings = paths.load_settings()
114
+ server_binary = engine.locate_server(settings)
115
+ installed = settings.get("models", {}).get(skill)
116
+ if not server_binary or not installed:
117
+ sys.exit(f"{models.MODELS[skill]['title']} is not installed yet. Run: nock add {skill}")
118
+ if settings.get("uses_gpu") is False and settings.get("backend") != "cpu":
119
+ warn_no_gpu(settings)
120
+ log_path = paths.nock_home() / "logs" / f"{skill}-server.log"
121
+ default_context = models.MODELS[skill].get("context")
122
+
123
+ def start(context=None):
124
+ return engine.LocalServer(server_binary, installed["model"], installed.get("mmproj"), context or default_context,
125
+ 99, log_path, settings.get("device_id"))
126
+
127
+ return start
128
+
129
+
130
+ def command_video(args):
131
+ kind = {"summarize": "summary", "ask": "ask", "find": "moment"}[args.action]
132
+ question = video.SUMMARY_QUESTION if kind == "summary" else args.question
133
+ frames = video.choose_frames(args.frames)
134
+ print(video.run(args.source, question, kind, frames, start_server_factory("video")))
135
+
136
+
137
+ def command_code(args):
138
+ code.run(" ".join(args.prompt).strip(), start_server_factory("coder"))
139
+
140
+
141
+ def command_fix(args):
142
+ need = models.MODELS["agent"]["min_gpu_mb"]
143
+ memory = video.gpu_memory_mb()
144
+ if memory is not None and memory < need and not args.force:
145
+ sys.exit(f"Nock Agent 31B needs a GPU with about {need // 1000} GB; this one has {memory // 1000} GB. "
146
+ "Use --force to try anyway (it will be very slow).")
147
+ if memory is None:
148
+ print(f"Note: Nock Agent 31B needs about {need // 1000} GB of GPU memory (a Mac with 32 GB or more may work; not tested).", file=sys.stderr)
149
+ approve = agent.Approver(always=args.yes)
150
+ print(agent.run(args.project, args.task, approve, start_server_factory("agent")))
151
+
152
+
153
+ def command_serve(args):
154
+ installed = paths.load_settings().get("models", {})
155
+ if not installed:
156
+ sys.exit("No skills installed yet. Run: nock add coder")
157
+ memory = video.gpu_memory_mb()
158
+ starters = {}
159
+ for skill in installed:
160
+ need = models.MODELS.get(skill, {}).get("min_gpu_mb")
161
+ if need and memory is not None and memory < need and not args.force:
162
+ print(f"Leaving out {skill}: it needs about {need // 1000} GB of GPU memory (--force to include it).", file=sys.stderr)
163
+ continue
164
+ start = start_server_factory(skill)
165
+ starters[skill] = make_starter(skill, start)
166
+ if not starters:
167
+ sys.exit("None of the installed skills fits this GPU. Run: nock add coder")
168
+ serve.run(starters, args.port)
169
+
170
+
171
+ def make_starter(skill, start):
172
+ # Video has no fixed context in MODELS: its size follows the frame count, and an app can send up to 64 frames.
173
+ context = models.MODELS[skill].get("context") or video.context_for(video.MAX_FRAMES)
174
+ return lambda: start(context)
175
+
176
+
177
+ def build_parser():
178
+ parser = argparse.ArgumentParser(prog="nock", description="Open models that live on your machine.")
179
+ parser.add_argument("--version", action="version", version=f"nock {__version__}")
180
+ commands = parser.add_subparsers(dest="command", required=True)
181
+
182
+ add = commands.add_parser("add", help="add a skill: coder (1 GB), video (3.4 GB), agent (18.7 GB)")
183
+ add.add_argument("skills", nargs="+", choices=SKILLS)
184
+ add.add_argument("--backend", choices=engine.BACKENDS, help="llama.cpp build: vulkan (default), cuda, cpu, metal (Mac)")
185
+ add.set_defaults(handler=command_add)
186
+
187
+ remove = commands.add_parser("remove", help="delete a skill's files")
188
+ remove.add_argument("skills", nargs="+", choices=SKILLS)
189
+ remove.set_defaults(handler=command_remove)
190
+
191
+ listing = commands.add_parser("list", help="show the skills and what is installed")
192
+ listing.set_defaults(handler=command_list)
193
+
194
+ setup = commands.add_parser("setup", help="install the engine and Nock Video (or the skills you name)")
195
+ setup.add_argument("skills", nargs="*", choices=SKILLS)
196
+ setup.add_argument("--backend", choices=engine.BACKENDS, help="llama.cpp build: vulkan (default), cuda, cpu, metal (Mac)")
197
+ setup.set_defaults(handler=command_setup)
198
+
199
+ doctor = commands.add_parser("doctor", help="check that everything is in place")
200
+ doctor.set_defaults(handler=command_doctor)
201
+
202
+ code_parser = commands.add_parser("code", help="ask Nock Coder; no question starts a chat; pipe a file or log in")
203
+ code_parser.add_argument("prompt", nargs="*")
204
+ code_parser.set_defaults(handler=command_code)
205
+
206
+ video_parser = commands.add_parser("video", help="ask about a video file or link, offline")
207
+ actions = video_parser.add_subparsers(dest="action", required=True)
208
+ summarize = actions.add_parser("summarize", help="a few short points about the video")
209
+ summarize.add_argument("source", help="video file or link")
210
+ ask = actions.add_parser("ask", help="a question about what the video shows")
211
+ ask.add_argument("source", help="video file or link")
212
+ ask.add_argument("question")
213
+ find = actions.add_parser("find", help="when does something happen")
214
+ find.add_argument("source", help="video file or link")
215
+ find.add_argument("question", help='what to find, for example "the error message shows up"')
216
+ for action in (summarize, ask, find):
217
+ action.add_argument("--frames", type=int, help=f"frames to read (default {video.MAX_FRAMES}, "
218
+ f"{video.LOW_MEMORY_FRAMES} on GPUs under 6 GB)")
219
+ video_parser.set_defaults(handler=command_video)
220
+
221
+ serve_parser = commands.add_parser("serve", help="one local OpenAI-style address for your apps (this machine only)")
222
+ serve_parser.add_argument("--port", type=int, default=serve.DEFAULT_PORT)
223
+ serve_parser.add_argument("--force", action="store_true", help="include skills that may not fit this GPU")
224
+ serve_parser.set_defaults(handler=command_serve)
225
+
226
+ fix = commands.add_parser("fix", help="Nock Agent (experimental, big GPU): fix an issue in a project folder")
227
+ fix.add_argument("project", help="the project folder")
228
+ fix.add_argument("task", help='what to fix, for example "the total is off by one"')
229
+ fix.add_argument("--yes", action="store_true", help="approve every edit and command without asking")
230
+ fix.add_argument("--force", action="store_true", help="start even on a GPU that is too small")
231
+ fix.set_defaults(handler=command_fix)
232
+ return parser
233
+
234
+
235
+ def main(argv=None):
236
+ args = build_parser().parse_args(argv)
237
+ try:
238
+ result = args.handler(args)
239
+ except KeyboardInterrupt:
240
+ sys.exit("Stopped.")
241
+ except (engine.EngineError, models.ModelError, video.VideoError, ValueError) as error:
242
+ sys.exit(f"nock: {error}")
243
+ except subprocess.CalledProcessError as error:
244
+ sys.exit(f"nock: {error.cmd[0]} failed (exit code {error.returncode})")
245
+ except urllib.error.URLError as error:
246
+ sys.exit(f"nock: network error: {error.reason}")
247
+ sys.exit(result or 0)
248
+
249
+
250
+ if __name__ == "__main__":
251
+ main()
@@ -0,0 +1,181 @@
1
+ """`nock serve`: one local, OpenAI-compatible address for every installed skill.
2
+
3
+ Editors and tools point at http://127.0.0.1:8080/v1 and choose a model by name. The right llama-server starts on
4
+ first use; only one model is in memory at a time, so asking for another one swaps them. Everything stays on this
5
+ machine: it binds to 127.0.0.1, and refuses requests from websites (a page you visit could otherwise call it).
6
+ """
7
+
8
+ import json
9
+ import threading
10
+ import urllib.error
11
+ import urllib.request
12
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
13
+
14
+ from nock_cli import __version__
15
+ from nock_cli.code import SYSTEM_PROMPT as CODER_SYSTEM_PROMPT
16
+
17
+ DEFAULT_PORT = 8080
18
+ MODEL_NAMES = {"nock-coder": "coder", "nock-video": "video", "nock-agent": "agent"}
19
+ SYSTEM_PROMPTS = {"coder": CODER_SYSTEM_PROMPT} # same answers as `nock code`, unless the app sends its own
20
+ MAX_BODY_BYTES = 64 * 1024 * 1024 # room for many video frames as images
21
+ UPSTREAM_TIMEOUT = 1800
22
+ LOCAL_HOSTS = ("127.0.0.1", "localhost", "[::1]")
23
+
24
+
25
+ def is_local_host(value):
26
+ """True for 127.0.0.1, localhost and [::1], with or without a port."""
27
+ if not value:
28
+ return False
29
+ value = value.split("://", 1)[-1].split("/", 1)[0].lower()
30
+ host = value if value.startswith("[") and value.endswith("]") else value.rsplit(":", 1)[0] if ":" in value else value
31
+ return host in LOCAL_HOSTS
32
+
33
+
34
+ def allowed_request(headers):
35
+ """Blocks other websites (Origin) and DNS rebinding (Host). Apps and scripts send no Origin and pass."""
36
+ if not is_local_host(headers.get("Host")):
37
+ return False
38
+ origin = headers.get("Origin")
39
+ return origin is None or is_local_host(origin)
40
+
41
+
42
+ def prepare_body(body, skill):
43
+ """Adds the skill's system prompt when the app sent none. The app's other settings pass through unchanged."""
44
+ prompt = SYSTEM_PROMPTS.get(skill)
45
+ messages = body.get("messages")
46
+ if prompt and isinstance(messages, list) and not any(isinstance(m, dict) and m.get("role") == "system" for m in messages):
47
+ body = {**body, "messages": [{"role": "system", "content": prompt}, *messages]}
48
+ return body
49
+
50
+
51
+ class ModelRouter:
52
+ """Keeps at most one llama-server running. `starters` maps a skill to a function returning an unstarted server."""
53
+
54
+ def __init__(self, starters):
55
+ self.starters = starters
56
+ self.lock = threading.Lock() # held for a whole request: one answer at a time, and no swap in the middle
57
+ self.current_skill = None
58
+ self.current_server = None
59
+
60
+ def load(self, skill):
61
+ """Call with the lock held. Returns the running server for this skill."""
62
+ if self.current_skill != skill:
63
+ self.stop()
64
+ server = self.starters[skill]()
65
+ server.__enter__()
66
+ self.current_skill, self.current_server = skill, server
67
+ return self.current_server
68
+
69
+ def stop(self):
70
+ if self.current_server:
71
+ self.current_server.__exit__(None, None, None)
72
+ self.current_skill, self.current_server = None, None
73
+
74
+
75
+ def make_handler(router):
76
+ class Handler(BaseHTTPRequestHandler):
77
+ server_version = f"nock/{__version__}"
78
+ protocol_version = "HTTP/1.1"
79
+
80
+ def log_message(self, *args):
81
+ pass # questions and answers are never logged
82
+
83
+ def send_json(self, status, payload):
84
+ data = json.dumps(payload).encode()
85
+ self.send_response(status)
86
+ self.send_header("Content-Type", "application/json")
87
+ self.send_header("Content-Length", str(len(data)))
88
+ self.end_headers()
89
+ self.wfile.write(data)
90
+
91
+ def send_error_json(self, status, message):
92
+ self.send_json(status, {"error": {"message": message, "type": "nock_error"}})
93
+
94
+ def do_OPTIONS(self):
95
+ # No CORS headers: a browser's preflight from a website fails here.
96
+ self.send_response(403)
97
+ self.send_header("Content-Length", "0")
98
+ self.end_headers()
99
+
100
+ def do_GET(self):
101
+ if not allowed_request(self.headers):
102
+ return self.send_error_json(403, "Requests from websites are not allowed.")
103
+ if self.path == "/health":
104
+ return self.send_json(200, {"status": "ok", "loaded": router.current_skill})
105
+ if self.path == "/v1/models":
106
+ names = [name for name, skill in MODEL_NAMES.items() if skill in router.starters]
107
+ return self.send_json(200, {"object": "list", "data": [{"id": name, "object": "model", "owned_by": "nockai"} for name in names]})
108
+ self.send_error_json(404, "Not found. Try /v1/models or /v1/chat/completions.")
109
+
110
+ def do_POST(self):
111
+ if not allowed_request(self.headers):
112
+ return self.send_error_json(403, "Requests from websites are not allowed.")
113
+ if self.path != "/v1/chat/completions":
114
+ return self.send_error_json(404, "Only /v1/chat/completions is available.")
115
+ length = int(self.headers.get("Content-Length") or 0)
116
+ if length > MAX_BODY_BYTES:
117
+ return self.send_error_json(413, "Request is too large.")
118
+ try:
119
+ body = json.loads(self.rfile.read(length) or b"{}")
120
+ except ValueError:
121
+ return self.send_error_json(400, "The request body is not valid JSON.")
122
+ skill = MODEL_NAMES.get(body.get("model"))
123
+ if not skill:
124
+ return self.send_error_json(400, f"Unknown model. Use one of: {', '.join(MODEL_NAMES)}.")
125
+ if skill not in router.starters:
126
+ return self.send_error_json(404, f"{body['model']} is not installed. Run: nock add {skill}")
127
+ with router.lock:
128
+ try:
129
+ server = router.load(skill)
130
+ except Exception as error:
131
+ router.stop()
132
+ return self.send_error_json(503, f"Could not start {body['model']}: {error}")
133
+ self.forward(server.url, prepare_body(body, skill))
134
+
135
+ def forward(self, upstream_url, body):
136
+ request = urllib.request.Request(f"{upstream_url}/v1/chat/completions", data=json.dumps(body).encode(),
137
+ headers={"Content-Type": "application/json"})
138
+ try:
139
+ upstream = urllib.request.urlopen(request, timeout=UPSTREAM_TIMEOUT)
140
+ except urllib.error.HTTPError as error:
141
+ upstream = error # the engine's own error, passed on with its status
142
+ except OSError as error:
143
+ return self.send_error_json(502, f"The engine did not answer: {error}")
144
+ with upstream:
145
+ self.send_response(upstream.status)
146
+ self.send_header("Content-Type", upstream.headers.get("Content-Type", "application/json"))
147
+ # Chunked so streamed answers reach the app word by word.
148
+ self.send_header("Transfer-Encoding", "chunked")
149
+ self.end_headers()
150
+ while True:
151
+ chunk = upstream.read1(65536)
152
+ if not chunk:
153
+ break
154
+ self.wfile.write(f"{len(chunk):X}\r\n".encode() + chunk + b"\r\n")
155
+ self.wfile.flush()
156
+ self.wfile.write(b"0\r\n\r\n")
157
+
158
+ return Handler
159
+
160
+
161
+ def create_server(starters, port=DEFAULT_PORT):
162
+ router = ModelRouter(starters)
163
+ httpd = ThreadingHTTPServer(("127.0.0.1", port), make_handler(router))
164
+ return httpd, router
165
+
166
+
167
+ def run(starters, port=DEFAULT_PORT):
168
+ try:
169
+ httpd, router = create_server(starters, port)
170
+ except OSError:
171
+ raise ValueError(f"Port {port} is already in use. Try: nock serve --port {port + 1}")
172
+ print(f"nock serve: http://127.0.0.1:{port}/v1 (this machine only)")
173
+ print(f"Models: {', '.join(name for name, skill in MODEL_NAMES.items() if skill in starters)}")
174
+ print("In your app: base URL as above, any API key, model as listed. Ctrl+C to stop.")
175
+ try:
176
+ httpd.serve_forever()
177
+ except KeyboardInterrupt:
178
+ pass
179
+ finally:
180
+ router.stop()
181
+ httpd.server_close()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nock-cli
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Open models that live on your machine: code, video and a coding agent, offline.
5
5
  Author: NockAI
6
6
  License-Expression: Apache-2.0
@@ -101,6 +101,23 @@ and edited a test file 0 times. It works on your own files, so **every edit is s
101
101
  before it runs, and each one needs your yes**. `--yes` skips the questions; use it only in a copy or a git branch.
102
102
  It refuses to start on GPUs under about 20 GB (`--force` to try anyway, very slowly).
103
103
 
104
+ ## Use it from your apps: `nock serve`
105
+
106
+ ```bash
107
+ nock serve
108
+ ```
109
+
110
+ One local, OpenAI-compatible address for every skill you have installed. In your editor or tool, set the base URL to
111
+ `http://127.0.0.1:8080/v1`, any API key, and the model to `nock-coder`, `nock-video` or `nock-agent`.
112
+
113
+ ```bash
114
+ curl http://127.0.0.1:8080/v1/chat/completions -H "Content-Type: application/json" -d '{"model": "nock-coder", "messages": [{"role": "user", "content": "regex for an EVM address"}]}'
115
+ ```
116
+
117
+ The model starts on first use, and one model is in memory at a time, so asking for another swaps them (it can take
118
+ a while). Streaming works. It listens on this machine only, refuses requests from websites, and logs no questions or
119
+ answers. `--port` changes the port.
120
+
104
121
  ## Speed and GPUs
105
122
 
106
123
  `nock doctor` shows which device the engine uses. If it says **software device** or **no GPU device**, the
@@ -11,6 +11,7 @@ src/nock_cli/engine.py
11
11
  src/nock_cli/main.py
12
12
  src/nock_cli/models.py
13
13
  src/nock_cli/paths.py
14
+ src/nock_cli/serve.py
14
15
  src/nock_cli/video.py
15
16
  src/nock_cli.egg-info/PKG-INFO
16
17
  src/nock_cli.egg-info/SOURCES.txt
@@ -6,12 +6,14 @@ import tarfile
6
6
  import tempfile
7
7
  import threading
8
8
  import unittest
9
+ import urllib.error
10
+ import urllib.request
9
11
  import zipfile
10
12
  from contextlib import contextmanager
11
13
  from http.server import BaseHTTPRequestHandler, HTTPServer
12
14
  from pathlib import Path
13
15
 
14
- from nock_cli import agent, code, engine, main, models, video
16
+ from nock_cli import agent, code, engine, main, models, serve, video
15
17
 
16
18
 
17
19
  class FrameTimesTest(unittest.TestCase):
@@ -203,6 +205,86 @@ class AgentTest(unittest.TestCase):
203
205
  self.assertIn("outside the project", output)
204
206
 
205
207
 
208
+ class FakeEngine:
209
+ """Stands in for one llama-server: records its requests and whether it is running."""
210
+
211
+ def __init__(self, name, log):
212
+ self.name, self.log = name, log
213
+ self.fake = None
214
+
215
+ def __enter__(self):
216
+ self.log.append(f"start {self.name}")
217
+ self.fake = FakeModel([{"role": "assistant", "content": f"answer from {self.name}"}] * 5)
218
+ self.url = self.fake.url
219
+ return self
220
+
221
+ def __exit__(self, *args):
222
+ self.log.append(f"stop {self.name}")
223
+ self.fake.close()
224
+
225
+
226
+ class ServeTest(unittest.TestCase):
227
+ def setUp(self):
228
+ self.log = []
229
+ self.engines = {}
230
+
231
+ def starter(name):
232
+ def start():
233
+ self.engines[name] = FakeEngine(name, self.log)
234
+ return self.engines[name]
235
+ return start
236
+
237
+ self.httpd, self.router = serve.create_server({"coder": starter("coder"), "video": starter("video")}, 0)
238
+ self.port = self.httpd.server_address[1]
239
+ threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
240
+
241
+ def tearDown(self):
242
+ self.router.stop()
243
+ self.httpd.shutdown()
244
+ self.httpd.server_close()
245
+
246
+ def call(self, path, body=None, headers=None):
247
+ data = json.dumps(body).encode() if body is not None else None
248
+ request = urllib.request.Request(f"http://127.0.0.1:{self.port}{path}", data=data, headers=headers or {})
249
+ try:
250
+ with urllib.request.urlopen(request, timeout=10) as response:
251
+ return response.status, json.load(response)
252
+ except urllib.error.HTTPError as error:
253
+ return error.code, json.load(error)
254
+
255
+ def ask(self, model, **extra):
256
+ return self.call("/v1/chat/completions", {"model": model, "messages": [{"role": "user", "content": "hi"}], **extra})
257
+
258
+ def test_lists_installed_models_only(self):
259
+ _, listing = self.call("/v1/models")
260
+ self.assertEqual([m["id"] for m in listing["data"]], ["nock-coder", "nock-video"])
261
+
262
+ def test_routes_and_swaps_one_model_at_a_time(self):
263
+ self.assertEqual(self.ask("nock-coder")[1]["choices"][0]["message"]["content"], "answer from coder")
264
+ self.ask("nock-coder")
265
+ self.assertEqual(self.ask("nock-video")[1]["choices"][0]["message"]["content"], "answer from video")
266
+ self.assertEqual(self.log, ["start coder", "stop coder", "start video"])
267
+
268
+ def test_adds_coder_prompt_unless_the_app_sends_one(self):
269
+ self.ask("nock-coder")
270
+ sent = self.engines["coder"].fake.requests[0]["messages"]
271
+ self.assertEqual(sent[0]["content"], code.SYSTEM_PROMPT)
272
+ own = serve.prepare_body({"messages": [{"role": "system", "content": "mine"}]}, "coder")
273
+ self.assertEqual(len(own["messages"]), 1)
274
+
275
+ def test_errors_are_clear(self):
276
+ self.assertEqual(self.ask("gpt-5")[0], 400)
277
+ status, reply = self.ask("nock-agent")
278
+ self.assertEqual(status, 404)
279
+ self.assertIn("nock add agent", reply["error"]["message"])
280
+
281
+ def test_websites_are_refused(self):
282
+ self.assertEqual(self.call("/v1/models", headers={"Origin": "https://evil.example"})[0], 403)
283
+ self.assertEqual(self.call("/v1/models", headers={"Host": "evil.example"})[0], 403)
284
+ self.assertEqual(self.call("/v1/models", headers={"Origin": "http://localhost:3000"})[0], 200)
285
+ self.assertEqual(self.log, [])
286
+
287
+
206
288
  class ParserTest(unittest.TestCase):
207
289
  def test_commands_parse(self):
208
290
  parser = main.build_parser()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes