minu 0.1.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.
minu-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 mr-jones123
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
minu-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,134 @@
1
+ Metadata-Version: 2.4
2
+ Name: minu
3
+ Version: 0.1.0
4
+ Summary: Tiny agent harness for local models (llama.cpp, MLX, Ollama)
5
+ Keywords: agent,llm,local-llm,llama.cpp,mlx,ollama,tui,coding-agent
6
+ Author: mr-jones123
7
+ Author-email: mr-jones123 <lacapxyniljhed@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: MacOS
14
+ Classifier: Operating System :: POSIX :: Linux
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
18
+ Classifier: Topic :: Software Development
19
+ Requires-Dist: textual>=1.0
20
+ Requires-Dist: textual-image>=0.8
21
+ Requires-Dist: pillow>=10.3
22
+ Requires-Dist: mlx-lm ; platform_machine == 'arm64' and sys_platform == 'darwin' and extra == 'mlx'
23
+ Requires-Python: >=3.13
24
+ Project-URL: Homepage, https://github.com/mr-jones123/minu
25
+ Project-URL: Repository, https://github.com/mr-jones123/minu
26
+ Project-URL: Issues, https://github.com/mr-jones123/minu/issues
27
+ Provides-Extra: mlx
28
+ Description-Content-Type: text/markdown
29
+
30
+ # minu
31
+
32
+ A very small agent harness for local models. One tool (`sh`) and a system prompt of about 86 tokens.
33
+
34
+ ```
35
+ uv tool install minu # or: pipx install minu (needs Python 3.13; uv fetches it for you)
36
+ minu # run it in any project folder
37
+ ```
38
+ Upgrade with `uv tool upgrade minu`, remove with `uv tool uninstall minu`. Sessions are saved in `~/.minu/`.
39
+
40
+ ```
41
+ minu # opens the model picker
42
+ minu --hf LiquidAI/LFM3-1.7B-GGUF
43
+ minu --mlx mlx-community/Qwen3-1.7B-4bit
44
+ minu --ollama gemma3:4b
45
+ minu --url http://localhost:8080 # server that's already running
46
+ ```
47
+
48
+ `-y` runs commands without asking. `--native` uses the server's tool-call API instead of ```sh blocks.
49
+
50
+ ## Models
51
+ `ctrl+o` (or `/model`) opens the picker. It has:
52
+ - the GGUF and MLX models in your Hugging Face cache, plus your Ollama models
53
+ - **⬇ download from Hugging Face**: search, or paste `org/repo`, then pick a size. For vision repos the `mmproj` file is downloaded too.
54
+ - **⬇ pull from Ollama**: any name from ollama.com/library, with progress shown in the status bar.
55
+
56
+ Switching models stops the old server and starts the new one. The chat stays.
57
+
58
+ **✕ delete a model** (in the picker, or `/delete`) lists your downloaded models with their size on disk. It asks before
59
+ deleting (the default is "keep it"). For GGUF it removes that quant, plus the whole repo (mmproj, cache entry) once
60
+ no weights are left in it; MLX repos are removed whole; Ollama models go through `ollama`'s API. A loaded model is stopped first.
61
+ `esc` cancels a download.
62
+
63
+ If something is missing, minu offers to install it (it always asks first):
64
+ | needed for | tool | install |
65
+ |---|---|---|
66
+ | GGUF | llama-server | `brew install llama.cpp` |
67
+ | MLX (Apple Silicon only) | mlx_lm | `uv tool install mlx-lm` |
68
+ | HF downloads | hf | `uv tool install huggingface_hub` |
69
+ | Ollama | ollama | `brew install ollama` / ollama.com install script |
70
+
71
+ If Ollama is installed but not running, minu starts `ollama serve` itself and stops it when you quit.
72
+
73
+ ## Images
74
+ Put an image path in your message (dragging a file into the terminal pastes its path):
75
+ `what's in ~/Desktop/shot.png?`
76
+ The image is shown in the chat and sent to the model. That needs a vision model: GGUF repos with an
77
+ mmproj (marked `[vision]` in the picker), or an Ollama vision model such as `gemma3:4b`. MLX models are text only.
78
+ Image files the model mentions in its answer are shown too, and `/view path.png` shows any image.
79
+ Images render as real pixels in Kitty, Ghostty, WezTerm and iTerm2, and as colored blocks in other terminals.
80
+
81
+ ## Sessions
82
+ Each chat is saved to `~/.minu/sessions/<project>/<timestamp>.jsonl` (one folder per project path, e.g.
83
+ `-Users-me-code-app`): a `{"meta": …}` line, then one message per line. Set `MINU_HOME` to move `~/.minu`.
84
+ Sessions from older versions in `./.minu/sessions` still show up in `/resume`.
85
+ `/resume` opens a session picker · `minu -c` continues the latest one · `minu -r FILE` opens a specific file · `/new` (or `ctrl+n`) starts a new session.
86
+
87
+ ## Keys
88
+ `enter` send · `ctrl+j` newline · `ctrl+o` model · `ctrl+y` auto-run · `esc` stop · `ctrl+n` new session · `ctrl+c` quit
89
+ Type `/` for a menu of commands (↑/↓ to move, tab to complete, enter to run, esc to close):
90
+ `/new` `/model` `/delete` `/resume` `/search <query>` `/fetch <url>` `/view <img>` `/auto` `/clear` `/exit`
91
+
92
+ ## Web
93
+ The model can search on its own (`minu-search "q"`, `minu-fetch URL` in its shell). You can also do it yourself:
94
+ `/search <query>` gets results and the model answers from them; `/fetch <url>` reads a page and the model summarizes it.
95
+ Search tries DuckDuckGo first, then Wikipedia's API. DuckDuckGo sometimes rate-limits; for search that never gets blocked,
96
+ point minu at your own SearXNG instance: `MINU_SEARXNG=http://localhost:8888`.
97
+ Env: `MINU_CTX` (llama.cpp context, default 16384), `MINU_HOME` (default `~/.minu`), `OLLAMA_HOST`, `HF_HOME`. Server log: `~/.cache/minu/server.log`.
98
+
99
+ ## Development
100
+ ```
101
+ git clone https://github.com/mr-jones123/minu && cd minu
102
+ uv tool install -e . # global `minu` that runs your checkout (changes to src/ apply right away)
103
+ uv run minu # or run it from the repo
104
+ uv build # sdist + wheel into dist/
105
+ ```
106
+
107
+ ## Releasing to PyPI
108
+ Publishing runs in GitHub Actions (`.github/workflows/publish.yml`) with PyPI Trusted Publishing, so no token is stored.
109
+ One-time setup: on pypi.org → *Your account → Publishing → Add a pending publisher*: project `minu`,
110
+ owner `mr-jones123`, repo `minu`, workflow `publish.yml`, environment `pypi`. Then for each release:
111
+ ```
112
+ uv version --bump patch # or minor / major; edits pyproject.toml
113
+ git commit -am "release v$(uv version --short)" && git tag v$(uv version --short) && git push --follow-tags
114
+ ```
115
+ CI builds, smoke-tests the wheel, checks the tag matches the version, and uploads.
116
+
117
+ ## Code layout (`src/minu/`, the `minu` package)
118
+ | file | what it does |
119
+ |---|---|
120
+ | `cli.py` | command-line flags, plus `minu search` / `minu fetch` for the agent's shell |
121
+ | `config.py` | paths, limits, system prompts, banner |
122
+ | `app.py` | the TUI: layout, status bar, input handling |
123
+ | `agent.py` | agent loop: stream a reply, run its command, send the output back |
124
+ | `model_flows.py` | model picker, HF/Ollama downloads, install offers, loading |
125
+ | `sessions.py` | saving and resuming `~/.minu/sessions/<project>/*.jsonl` |
126
+ | `server.py` | starting llama-server / mlx_lm / Ollama and streaming chat |
127
+ | `models.py` | finding local models and searching Hugging Face |
128
+ | `tools.py` | the `sh` tool, web search/fetch, parsing commands from replies |
129
+ | `images.py` | attaching images and showing them |
130
+ | `installs.py` | finding tools and their install commands |
131
+ | `widgets.py` | input box, list picker, text prompt |
132
+
133
+ ## License
134
+ MIT, see [LICENSE](LICENSE).
minu-0.1.0/README.md ADDED
@@ -0,0 +1,105 @@
1
+ # minu
2
+
3
+ A very small agent harness for local models. One tool (`sh`) and a system prompt of about 86 tokens.
4
+
5
+ ```
6
+ uv tool install minu # or: pipx install minu (needs Python 3.13; uv fetches it for you)
7
+ minu # run it in any project folder
8
+ ```
9
+ Upgrade with `uv tool upgrade minu`, remove with `uv tool uninstall minu`. Sessions are saved in `~/.minu/`.
10
+
11
+ ```
12
+ minu # opens the model picker
13
+ minu --hf LiquidAI/LFM3-1.7B-GGUF
14
+ minu --mlx mlx-community/Qwen3-1.7B-4bit
15
+ minu --ollama gemma3:4b
16
+ minu --url http://localhost:8080 # server that's already running
17
+ ```
18
+
19
+ `-y` runs commands without asking. `--native` uses the server's tool-call API instead of ```sh blocks.
20
+
21
+ ## Models
22
+ `ctrl+o` (or `/model`) opens the picker. It has:
23
+ - the GGUF and MLX models in your Hugging Face cache, plus your Ollama models
24
+ - **⬇ download from Hugging Face**: search, or paste `org/repo`, then pick a size. For vision repos the `mmproj` file is downloaded too.
25
+ - **⬇ pull from Ollama**: any name from ollama.com/library, with progress shown in the status bar.
26
+
27
+ Switching models stops the old server and starts the new one. The chat stays.
28
+
29
+ **✕ delete a model** (in the picker, or `/delete`) lists your downloaded models with their size on disk. It asks before
30
+ deleting (the default is "keep it"). For GGUF it removes that quant, plus the whole repo (mmproj, cache entry) once
31
+ no weights are left in it; MLX repos are removed whole; Ollama models go through `ollama`'s API. A loaded model is stopped first.
32
+ `esc` cancels a download.
33
+
34
+ If something is missing, minu offers to install it (it always asks first):
35
+ | needed for | tool | install |
36
+ |---|---|---|
37
+ | GGUF | llama-server | `brew install llama.cpp` |
38
+ | MLX (Apple Silicon only) | mlx_lm | `uv tool install mlx-lm` |
39
+ | HF downloads | hf | `uv tool install huggingface_hub` |
40
+ | Ollama | ollama | `brew install ollama` / ollama.com install script |
41
+
42
+ If Ollama is installed but not running, minu starts `ollama serve` itself and stops it when you quit.
43
+
44
+ ## Images
45
+ Put an image path in your message (dragging a file into the terminal pastes its path):
46
+ `what's in ~/Desktop/shot.png?`
47
+ The image is shown in the chat and sent to the model. That needs a vision model: GGUF repos with an
48
+ mmproj (marked `[vision]` in the picker), or an Ollama vision model such as `gemma3:4b`. MLX models are text only.
49
+ Image files the model mentions in its answer are shown too, and `/view path.png` shows any image.
50
+ Images render as real pixels in Kitty, Ghostty, WezTerm and iTerm2, and as colored blocks in other terminals.
51
+
52
+ ## Sessions
53
+ Each chat is saved to `~/.minu/sessions/<project>/<timestamp>.jsonl` (one folder per project path, e.g.
54
+ `-Users-me-code-app`): a `{"meta": …}` line, then one message per line. Set `MINU_HOME` to move `~/.minu`.
55
+ Sessions from older versions in `./.minu/sessions` still show up in `/resume`.
56
+ `/resume` opens a session picker · `minu -c` continues the latest one · `minu -r FILE` opens a specific file · `/new` (or `ctrl+n`) starts a new session.
57
+
58
+ ## Keys
59
+ `enter` send · `ctrl+j` newline · `ctrl+o` model · `ctrl+y` auto-run · `esc` stop · `ctrl+n` new session · `ctrl+c` quit
60
+ Type `/` for a menu of commands (↑/↓ to move, tab to complete, enter to run, esc to close):
61
+ `/new` `/model` `/delete` `/resume` `/search <query>` `/fetch <url>` `/view <img>` `/auto` `/clear` `/exit`
62
+
63
+ ## Web
64
+ The model can search on its own (`minu-search "q"`, `minu-fetch URL` in its shell). You can also do it yourself:
65
+ `/search <query>` gets results and the model answers from them; `/fetch <url>` reads a page and the model summarizes it.
66
+ Search tries DuckDuckGo first, then Wikipedia's API. DuckDuckGo sometimes rate-limits; for search that never gets blocked,
67
+ point minu at your own SearXNG instance: `MINU_SEARXNG=http://localhost:8888`.
68
+ Env: `MINU_CTX` (llama.cpp context, default 16384), `MINU_HOME` (default `~/.minu`), `OLLAMA_HOST`, `HF_HOME`. Server log: `~/.cache/minu/server.log`.
69
+
70
+ ## Development
71
+ ```
72
+ git clone https://github.com/mr-jones123/minu && cd minu
73
+ uv tool install -e . # global `minu` that runs your checkout (changes to src/ apply right away)
74
+ uv run minu # or run it from the repo
75
+ uv build # sdist + wheel into dist/
76
+ ```
77
+
78
+ ## Releasing to PyPI
79
+ Publishing runs in GitHub Actions (`.github/workflows/publish.yml`) with PyPI Trusted Publishing, so no token is stored.
80
+ One-time setup: on pypi.org → *Your account → Publishing → Add a pending publisher*: project `minu`,
81
+ owner `mr-jones123`, repo `minu`, workflow `publish.yml`, environment `pypi`. Then for each release:
82
+ ```
83
+ uv version --bump patch # or minor / major; edits pyproject.toml
84
+ git commit -am "release v$(uv version --short)" && git tag v$(uv version --short) && git push --follow-tags
85
+ ```
86
+ CI builds, smoke-tests the wheel, checks the tag matches the version, and uploads.
87
+
88
+ ## Code layout (`src/minu/`, the `minu` package)
89
+ | file | what it does |
90
+ |---|---|
91
+ | `cli.py` | command-line flags, plus `minu search` / `minu fetch` for the agent's shell |
92
+ | `config.py` | paths, limits, system prompts, banner |
93
+ | `app.py` | the TUI: layout, status bar, input handling |
94
+ | `agent.py` | agent loop: stream a reply, run its command, send the output back |
95
+ | `model_flows.py` | model picker, HF/Ollama downloads, install offers, loading |
96
+ | `sessions.py` | saving and resuming `~/.minu/sessions/<project>/*.jsonl` |
97
+ | `server.py` | starting llama-server / mlx_lm / Ollama and streaming chat |
98
+ | `models.py` | finding local models and searching Hugging Face |
99
+ | `tools.py` | the `sh` tool, web search/fetch, parsing commands from replies |
100
+ | `images.py` | attaching images and showing them |
101
+ | `installs.py` | finding tools and their install commands |
102
+ | `widgets.py` | input box, list picker, text prompt |
103
+
104
+ ## License
105
+ MIT, see [LICENSE](LICENSE).
@@ -0,0 +1,53 @@
1
+ [project]
2
+ name = "minu"
3
+ version = "0.1.0"
4
+ description = "Tiny agent harness for local models (llama.cpp, MLX, Ollama)"
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ keywords = [
10
+ "agent",
11
+ "llm",
12
+ "local-llm",
13
+ "llama.cpp",
14
+ "mlx",
15
+ "ollama",
16
+ "tui",
17
+ "coding-agent",
18
+ ]
19
+ classifiers = [
20
+ "Development Status :: 3 - Alpha",
21
+ "Environment :: Console",
22
+ "Intended Audience :: Developers",
23
+ "Operating System :: MacOS",
24
+ "Operating System :: POSIX :: Linux",
25
+ "Programming Language :: Python :: 3",
26
+ "Programming Language :: Python :: 3.13",
27
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
28
+ "Topic :: Software Development",
29
+ ]
30
+ dependencies = [
31
+ "textual>=1.0",
32
+ "textual-image>=0.8",
33
+ "pillow>=10.3",
34
+ ]
35
+
36
+ [[project.authors]]
37
+ name = "mr-jones123"
38
+ email = "lacapxyniljhed@gmail.com"
39
+
40
+ [project.optional-dependencies]
41
+ mlx = ["mlx-lm; sys_platform == 'darwin' and platform_machine == 'arm64'"]
42
+
43
+ [project.urls]
44
+ Homepage = "https://github.com/mr-jones123/minu"
45
+ Repository = "https://github.com/mr-jones123/minu"
46
+ Issues = "https://github.com/mr-jones123/minu/issues"
47
+
48
+ [project.scripts]
49
+ minu = "minu.cli:main"
50
+
51
+ [build-system]
52
+ requires = ["uv_build>=0.9,<0.10"]
53
+ build-backend = "uv_build"
@@ -0,0 +1,37 @@
1
+ [project]
2
+ name = "minu"
3
+ version = "0.1.0"
4
+ description = "Tiny agent harness for local models (llama.cpp, MLX, Ollama)"
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ authors = [{ name = "mr-jones123", email = "lacapxyniljhed@gmail.com" }]
10
+ keywords = ["agent", "llm", "local-llm", "llama.cpp", "mlx", "ollama", "tui", "coding-agent"]
11
+ classifiers = [
12
+ "Development Status :: 3 - Alpha",
13
+ "Environment :: Console",
14
+ "Intended Audience :: Developers",
15
+ "Operating System :: MacOS",
16
+ "Operating System :: POSIX :: Linux",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.13",
19
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
20
+ "Topic :: Software Development",
21
+ ]
22
+ dependencies = ["textual>=1.0", "textual-image>=0.8", "pillow>=10.3"]
23
+
24
+ [project.optional-dependencies]
25
+ mlx = ["mlx-lm; sys_platform == 'darwin' and platform_machine == 'arm64'"]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/mr-jones123/minu"
29
+ Repository = "https://github.com/mr-jones123/minu"
30
+ Issues = "https://github.com/mr-jones123/minu/issues"
31
+
32
+ [project.scripts]
33
+ minu = "minu.cli:main"
34
+
35
+ [build-system]
36
+ requires = ["uv_build>=0.9,<0.10"]
37
+ build-backend = "uv_build"
@@ -0,0 +1,5 @@
1
+ """minu: a tiny agent harness for local models (llama.cpp / MLX / Ollama)."""
2
+ from importlib.metadata import PackageNotFoundError, version
3
+
4
+ try: __version__ = version("minu")
5
+ except PackageNotFoundError: __version__ = "0.0.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
@@ -0,0 +1,119 @@
1
+ """The agent loop: stream a reply, run its command, feed the output back."""
2
+ import json, re, time
3
+
4
+ from textual import work
5
+ from textual.widgets import Markdown
6
+
7
+ from .config import BLOCK, STEPS
8
+ from .images import mentioned_images
9
+ from .tools import fetch, search, sh, split_think, to_cmd
10
+
11
+
12
+ class AgentMixin:
13
+ def approve(s, cmd):
14
+ if s.auto: s.ui(s.note, f"$ {cmd}", "cmd"); return True
15
+ s.ui(s.note, f"$ {cmd}", "cmd")
16
+ return s.ask("run it?")
17
+
18
+ def exec_cmd(s, cmd):
19
+ if not s.approve(cmd): return "[user declined to run this]"
20
+ s.set_state("running…")
21
+ out = sh(cmd); s.state = ""
22
+ lines = out.splitlines()
23
+ s.ui(s.note, "\n".join(lines[:15]) + (f"\n… ({len(lines) - 15} more lines)" if len(lines) > 15 else ""), "out")
24
+ return out
25
+
26
+ def web(s, kind, arg): # /search and /fetch: look it up, show it, hand it to the model
27
+ s.set_state(f"{'searching' if kind == 'search' else 'fetching'}…")
28
+ try: res = search(arg) if kind == "search" else fetch(arg)
29
+ except Exception as e: res = f"{kind} failed: {e}"
30
+ lines = res.splitlines()
31
+ s.ui(s.note, "\n".join(lines[:20]) + (f"\n… ({len(lines) - 20} more lines)" if len(lines) > 20 else ""), "out")
32
+ if not s.srv.url: return s.say("▸ no model loaded, so just showing results (ctrl+o to pick one)")
33
+ ask = (f'Web search results for "{arg}":\n\n{res}\n\nAnswer "{arg}" now from these results, citing URLs. Do not run any commands.' if kind == "search"
34
+ else f"Contents of {arg}:\n\n{res}\n\nSummarize this page.")
35
+ s.push({"role": "user", "content": ask}); return True
36
+
37
+ @work(thread=True, exclusive=True, group="turn")
38
+ def turn(s, pre=None):
39
+ s.busy = True; s.stop_ev.clear()
40
+ try:
41
+ if not pre or s.web(*pre): # web() returns None when there's no model to answer
42
+ for _ in range(STEPS):
43
+ if s.step(): break
44
+ except Exception as e:
45
+ msg = str(e)
46
+ if re.search(r"image|mmproj|multimodal|vision", msg, re.I) and any(isinstance(m["content"], list) for m in s.msgs):
47
+ for m in s.msgs: # drop images so the chat can continue
48
+ if isinstance(m["content"], list):
49
+ m["content"] = m["content"][0]["text"] + "\n[image removed: model can't see images]"
50
+ msg = "this model can't read images. Pick one marked [vision] (ctrl+o), or e.g. gemma3:4b on Ollama"
51
+ s.say(f"▸ error: {msg}")
52
+ s.busy = False; s.set_state("")
53
+
54
+ def step(s):
55
+ md = think_box = think_w = None
56
+ raw, reasoning, calls, block = "", "", {}, None
57
+ last, first, think_t, n, final_tps = 0.0, None, None, 0, None
58
+ s.set_state("waiting…")
59
+ for ch in s.srv.stream(s.msgs, s.native, s.stop_ev):
60
+ if u := ch.get("usage"):
61
+ s.used = u.get("total_tokens") or u.get("prompt_tokens", 0) + u.get("completion_tokens", 0)
62
+ if u.get("completion_tokens") and first: final_tps = u["completion_tokens"] / max(time.time() - first, 1e-3)
63
+ if (tm := ch.get("timings")) and tm.get("predicted_per_second"): final_tps = tm["predicted_per_second"] # llama.cpp
64
+ if not ch.get("choices"): continue
65
+ d = ch["choices"][0].get("delta") or {}
66
+ piece, rpiece = d.get("content") or "", d.get("reasoning_content") or d.get("reasoning") or ""
67
+ raw += piece; reasoning += rpiece
68
+ for tc in d.get("tool_calls") or []:
69
+ c = calls.setdefault(tc.get("index", 0), {"id": "", "name": "", "args": ""})
70
+ f = tc.get("function") or {}
71
+ c["id"] = tc.get("id") or c["id"]; c["name"] += f.get("name") or ""; c["args"] += f.get("arguments") or ""
72
+ if piece or rpiece or d.get("tool_calls"):
73
+ n += 1; first = first or time.time()
74
+ think, text = s.split(reasoning, raw)
75
+ if not s.native and (block := BLOCK.search(text)):
76
+ break
77
+ if time.time() - last > 0.08:
78
+ last = time.time()
79
+ if n > 1: s.tps = (n - 1) / max(last - first, 1e-3) # ~1 token per streamed chunk
80
+ if think:
81
+ think_t = think_t or time.time()
82
+ if not think_box: think_box, think_w = s.ui(s.add_think)
83
+ s.ui(s.render, think_w, think)
84
+ if text:
85
+ if not md: md = s.ui(s.add, Markdown(""))
86
+ if think_box and not think_box.collapsed: s.ui(s.end_think, think_box, time.time() - think_t)
87
+ s.ui(s.render, md, text)
88
+ s.set_state("writing…" if text else "thinking…" if think else "waiting…")
89
+ think, text = s.split(reasoning, raw)
90
+ if block: text = text[:block.end()]
91
+ if final_tps: s.tps = final_tps
92
+ if think:
93
+ if not think_box: think_box, think_w = s.ui(s.add_think)
94
+ s.ui(s.render, think_w, think); s.ui(s.end_think, think_box, time.time() - (think_t or first or time.time()))
95
+ if text or not calls:
96
+ if not md: md = s.ui(s.add, Markdown(""))
97
+ s.ui(s.render, md, text or "_(empty)_")
98
+ if s.stop_ev.is_set():
99
+ s.push({"role": "assistant", "content": text}); s.say("▸ stopped"); return True
100
+ if calls:
101
+ s.push({"role": "assistant", "content": text, "tool_calls": [
102
+ {"id": c["id"] or f"call{i}", "type": "function", "function": {"name": c["name"], "arguments": c["args"]}}
103
+ for i, c in calls.items()]})
104
+ for i, c in calls.items():
105
+ try: cmd = json.loads(c["args"] or "{}").get("command", "")
106
+ except json.JSONDecodeError: cmd = c["args"]
107
+ s.push({"role": "tool", "tool_call_id": c["id"] or f"call{i}", "content": s.exec_cmd(cmd)})
108
+ return s.stop_ev.is_set()
109
+ s.push({"role": "assistant", "content": text})
110
+ if block:
111
+ s.push({"role": "user", "content": "Output:\n" + s.exec_cmd(to_cmd(block))})
112
+ return s.stop_ev.is_set()
113
+ for p in mentioned_images(text): s.ui(s.show_image, p)
114
+ return True
115
+
116
+ @staticmethod
117
+ def split(reasoning, raw): # reasoning field (llama.cpp/Ollama) + inline <think> tags -> (thinking, answer)
118
+ think, text = split_think(raw)
119
+ return "\n".join(x for x in (reasoning.strip(), think) if x), text