nock-cli 0.1.0__tar.gz → 0.2.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 (29) hide show
  1. nock_cli-0.2.0/PKG-INFO +120 -0
  2. nock_cli-0.2.0/README.md +97 -0
  3. {nock_cli-0.1.0 → nock_cli-0.2.0}/pyproject.toml +2 -2
  4. {nock_cli-0.1.0 → nock_cli-0.2.0}/src/nock_cli/__init__.py +1 -1
  5. nock_cli-0.2.0/src/nock_cli/agent.py +193 -0
  6. nock_cli-0.2.0/src/nock_cli/chat.py +19 -0
  7. nock_cli-0.2.0/src/nock_cli/code.py +62 -0
  8. {nock_cli-0.1.0 → nock_cli-0.2.0}/src/nock_cli/download.py +16 -3
  9. {nock_cli-0.1.0 → nock_cli-0.2.0}/src/nock_cli/engine.py +227 -180
  10. nock_cli-0.2.0/src/nock_cli/main.py +222 -0
  11. nock_cli-0.2.0/src/nock_cli/models.py +81 -0
  12. nock_cli-0.2.0/src/nock_cli.egg-info/PKG-INFO +120 -0
  13. {nock_cli-0.1.0 → nock_cli-0.2.0}/src/nock_cli.egg-info/SOURCES.txt +3 -0
  14. nock_cli-0.2.0/tests/test_cli.py +219 -0
  15. nock_cli-0.1.0/PKG-INFO +0 -90
  16. nock_cli-0.1.0/README.md +0 -67
  17. nock_cli-0.1.0/src/nock_cli/main.py +0 -124
  18. nock_cli-0.1.0/src/nock_cli/models.py +0 -42
  19. nock_cli-0.1.0/src/nock_cli.egg-info/PKG-INFO +0 -90
  20. nock_cli-0.1.0/tests/test_cli.py +0 -101
  21. {nock_cli-0.1.0 → nock_cli-0.2.0}/LICENSE +0 -0
  22. {nock_cli-0.1.0 → nock_cli-0.2.0}/setup.cfg +0 -0
  23. {nock_cli-0.1.0 → nock_cli-0.2.0}/src/nock_cli/__main__.py +0 -0
  24. {nock_cli-0.1.0 → nock_cli-0.2.0}/src/nock_cli/paths.py +0 -0
  25. {nock_cli-0.1.0 → nock_cli-0.2.0}/src/nock_cli/video.py +0 -0
  26. {nock_cli-0.1.0 → nock_cli-0.2.0}/src/nock_cli.egg-info/dependency_links.txt +0 -0
  27. {nock_cli-0.1.0 → nock_cli-0.2.0}/src/nock_cli.egg-info/entry_points.txt +0 -0
  28. {nock_cli-0.1.0 → nock_cli-0.2.0}/src/nock_cli.egg-info/requires.txt +0 -0
  29. {nock_cli-0.1.0 → nock_cli-0.2.0}/src/nock_cli.egg-info/top_level.txt +0 -0
@@ -0,0 +1,120 @@
1
+ Metadata-Version: 2.4
2
+ Name: nock-cli
3
+ Version: 0.2.0
4
+ Summary: Open models that live on your machine: code, video and a coding agent, offline.
5
+ Author: NockAI
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://nockai.dev
8
+ Project-URL: Model, https://huggingface.co/Nock-AI/nock-video-4b-GGUF
9
+ Keywords: local,offline,llm,video,llama.cpp,gguf
10
+ Classifier: Environment :: Console
11
+ Classifier: Operating System :: Microsoft :: Windows
12
+ Classifier: Operating System :: POSIX :: Linux
13
+ Classifier: Operating System :: MacOS
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Multimedia :: Video
16
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Provides-Extra: links
21
+ Requires-Dist: yt-dlp; extra == "links"
22
+ Dynamic: license-file
23
+
24
+ # Nock CLI
25
+
26
+ Open models that live on your machine. One tool for the NockAI family: code, video, and a coding agent, offline.
27
+
28
+ ```bash
29
+ nock add coder video
30
+ nock code "regex for an EVM address"
31
+ nock video find meeting.mp4 "when do they talk about the budget"
32
+ nock fix ./my-project "the total is off by one" # experimental, big GPU
33
+ ```
34
+
35
+ Everything runs on your computer. The models run in a local llama.cpp server on `127.0.0.1`. Nothing is uploaded.
36
+
37
+ ## Install
38
+
39
+ You need Python 3.9 or newer.
40
+
41
+ ```bash
42
+ pipx install nock-cli
43
+ nock add coder # or: video, agent
44
+ nock doctor
45
+ ```
46
+
47
+ `nock add` downloads, once:
48
+ - the engine: an official [llama.cpp](https://github.com/ggml-org/llama.cpp) build (about 35 MB; Vulkan on Windows
49
+ and Linux, Metal on Apple Silicon). Already have `llama-server`? Nock uses it.
50
+ - the skill's model from Hugging Face.
51
+
52
+ | Skill | Model | Download | Needs |
53
+ |---|---|---|---|
54
+ | `coder` | [Nock Coder 1.5B](https://huggingface.co/Nock-AI/nock-coder-1.5b-GGUF) | 1 GB | any laptop |
55
+ | `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) |
56
+ | `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) |
57
+
58
+ `nock list` shows what is installed, `nock remove <skill>` deletes a skill's files. Files go to `~/.nock`
59
+ (set `NOCK_HOME` to use another folder).
60
+
61
+ ## Nock Coder: `nock code`
62
+
63
+ ```bash
64
+ nock code "write a Solidity ERC-20 with a cap"
65
+ cat error.log | nock code "why does this fail?"
66
+ nock code # a chat; type exit to quit
67
+ ```
68
+
69
+ Code first, one short line of explanation. The same model and prompt as `ollama run nockai/nock-coder`. It is a
70
+ 1.5B model, so review code before you run it. On HumanEval it passes 118 of 164.
71
+
72
+ ## Nock Video: `nock video`
73
+
74
+ ```bash
75
+ nock video summarize meeting.mp4
76
+ nock video ask clip.mp4 "what error message shows on screen?"
77
+ nock video find clip.mp4 "when the cake comes out"
78
+ ```
79
+
80
+ Files or links. Links are downloaded to your own machine with [yt-dlp](https://github.com/yt-dlp/yt-dlp), which
81
+ you install yourself (`pipx inject nock-cli yt-dlp`). Only use videos you have the right to use.
82
+
83
+ Nock Video 4B is a **preview**. On our test of 20 videos, through llama.cpp at 64 frames, it found the moment in
84
+ 43 of 56 questions, answered 59 of 60 questions about what is on screen, and gave 6 of 7 good summaries. Full
85
+ results, including the misses, are on the [model card](https://huggingface.co/Nock-AI/nock-video-4b-GGUF).
86
+
87
+ - It reads frames only, not sound.
88
+ - A long video is read as at most 64 frames, so short events in long videos can be missed.
89
+ - 64 frames used about 5.5 GB of GPU memory in our test. On GPUs under 6 GB, Nock uses 32 frames (the first
90
+ preview dropped from 42 to 35 of 56 moments at 32 frames; the current model was not measured at 32).
91
+ `--frames` overrides it.
92
+
93
+ ## Nock Agent: `nock fix` (experimental)
94
+
95
+ ```bash
96
+ nock fix ./my-project "the total is off by one"
97
+ ```
98
+
99
+ Nock Agent reads the code, makes the smallest edit and runs the tests. On our agent test it solved 145 of 150 runs
100
+ and edited a test file 0 times. It works on your own files, so **every edit is shown as a diff and every command
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
+ It refuses to start on GPUs under about 20 GB (`--force` to try anyway, very slowly).
103
+
104
+ ## Speed and GPUs
105
+
106
+ `nock doctor` shows which device the engine uses. If it says **software device** or **no GPU device**, the
107
+ engine runs on the CPU and answers take many minutes. This happens inside most containers and VMs. On a machine
108
+ 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).
109
+
110
+ | Option | Meaning |
111
+ |---|---|
112
+ | `--backend vulkan\|cuda\|cpu\|metal` | which llama.cpp build `nock add` / `nock setup` installs |
113
+ | `NOCK_LLAMA_SERVER=/path/llama-server` | use your own llama.cpp build |
114
+ | `NOCK_HOME=/path` | where models and the engine are stored |
115
+
116
+ Server logs are in `~/.nock/logs/` if something goes wrong.
117
+
118
+ ## License
119
+
120
+ Apache-2.0. Each model has its own license on its model card (all Apache-2.0).
@@ -0,0 +1,97 @@
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).
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "nock-cli"
7
- version = "0.1.0"
8
- description = "Open models that live on your machine. Ask questions about your videos, offline."
7
+ version = "0.2.0"
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"
11
11
  license = "Apache-2.0"
@@ -1,3 +1,3 @@
1
1
  """Nock CLI: open models that live on your machine."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.2.0"
@@ -0,0 +1,193 @@
1
+ """Nock Agent (experimental): fixes an issue in a project folder on your machine.
2
+
3
+ The loop and the five tools are the ones Nock Agent was tested with (runpod/harness.py). Because it works on your
4
+ own files, every edit is shown as a diff and every command is shown before it runs, and each needs a yes
5
+ (`--yes` skips the questions). Reading and searching need no approval. Paths outside the project are refused.
6
+ """
7
+
8
+ import difflib
9
+ import json
10
+ import subprocess
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ from nock_cli.chat import complete
15
+
16
+ MAX_STEPS = 30
17
+ COMMAND_TIMEOUT = 120
18
+ MAX_TOKENS = 2048
19
+ SEARCH_SUFFIXES = (".py", ".js", ".ts", ".tsx", ".jsx", ".go", ".rs", ".java", ".kt", ".rb", ".php", ".c", ".h", ".cpp",
20
+ ".cs", ".sol", ".sh", ".txt", ".md", ".cfg", ".toml", ".json", ".yaml", ".yml", ".html", ".css")
21
+ SKIPPED_FOLDERS = {".git", "node_modules", ".venv", "venv", "__pycache__", "dist", "build", ".next", "target"}
22
+
23
+ AGENT_PROMPT = (
24
+ "You are Nock Agent, a coding agent working in a project folder. Use the tools to read the code, make the "
25
+ "smallest correct edit, and run the tests. Never edit test files. When the tests pass, reply with a one line "
26
+ "summary and stop calling tools."
27
+ )
28
+
29
+ TOOLS = [
30
+ {"type": "function", "function": {
31
+ "name": "read_file", "description": "Read a text file from the project. Optionally only a line range.",
32
+ "parameters": {"type": "object", "required": ["path"], "properties": {
33
+ "path": {"type": "string", "description": "File path relative to the project root"},
34
+ "start_line": {"type": "integer", "description": "First line to read (1 based)"},
35
+ "end_line": {"type": "integer", "description": "Last line to read"},
36
+ }}}},
37
+ {"type": "function", "function": {
38
+ "name": "edit_file", "description": "Replace one exact piece of text in a file with new text.",
39
+ "parameters": {"type": "object", "required": ["path", "old_text", "new_text"], "properties": {
40
+ "path": {"type": "string"},
41
+ "old_text": {"type": "string", "description": "Exact text to find, copied from the file"},
42
+ "new_text": {"type": "string", "description": "Text to put in its place"},
43
+ }}}},
44
+ {"type": "function", "function": {
45
+ "name": "write_file", "description": "Create a file or overwrite it with new content.",
46
+ "parameters": {"type": "object", "required": ["path", "content"], "properties": {
47
+ "path": {"type": "string"}, "content": {"type": "string"},
48
+ }}}},
49
+ {"type": "function", "function": {
50
+ "name": "search_code", "description": "Search all project files for a piece of text. Returns file:line matches.",
51
+ "parameters": {"type": "object", "required": ["query"], "properties": {"query": {"type": "string"}}}}},
52
+ {"type": "function", "function": {
53
+ "name": "run_command", "description": "Run a shell command in the project folder and return its output.",
54
+ "parameters": {"type": "object", "required": ["command"], "properties": {"command": {"type": "string"}}}}},
55
+ ]
56
+
57
+
58
+ class Approver:
59
+ """Asks before each change. `always` (from --yes) approves everything without asking."""
60
+
61
+ def __init__(self, always=False, ask=input):
62
+ self.always = always
63
+ self.ask = ask
64
+
65
+ def __call__(self, title, detail):
66
+ print(f"\n{title}\n{detail}", file=sys.stderr)
67
+ if self.always:
68
+ return True
69
+ try:
70
+ return self.ask("Allow? [y/N] ").strip().lower() in ("y", "yes")
71
+ except EOFError:
72
+ return False
73
+
74
+
75
+ def inside(root, path):
76
+ target = (root / path).resolve()
77
+ if target != root and root not in target.parents:
78
+ raise ValueError("path outside the project")
79
+ return target
80
+
81
+
82
+ def diff_text(path, before, after):
83
+ lines = difflib.unified_diff(before.splitlines(), after.splitlines(), f"a/{path}", f"b/{path}", lineterm="")
84
+ return "\n".join(lines) or "(no change)"
85
+
86
+
87
+ def project_files(root):
88
+ for path in sorted(root.rglob("*")):
89
+ if path.is_file() and path.suffix in SEARCH_SUFFIXES and not SKIPPED_FOLDERS.intersection(path.relative_to(root).parts):
90
+ yield path
91
+
92
+
93
+ def run_tool(root, name, arguments, approve):
94
+ """Runs one tool call. Errors and refusals go back to the model as text."""
95
+ try:
96
+ if name == "read_file":
97
+ text = inside(root, arguments["path"]).read_text(encoding="utf-8")
98
+ if not arguments.get("start_line") and not arguments.get("end_line"):
99
+ return text or "(empty file)"
100
+ lines = text.splitlines()
101
+ start = max(1, int(arguments.get("start_line") or 1))
102
+ end = min(len(lines), int(arguments.get("end_line") or len(lines)))
103
+ return f"Lines {start} to {end} of {len(lines)}:\n" + "\n".join(lines[start - 1:end])
104
+ if name == "search_code":
105
+ matches = []
106
+ for file in project_files(root):
107
+ try:
108
+ lines = file.read_text(encoding="utf-8").splitlines()
109
+ except UnicodeDecodeError:
110
+ continue
111
+ for number, line in enumerate(lines, 1):
112
+ if arguments["query"] in line:
113
+ matches.append(f"{file.relative_to(root).as_posix()}:{number}: {line.strip()}")
114
+ return "\n".join(matches[:30]) or "No matches."
115
+ if name == "edit_file":
116
+ target = inside(root, arguments["path"])
117
+ text = target.read_text(encoding="utf-8")
118
+ if arguments["old_text"] not in text:
119
+ return "Error: old_text not found in the file. Read the file again and copy the text exactly."
120
+ updated = text.replace(arguments["old_text"], arguments["new_text"], 1)
121
+ if not approve(f"Edit {arguments['path']}:", diff_text(arguments["path"], text, updated)):
122
+ return "The user declined this edit."
123
+ target.write_text(updated, encoding="utf-8")
124
+ return f"Edited {arguments['path']}"
125
+ if name == "write_file":
126
+ target = inside(root, arguments["path"])
127
+ before = target.read_text(encoding="utf-8") if target.exists() else ""
128
+ title = f"{'Overwrite' if target.exists() else 'Create'} {arguments['path']}:"
129
+ if not approve(title, diff_text(arguments["path"], before, arguments["content"])):
130
+ return "The user declined writing this file."
131
+ target.parent.mkdir(parents=True, exist_ok=True)
132
+ target.write_text(arguments["content"], encoding="utf-8")
133
+ return f"Wrote {arguments['path']}"
134
+ if name == "run_command":
135
+ if not approve("Run in the project folder:", f" $ {arguments['command']}"):
136
+ return "The user declined running this command."
137
+ completed = subprocess.run(arguments["command"], shell=True, cwd=root, capture_output=True, text=True,
138
+ timeout=COMMAND_TIMEOUT)
139
+ return ((completed.stdout + completed.stderr)[-3000:]).strip() or f"(no output, exit code {completed.returncode})"
140
+ return f"Error: unknown tool {name}"
141
+ except subprocess.TimeoutExpired:
142
+ return f"Error: the command took longer than {COMMAND_TIMEOUT} seconds and was stopped."
143
+ except Exception as error: # reported to the model, like an editor would
144
+ return f"Error: {type(error).__name__}: {error}"
145
+
146
+
147
+ def parse_arguments(raw):
148
+ if isinstance(raw, dict):
149
+ return raw
150
+ try:
151
+ value = json.loads(raw or "{}")
152
+ return value if isinstance(value, dict) else {}
153
+ except json.JSONDecodeError:
154
+ return {}
155
+
156
+
157
+ def describe(name, arguments):
158
+ if name == "read_file":
159
+ return f"reads {arguments.get('path')}"
160
+ if name == "search_code":
161
+ return f"searches for {arguments.get('query')!r}"
162
+ return name.replace("_", " ")
163
+
164
+
165
+ def run(project, task, approve, start_server, max_steps=MAX_STEPS):
166
+ root = Path(project).resolve()
167
+ if not root.is_dir():
168
+ raise ValueError(f"Not a folder: {project}")
169
+ messages = [
170
+ {"role": "system", "content": AGENT_PROMPT},
171
+ {"role": "user", "content": f"{task} Find and run the project's tests to check your fix."},
172
+ ]
173
+ with start_server() as server:
174
+ for step in range(max_steps):
175
+ message = complete(server.url, messages, MAX_TOKENS, tools=TOOLS)
176
+ calls = message.get("tool_calls") or []
177
+ record = {"role": "assistant", "content": message.get("content") or ""}
178
+ if calls:
179
+ record["tool_calls"] = [
180
+ {"id": call.get("id") or f"call_{step}_{index}", "type": "function",
181
+ "function": {"name": call["function"]["name"], "arguments": json.dumps(parse_arguments(call["function"].get("arguments")))}}
182
+ for index, call in enumerate(calls)
183
+ ]
184
+ messages.append(record)
185
+ if not calls:
186
+ return record["content"].strip() or "Done."
187
+ for call in record["tool_calls"]:
188
+ name, arguments = call["function"]["name"], parse_arguments(call["function"]["arguments"])
189
+ if name in ("read_file", "search_code"):
190
+ print(f" Nock Agent {describe(name, arguments)}", file=sys.stderr)
191
+ output = run_tool(root, name, arguments, approve)
192
+ messages.append({"role": "tool", "tool_call_id": call["id"], "name": name, "content": output})
193
+ return f"Stopped after {max_steps} steps without finishing. Check the changes it made."
@@ -0,0 +1,19 @@
1
+ """One call to the local llama-server's OpenAI-style chat endpoint (127.0.0.1 only)."""
2
+
3
+ import json
4
+ import urllib.request
5
+
6
+ REQUEST_TIMEOUT = 1800 # CPU-only machines can take many minutes
7
+
8
+
9
+ def complete(server_url, messages, max_tokens, temperature=0.0, tools=None, extra=None):
10
+ """Returns the assistant message (a dict with `content` and maybe `tool_calls`)."""
11
+ body = {"messages": messages, "temperature": temperature, "max_tokens": max_tokens}
12
+ if tools:
13
+ body["tools"] = tools
14
+ body["tool_choice"] = "auto"
15
+ body.update(extra or {})
16
+ request = urllib.request.Request(f"{server_url}/v1/chat/completions", data=json.dumps(body).encode(),
17
+ headers={"Content-Type": "application/json"})
18
+ with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT) as response:
19
+ return json.load(response)["choices"][0]["message"]
@@ -0,0 +1,62 @@
1
+ """Nock Coder in the terminal: one question, piped input, or a chat until you quit.
2
+
3
+ Same system prompt and temperature as the Ollama model (ollama/Modelfile), so answers match `ollama run nockai/nock-coder`.
4
+ """
5
+
6
+ import sys
7
+
8
+ from nock_cli.chat import complete
9
+
10
+ SYSTEM_PROMPT = ("You are Nock Coder, an open coding model from NockAI. Reply with code first. Keep any explanation to "
11
+ "one short sentence. For Solidity, use pragma solidity ^0.8.0 unless the user asks for another version.")
12
+ TEMPERATURE = 0.2
13
+ MAX_TOKENS = 1024
14
+ MAX_PIPED_CHARS = 20000 # about 5K tokens: leaves room in the 8K context for the question and the answer
15
+ EXIT_WORDS = ("exit", "quit", ":q")
16
+
17
+
18
+ def first_message(prompt, piped):
19
+ """The question with piped text (a file or an error log). The text goes first and the question last: with the
20
+ question first, Nock Coder repeated the code instead of answering (checked 2026-10-26 on a ZeroDivisionError log)."""
21
+ if piped and len(piped) > MAX_PIPED_CHARS:
22
+ print(f"Input is long: only the last {MAX_PIPED_CHARS:,} characters are sent.", file=sys.stderr)
23
+ piped = piped[-MAX_PIPED_CHARS:]
24
+ if piped and prompt:
25
+ return f"```\n{piped.rstrip()}\n```\n\n{prompt}"
26
+ return prompt or piped
27
+
28
+
29
+ def read_piped():
30
+ """Text piped into nock, or None. A closed or detached stdin (scripts, nohup, cron) counts as nothing piped."""
31
+ try:
32
+ if sys.stdin is None or sys.stdin.isatty():
33
+ return None
34
+ return sys.stdin.read() or None
35
+ except (OSError, ValueError):
36
+ return None
37
+
38
+
39
+ def run(prompt, start_server):
40
+ piped = read_piped()
41
+ message = first_message(prompt, piped)
42
+ messages = [{"role": "system", "content": SYSTEM_PROMPT}]
43
+ with start_server() as server:
44
+ if message:
45
+ messages.append({"role": "user", "content": message})
46
+ print(complete(server.url, messages, MAX_TOKENS, TEMPERATURE)["content"].strip())
47
+ return
48
+ # No question and nothing piped: a chat. The conversation is kept so follow-ups have context.
49
+ print("Nock Coder, offline. Type a question, or exit to quit.", file=sys.stderr)
50
+ while True:
51
+ try:
52
+ question = input("> ").strip()
53
+ except EOFError:
54
+ return
55
+ if question.lower() in EXIT_WORDS:
56
+ return
57
+ if not question:
58
+ continue
59
+ messages.append({"role": "user", "content": question})
60
+ reply = complete(server.url, messages, MAX_TOKENS, TEMPERATURE)["content"].strip()
61
+ messages.append({"role": "assistant", "content": reply})
62
+ print(reply + "\n")
@@ -40,15 +40,28 @@ def download(url, target, label):
40
40
  handle.write(chunk)
41
41
  done += len(chunk)
42
42
  show_progress(label, done, total)
43
- sys.stderr.write("\n")
43
+ if sys.stderr.isatty():
44
+ sys.stderr.write("\n")
44
45
  partial.replace(target)
45
46
  return target
46
47
 
47
48
 
49
+ _last_logged_step = {}
50
+
51
+
48
52
  def show_progress(label, done, total):
53
+ """One updating line on a terminal; in a log file (no terminal) a line every 10%, so logs stay readable."""
49
54
  megabytes = done / 1048576
50
55
  if total:
51
- sys.stderr.write(f"\r {label}: {megabytes:,.0f} / {total / 1048576:,.0f} MB ({done * 100 // total}%)")
56
+ line = f" {label}: {megabytes:,.0f} / {total / 1048576:,.0f} MB ({done * 100 // total}%)"
57
+ else:
58
+ line = f" {label}: {megabytes:,.0f} MB"
59
+ if sys.stderr.isatty():
60
+ sys.stderr.write("\r" + line)
52
61
  else:
53
- sys.stderr.write(f"\r {label}: {megabytes:,.0f} MB")
62
+ step = done * 10 // total if total else int(megabytes // 500)
63
+ if _last_logged_step.get(label) == step:
64
+ return
65
+ _last_logged_step[label] = step
66
+ sys.stderr.write(line + "\n")
54
67
  sys.stderr.flush()