minu 0.1.0__py3-none-any.whl
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/__init__.py +5 -0
- minu/__main__.py +3 -0
- minu/agent.py +119 -0
- minu/app.py +207 -0
- minu/cli.py +49 -0
- minu/config.py +49 -0
- minu/images.py +35 -0
- minu/installs.py +21 -0
- minu/model_flows.py +156 -0
- minu/models.py +104 -0
- minu/server.py +85 -0
- minu/sessions.py +75 -0
- minu/tools.py +100 -0
- minu/widgets.py +43 -0
- minu-0.1.0.dist-info/METADATA +134 -0
- minu-0.1.0.dist-info/RECORD +19 -0
- minu-0.1.0.dist-info/WHEEL +4 -0
- minu-0.1.0.dist-info/entry_points.txt +3 -0
- minu-0.1.0.dist-info/licenses/LICENSE +21 -0
minu/__init__.py
ADDED
minu/__main__.py
ADDED
minu/agent.py
ADDED
|
@@ -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
|
minu/app.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""The minu TUI: layout, status bar, input handling."""
|
|
2
|
+
import os, subprocess, threading, time, re
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from textual.app import App, ComposeResult
|
|
7
|
+
from textual.binding import Binding
|
|
8
|
+
from textual.containers import VerticalScroll
|
|
9
|
+
from textual.widgets import Collapsible, OptionList, Static
|
|
10
|
+
|
|
11
|
+
from .agent import AgentMixin
|
|
12
|
+
from .config import BANNER, COMMANDS, SYSTEM, SYSTEM_NATIVE
|
|
13
|
+
from .images import encode_image, find_images, image_widget
|
|
14
|
+
from .model_flows import ModelFlowsMixin
|
|
15
|
+
from .server import Server, kill_children
|
|
16
|
+
from .sessions import SessionsMixin
|
|
17
|
+
from .tools import sh_env
|
|
18
|
+
from .widgets import Prompt
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Minu(ModelFlowsMixin, SessionsMixin, AgentMixin, App):
|
|
22
|
+
CSS = """
|
|
23
|
+
#banner { height: 1fr; width: 100%; content-align: center middle; text-align: center; color: $accent; }
|
|
24
|
+
#log { height: 1fr; padding: 0 1; scrollbar-size-vertical: 1; display: none; }
|
|
25
|
+
.user { color: $accent; margin-top: 1; text-style: bold; }
|
|
26
|
+
.cmd { color: $warning; }
|
|
27
|
+
.out { color: $text-muted; }
|
|
28
|
+
.sys { color: $text-muted; text-style: italic; }
|
|
29
|
+
.img { width: auto; height: 14; margin: 0 2; }
|
|
30
|
+
Markdown { margin: 0; padding: 0; }
|
|
31
|
+
.think-box { background: transparent; border: none; padding: 0; margin: 1 0 0 0; }
|
|
32
|
+
.think-box > Contents { padding: 0 0 0 2; }
|
|
33
|
+
.think-box CollapsibleTitle { color: $text-muted; padding: 0; }
|
|
34
|
+
.think { color: $text-muted; text-style: italic; }
|
|
35
|
+
#prompt { height: auto; min-height: 5; max-height: 12; border: round $accent; padding: 0 1; background: $surface; }
|
|
36
|
+
#prompt:focus { border: round $accent-lighten-2; }
|
|
37
|
+
#prompt > .text-area--cursor-line { background: transparent; }
|
|
38
|
+
#status { height: 1; padding: 0 1; color: $text-muted; background: $panel; }
|
|
39
|
+
#slash { height: auto; max-height: 11; border: round $accent; background: $surface; display: none; padding: 0 1; }
|
|
40
|
+
"""
|
|
41
|
+
BINDINGS = [("ctrl+o", "pick", "model"), ("ctrl+y", "auto", "auto-run"),
|
|
42
|
+
("escape", "halt", "stop"), ("ctrl+l", "clear", "clear"), ("ctrl+n", "clear", "new session"),
|
|
43
|
+
Binding("ctrl+c", "quit", "quit", show=False, priority=True)] # beats TextArea's ctrl+c copy
|
|
44
|
+
|
|
45
|
+
def __init__(s, start=None, auto=False, native=False, resume=None):
|
|
46
|
+
super().__init__()
|
|
47
|
+
s.resume_path = resume
|
|
48
|
+
s.srv, s.start_with, s.auto, s.native = Server(), start, auto, native
|
|
49
|
+
s.msgs = [{"role": "system", "content": SYSTEM_NATIVE if native else SYSTEM}]
|
|
50
|
+
s.used, s.busy, s.state, s.ollama_proc, s.session, s.tps = 0, False, "", None, None, None
|
|
51
|
+
s.stop_ev, s.ok_ev, s.ok, s.pending = threading.Event(), threading.Event(), False, False
|
|
52
|
+
|
|
53
|
+
def compose(s) -> ComposeResult:
|
|
54
|
+
yield Static(BANNER, id="banner")
|
|
55
|
+
yield VerticalScroll(id="log")
|
|
56
|
+
yield OptionList(id="slash")
|
|
57
|
+
p = Prompt(id="prompt", placeholder="ask minu… (type / for commands)", soft_wrap=True)
|
|
58
|
+
p.border_title, p.border_subtitle = "message", "enter send · ctrl+j newline"
|
|
59
|
+
yield p
|
|
60
|
+
yield Static(id="status")
|
|
61
|
+
|
|
62
|
+
def on_mount(s):
|
|
63
|
+
s.title = "minu"; s.status(); s.query_one("#prompt").focus()
|
|
64
|
+
if s.resume_path: s.resume(s.resume_path)
|
|
65
|
+
if s.start_with: s.load(*s.start_with)
|
|
66
|
+
else: s.action_pick()
|
|
67
|
+
|
|
68
|
+
def on_click(s, e): # click anywhere on the chat -> type in the input box
|
|
69
|
+
if len(s.screen_stack) == 1: s.query_one("#prompt").focus()
|
|
70
|
+
|
|
71
|
+
def on_unmount(s):
|
|
72
|
+
s.srv.stop()
|
|
73
|
+
if s.ollama_proc: s.ollama_proc.terminate()
|
|
74
|
+
|
|
75
|
+
# ---- ui helpers (main thread)
|
|
76
|
+
def status(s):
|
|
77
|
+
ctx = f"{s.used}/{s.srv.ctx}" if s.srv.ctx else str(s.used)
|
|
78
|
+
st = f" · {s.state}" if s.state else ""
|
|
79
|
+
tps = f" · {s.tps:.1f} tok/s" if s.tps else ""
|
|
80
|
+
s.query_one("#status", Static).update(
|
|
81
|
+
f"{s.srv.name} · {ctx} tok{tps} · auto {'on' if s.auto else 'off'}{st} ^o model ^y auto esc stop ^c quit")
|
|
82
|
+
|
|
83
|
+
def add(s, w):
|
|
84
|
+
s.query_one("#banner").display = False
|
|
85
|
+
log = s.query_one("#log"); log.display = True
|
|
86
|
+
log.mount(w); s.call_after_refresh(log.scroll_end, animate=False); return w
|
|
87
|
+
|
|
88
|
+
async def render(s, w, text): # update a streaming widget; follow the bottom unless the user scrolled up
|
|
89
|
+
log = s.query_one("#log"); pinned = log.max_scroll_y - log.scroll_y <= 3
|
|
90
|
+
if (r := w.update(text)) is not None: await r
|
|
91
|
+
if pinned: s.call_after_refresh(log.scroll_end, animate=False)
|
|
92
|
+
|
|
93
|
+
def add_think(s): # live reasoning, shown in the chat and folded away once the answer starts
|
|
94
|
+
body = Static("", classes="think", markup=False)
|
|
95
|
+
box = s.add(Collapsible(body, title="thinking…", collapsed=False, classes="think-box"))
|
|
96
|
+
return box, body
|
|
97
|
+
|
|
98
|
+
def end_think(s, box, secs): box.title, box.collapsed = f"thought for {secs:.1f}s", True
|
|
99
|
+
|
|
100
|
+
def note(s, text, cls="sys"): return s.add(Static(text, classes=cls, markup=False))
|
|
101
|
+
|
|
102
|
+
def show_image(s, p):
|
|
103
|
+
try: s.add(image_widget(p))
|
|
104
|
+
except Exception as e: s.note(f"▸ can't show {p.name}: {e}")
|
|
105
|
+
|
|
106
|
+
def ui(s, fn, *a): # call from worker thread
|
|
107
|
+
return s.call_from_thread(fn, *a)
|
|
108
|
+
|
|
109
|
+
def say(s, text): s.ui(s.note, text)
|
|
110
|
+
def set_state(s, text): s.state = text; s.ui(s.status)
|
|
111
|
+
|
|
112
|
+
# ---- actions
|
|
113
|
+
def action_quit(s): # unblock worker threads first, or the app hangs on exit waiting for them
|
|
114
|
+
s.stop_ev.set(); s.ok, s.pending = False, False; s.ok_ev.set(); s.srv.abort()
|
|
115
|
+
t = threading.Timer(4, lambda: (kill_children(), os._exit(0))); t.daemon = True # last resort if a thread is still stuck
|
|
116
|
+
t.start()
|
|
117
|
+
s.exit()
|
|
118
|
+
|
|
119
|
+
def action_auto(s): s.auto = not s.auto; s.status()
|
|
120
|
+
def action_halt(s):
|
|
121
|
+
s.stop_ev.set()
|
|
122
|
+
if s.pending: s.ok = False; s.pending = False; s.ok_ev.set()
|
|
123
|
+
|
|
124
|
+
def action_clear(s):
|
|
125
|
+
if s.busy: return s.notify("busy, press esc first")
|
|
126
|
+
s.msgs = s.msgs[:1]; s.used = 0; s.session = None; s.tps = None
|
|
127
|
+
log = s.query_one("#log"); log.remove_children(); log.display = False
|
|
128
|
+
s.query_one("#banner").display = True; s.status()
|
|
129
|
+
|
|
130
|
+
# ---- worker-thread helpers
|
|
131
|
+
def ask(s, q):
|
|
132
|
+
s.say(q)
|
|
133
|
+
def go():
|
|
134
|
+
s.pending = True; inp = s.query_one("#prompt"); inp.placeholder = "[Y/n] enter = yes"; inp.focus()
|
|
135
|
+
s.ok_ev.clear(); s.ui(go); s.ok_ev.wait()
|
|
136
|
+
return s.ok
|
|
137
|
+
|
|
138
|
+
def proc(s, cmd, label): # run a long command, live last line in the status bar
|
|
139
|
+
p = subprocess.Popen(cmd, shell=isinstance(cmd, str), stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
140
|
+
stdin=subprocess.DEVNULL, env={**sh_env(), "PYTHONUNBUFFERED": "1"})
|
|
141
|
+
tail, buf, last = [], b"", 0.0
|
|
142
|
+
while chunk := p.stdout.read1(4096):
|
|
143
|
+
buf += chunk
|
|
144
|
+
*lines, buf = re.split(rb"[\r\n]", buf)
|
|
145
|
+
tail = (tail + [x.decode(errors="replace").strip() for x in lines if x.strip()])[-20:]
|
|
146
|
+
if time.time() - last > 0.2:
|
|
147
|
+
cur = buf.decode(errors="replace").strip() or (tail[-1] if tail else "")
|
|
148
|
+
s.set_state(f"{label}: {cur[-70:]}"); last = time.time()
|
|
149
|
+
if s.stop_ev.is_set(): p.terminate()
|
|
150
|
+
if buf.strip(): tail.append(buf.decode(errors="replace").strip())
|
|
151
|
+
s.set_state("")
|
|
152
|
+
return p.wait(), tail
|
|
153
|
+
|
|
154
|
+
# ---- the / menu
|
|
155
|
+
def on_text_area_changed(s, e):
|
|
156
|
+
if e.text_area.id != "prompt": return
|
|
157
|
+
t, menu = e.text_area.text, s.query_one("#slash", OptionList)
|
|
158
|
+
s.menu_items = [c for c in COMMANDS if c[0].startswith(t)] if t.startswith("/") and not re.search(r"\s", t) else []
|
|
159
|
+
if s.menu_items:
|
|
160
|
+
menu.set_options([f"{c:<9}{a:<10}{d}" for c, a, d in s.menu_items]); menu.highlighted = 0
|
|
161
|
+
menu.display = bool(s.menu_items)
|
|
162
|
+
|
|
163
|
+
def slash_move(s, d):
|
|
164
|
+
menu = s.query_one("#slash", OptionList)
|
|
165
|
+
menu.highlighted = ((menu.highlighted or 0) + d) % len(s.menu_items)
|
|
166
|
+
|
|
167
|
+
def slash_choose(s, complete_only=False, index=None):
|
|
168
|
+
menu, inp = s.query_one("#slash", OptionList), s.query_one("#prompt")
|
|
169
|
+
cmd, arg, _ = s.menu_items[menu.highlighted if index is None else index]
|
|
170
|
+
menu.display = False
|
|
171
|
+
if arg or complete_only: # needs an argument: fill it in and keep typing
|
|
172
|
+
inp.text = cmd + " "; inp.move_cursor(inp.document.end); inp.focus()
|
|
173
|
+
else:
|
|
174
|
+
inp.clear(); s.on_prompt_submitted(Prompt.Submitted(cmd))
|
|
175
|
+
|
|
176
|
+
def on_option_list_option_selected(s, e):
|
|
177
|
+
if e.option_list.id == "slash": s.slash_choose(index=e.option_index)
|
|
178
|
+
|
|
179
|
+
def on_prompt_submitted(s, e):
|
|
180
|
+
text = e.value.strip(); inp = s.query_one("#prompt")
|
|
181
|
+
if s.pending:
|
|
182
|
+
s.ok = text.lower() in ("", "y", "yes"); s.pending = False
|
|
183
|
+
inp.placeholder = "ask minu…"; s.ok_ev.set(); return
|
|
184
|
+
if not text: return
|
|
185
|
+
if text == "/model": return s.action_pick()
|
|
186
|
+
if text == "/delete": return s.action_delete()
|
|
187
|
+
if text in ("/new", "/clear"): return s.action_clear()
|
|
188
|
+
if text == "/auto": return s.action_auto()
|
|
189
|
+
if text == "/resume": return s.pick_session()
|
|
190
|
+
if text in ("/exit", "/quit", "/q"): return s.action_quit()
|
|
191
|
+
if text.startswith("/view"):
|
|
192
|
+
imgs = find_images(text[5:])
|
|
193
|
+
return [s.show_image(p) for p in imgs] if imgs else s.notify("no image file at that path")
|
|
194
|
+
if s.busy: return s.notify("busy, esc to stop")
|
|
195
|
+
if m := re.match(r"/(search|fetch)\s+(.+)", text, re.S):
|
|
196
|
+
s.add(Static(f"> {text}", classes="user", markup=False)); return s.turn((m[1], m[2].strip()))
|
|
197
|
+
if re.fullmatch(r"/[a-z]+", text.split()[0]) and not Path(text.split()[0]).exists(): # not a pasted path
|
|
198
|
+
return s.notify(f"unknown command {text.split()[0]} (type / to see them)")
|
|
199
|
+
if not s.srv.url: return s.notify("no model loaded (ctrl+o)")
|
|
200
|
+
imgs = find_images(text)
|
|
201
|
+
s.add(Static(f"> {text}", classes="user", markup=False))
|
|
202
|
+
for p in imgs: s.show_image(p)
|
|
203
|
+
content = text
|
|
204
|
+
if imgs:
|
|
205
|
+
try: content = [{"type": "text", "text": text}] + [{"type": "image_url", "image_url": {"url": encode_image(p)}} for p in imgs]
|
|
206
|
+
except Exception as err: s.note(f"▸ couldn't read image: {err}")
|
|
207
|
+
s.push({"role": "user", "content": content}); s.turn()
|
minu/cli.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Command line: `minu [--hf|--gguf|--mlx|--ollama|--url ...]`, plus `minu search|fetch` for the agent."""
|
|
2
|
+
import argparse, os, signal, sys
|
|
3
|
+
|
|
4
|
+
from . import __version__, images
|
|
5
|
+
from .sessions import session_files
|
|
6
|
+
from .tools import fetch, search, shims
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main():
|
|
10
|
+
if len(sys.argv) > 2 and sys.argv[1] in ("search", "fetch"):
|
|
11
|
+
try: print((search if sys.argv[1] == "search" else fetch)(" ".join(sys.argv[2:])))
|
|
12
|
+
except Exception as e: print(f"error: {e}"); sys.exit(1)
|
|
13
|
+
return
|
|
14
|
+
ap = argparse.ArgumentParser(prog="minu", description="tiny agent harness for local models")
|
|
15
|
+
ap.add_argument("-V", "--version", action="version", version=f"minu {__version__}")
|
|
16
|
+
g = ap.add_mutually_exclusive_group()
|
|
17
|
+
g.add_argument("--hf", help="HF repo; GGUF -> llama.cpp, mlx -> MLX")
|
|
18
|
+
g.add_argument("--gguf", help="HF GGUF repo or .gguf path (llama.cpp)")
|
|
19
|
+
g.add_argument("--mlx", help="HF MLX repo (mlx_lm)")
|
|
20
|
+
g.add_argument("--ollama", help="Ollama model name")
|
|
21
|
+
g.add_argument("--url", help="existing OpenAI-compatible server")
|
|
22
|
+
ap.add_argument("-y", "--auto", action="store_true", help="run commands without asking")
|
|
23
|
+
ap.add_argument("--native", action="store_true", help="use native tool calls instead of ```sh blocks")
|
|
24
|
+
ap.add_argument("-c", "--continue", dest="cont", action="store_true", help="continue the latest session for this folder")
|
|
25
|
+
ap.add_argument("-r", "--resume", metavar="FILE", help="resume a session .jsonl")
|
|
26
|
+
a = ap.parse_args()
|
|
27
|
+
start = None
|
|
28
|
+
if a.hf:
|
|
29
|
+
low = a.hf.lower()
|
|
30
|
+
if "gguf" in low: start = ("gguf", a.hf)
|
|
31
|
+
elif "mlx" in low: start = ("mlx", a.hf)
|
|
32
|
+
else: ap.error("can't tell if repo is GGUF or MLX; use --gguf or --mlx")
|
|
33
|
+
for k in ("gguf", "mlx", "ollama", "url"):
|
|
34
|
+
if getattr(a, k): start = (k, getattr(a, k))
|
|
35
|
+
shims()
|
|
36
|
+
import logging; logging.getLogger("textual_image").setLevel(logging.CRITICAL)
|
|
37
|
+
try: import textual_image.widget # noqa: F401 (must detect terminal graphics before the app starts)
|
|
38
|
+
except Exception: images.IMAGES = False # odd terminal: show image names instead of pixels
|
|
39
|
+
resume = a.resume
|
|
40
|
+
if a.cont:
|
|
41
|
+
latest = session_files()
|
|
42
|
+
resume = latest[0] if latest else None
|
|
43
|
+
if not resume: print("no saved sessions for this folder yet")
|
|
44
|
+
from .app import Minu # imported late so `minu search|fetch` stay fast
|
|
45
|
+
from .server import kill_children
|
|
46
|
+
app = Minu(start, a.auto, a.native, resume)
|
|
47
|
+
signal.signal(signal.SIGTERM, lambda *_: app.action_quit())
|
|
48
|
+
signal.signal(signal.SIGHUP, lambda *_: (kill_children(), os._exit(1))) # terminal closed
|
|
49
|
+
app.run()
|
minu/config.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Paths, limits, prompts and the banner."""
|
|
2
|
+
import os, platform, re
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
HOME = Path.home()
|
|
6
|
+
HF = Path(os.environ.get("HF_HUB_CACHE") or Path(os.environ.get("HF_HOME", HOME / ".cache/huggingface")) / "hub")
|
|
7
|
+
OLLAMA = os.environ.get("OLLAMA_HOST", "http://localhost:11434")
|
|
8
|
+
OLLAMA = OLLAMA if OLLAMA.startswith("http") else "http://" + OLLAMA
|
|
9
|
+
CTX = int(os.environ.get("MINU_CTX", 16384))
|
|
10
|
+
DIR = HOME / ".cache/minu"
|
|
11
|
+
MINU_HOME = Path(os.environ.get("MINU_HOME") or HOME / ".minu").expanduser() # sessions live here
|
|
12
|
+
OUT_MAX, STEPS = 4000, 25
|
|
13
|
+
MLX_OK = platform.system() == "Darwin" and platform.machine() == "arm64"
|
|
14
|
+
IMG_EXT = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp")
|
|
15
|
+
IMG_RE = re.compile(r"[\w~./-]+\.(?:png|jpe?g|gif|webp|bmp)\b", re.I)
|
|
16
|
+
UA = {"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"}
|
|
17
|
+
OS = {"Darwin": "macOS"}.get(platform.system(), platform.system())
|
|
18
|
+
ENV = f"on {OS}, cwd {os.getcwd()}"
|
|
19
|
+
SYSTEM = (f"You are minu, a terse coding agent {ENV}. You can run shell commands and use the web. "
|
|
20
|
+
"To run one, reply with only a ```sh block, then stop; you get its output. Example:\n"
|
|
21
|
+
"```sh\nminu-search \"turing machine\"\n```\n"
|
|
22
|
+
"minu-fetch URL reads a page. Edit files with heredoc or python3. Use ```text for examples.")
|
|
23
|
+
SYSTEM_NATIVE = (f"You are minu, a terse coding agent {ENV}. Use the sh tool to run commands and use the web: "
|
|
24
|
+
"minu-search \"query\", minu-fetch URL. Edit files with heredoc or python3.")
|
|
25
|
+
COMMANDS = [ # shown in the / menu: (command, argument, description)
|
|
26
|
+
("/new", "", "start a new session"),
|
|
27
|
+
("/model", "", "pick or download a model"),
|
|
28
|
+
("/delete", "", "delete a downloaded model to free disk space"),
|
|
29
|
+
("/resume", "", "open a saved session"),
|
|
30
|
+
("/search", "<query>", "search the web, the model answers from the results"),
|
|
31
|
+
("/fetch", "<url>", "read a web page, the model summarizes it"),
|
|
32
|
+
("/view", "<image>", "show an image"),
|
|
33
|
+
("/auto", "", "toggle running commands without asking"),
|
|
34
|
+
("/clear", "", "same as /new"),
|
|
35
|
+
("/exit", "", "quit minu"),
|
|
36
|
+
]
|
|
37
|
+
TOOLS = [{"type": "function", "function": {"name": "sh", "description": "Run a shell command",
|
|
38
|
+
"parameters": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}}}]
|
|
39
|
+
BLOCK = re.compile(r"```(?:sh|bash|shell|zsh)[ \t]*\n(.*?)```|<tool_call>(.*?)</tool_call>", re.S)
|
|
40
|
+
BANNER = """\
|
|
41
|
+
███╗ ███╗ ██╗ ███╗ ██╗ ██╗ ██╗
|
|
42
|
+
████╗ ████║ ██║ ████╗ ██║ ██║ ██║
|
|
43
|
+
██╔████╔██║ ██║ ██╔██╗ ██║ ██║ ██║
|
|
44
|
+
██║╚██╔╝██║ ██║ ██║╚██╗██║ ██║ ██║
|
|
45
|
+
██║ ╚═╝ ██║ ██║ ██║ ╚████║ ╚██████╔╝
|
|
46
|
+
╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═══╝ ╚═════╝
|
|
47
|
+
|
|
48
|
+
[dim]tiny harness for local models
|
|
49
|
+
ctrl+o pick or download a model · drop an image path to attach it[/]"""
|
minu/images.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Attaching images to messages and showing them in the terminal."""
|
|
2
|
+
import base64, io, shlex
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from textual.widgets import Static
|
|
6
|
+
|
|
7
|
+
from .config import IMG_EXT, IMG_RE
|
|
8
|
+
|
|
9
|
+
def find_images(text): # image paths in a message (drag-and-drop pastes a path)
|
|
10
|
+
try: toks = shlex.split(text)
|
|
11
|
+
except ValueError: toks = text.split()
|
|
12
|
+
out = []
|
|
13
|
+
for t in toks:
|
|
14
|
+
p = Path(t.strip(",;:")).expanduser()
|
|
15
|
+
if p.suffix.lower() in IMG_EXT and p.is_file() and p.resolve() not in out: out.append(p.resolve())
|
|
16
|
+
return out
|
|
17
|
+
|
|
18
|
+
def mentioned_images(text):
|
|
19
|
+
out = []
|
|
20
|
+
for m in IMG_RE.findall(text):
|
|
21
|
+
p = Path(m).expanduser()
|
|
22
|
+
if p.is_file() and p.resolve() not in out: out.append(p.resolve())
|
|
23
|
+
return out
|
|
24
|
+
|
|
25
|
+
def encode_image(p):
|
|
26
|
+
from PIL import Image
|
|
27
|
+
im = Image.open(p); im.thumbnail((1024, 1024)); im = im.convert("RGB")
|
|
28
|
+
b = io.BytesIO(); im.save(b, "JPEG", quality=85)
|
|
29
|
+
return "data:image/jpeg;base64," + base64.b64encode(b.getvalue()).decode()
|
|
30
|
+
|
|
31
|
+
IMAGES = True
|
|
32
|
+
def image_widget(p):
|
|
33
|
+
if not IMAGES: return Static(f"[image: {p}]", classes="sys", markup=False)
|
|
34
|
+
from textual_image.widget import Image
|
|
35
|
+
return Image(str(p), classes="img")
|
minu/installs.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Where tools live, and how to install them when they are missing."""
|
|
2
|
+
import platform, shutil, sys
|
|
3
|
+
|
|
4
|
+
from .config import HOME, MLX_OK
|
|
5
|
+
|
|
6
|
+
def which(tool):
|
|
7
|
+
return shutil.which(tool) or shutil.which(tool, path=f"{HOME}/.local/bin:/opt/homebrew/bin:/usr/local/bin")
|
|
8
|
+
|
|
9
|
+
def install_cmd(tool): # -> (command or None, help)
|
|
10
|
+
brew, uv = which("brew"), which("uv")
|
|
11
|
+
pip = lambda pkg: f"uv tool install {pkg}" if uv else f"{sys.executable} -m pip install -U {pkg}"
|
|
12
|
+
if tool == "llama-server":
|
|
13
|
+
return ("brew install llama.cpp" if brew else None), "https://github.com/ggml-org/llama.cpp/blob/master/docs/install.md"
|
|
14
|
+
if tool == "ollama":
|
|
15
|
+
if platform.system() == "Linux": return "curl -fsSL https://ollama.com/install.sh | sh", "https://ollama.com/download"
|
|
16
|
+
return ("brew install ollama" if brew else None), "https://ollama.com/download"
|
|
17
|
+
if tool == "hf":
|
|
18
|
+
return pip("huggingface_hub"), "https://huggingface.co/docs/huggingface_hub/installation"
|
|
19
|
+
if tool == "mlx_lm.server":
|
|
20
|
+
return (pip("mlx-lm") if MLX_OK else None), "MLX needs an Apple Silicon Mac"
|
|
21
|
+
return None, ""
|
minu/model_flows.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Picking, downloading, installing and loading models (mixed into the app)."""
|
|
2
|
+
import asyncio, importlib.util, json, re, subprocess, time, urllib.request
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from textual import work
|
|
6
|
+
|
|
7
|
+
from .config import MLX_OK, OLLAMA
|
|
8
|
+
from .installs import install_cmd, which
|
|
9
|
+
from .server import spawn
|
|
10
|
+
from .models import delete_model, deletable, discover, gb, hf_files, hf_search, ollama_up
|
|
11
|
+
from .widgets import Ask, Picker
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ModelFlowsMixin:
|
|
15
|
+
@work(exclusive=True, group="pick")
|
|
16
|
+
async def action_pick(s):
|
|
17
|
+
if which("ollama") and not await asyncio.to_thread(ollama_up):
|
|
18
|
+
await asyncio.to_thread(s.start_ollama)
|
|
19
|
+
items = [("⬇ download from Hugging Face…", ("dl-hf", None)),
|
|
20
|
+
("⬇ pull from Ollama…", ("dl-ollama", None)),
|
|
21
|
+
("✕ delete a model…", ("delete", None))] + await asyncio.to_thread(discover)
|
|
22
|
+
choice = await s.push_screen_wait(Picker("models", items))
|
|
23
|
+
if not choice: return
|
|
24
|
+
kind, ref = choice
|
|
25
|
+
if kind == "dl-ollama":
|
|
26
|
+
name = await s.push_screen_wait(Ask("Ollama model to pull (see ollama.com/library)", "e.g. qwen3:4b, gemma3:4b (vision)"))
|
|
27
|
+
if name: s.pull_ollama(name)
|
|
28
|
+
elif kind == "dl-hf":
|
|
29
|
+
await s.hf_flow()
|
|
30
|
+
elif kind == "delete":
|
|
31
|
+
await s.delete_flow()
|
|
32
|
+
else:
|
|
33
|
+
s.load(kind, ref)
|
|
34
|
+
|
|
35
|
+
@work(exclusive=True, group="pick")
|
|
36
|
+
async def action_delete(s): await s.delete_flow()
|
|
37
|
+
|
|
38
|
+
async def delete_flow(s):
|
|
39
|
+
items = await asyncio.to_thread(deletable)
|
|
40
|
+
if not items: return s.notify("no downloaded models to delete")
|
|
41
|
+
total = sum(i[1][3] for i in items)
|
|
42
|
+
choice = await s.push_screen_wait(Picker(f"delete a model ({gb(total)} on disk)", items))
|
|
43
|
+
if not choice: return
|
|
44
|
+
kind, ref, name, size = choice
|
|
45
|
+
loaded = s.srv.url and s.srv.kind == kind and s.srv.ref == ref
|
|
46
|
+
if loaded and s.busy: return s.notify("that model is busy, press esc first")
|
|
47
|
+
warn = " (it's loaded; it will be stopped)" if loaded else ""
|
|
48
|
+
if not await s.push_screen_wait(Picker(f"delete {name}?{warn}", [("no, keep it", False), (f"yes, delete it and free {gb(size)}", True)])):
|
|
49
|
+
return
|
|
50
|
+
try:
|
|
51
|
+
if loaded:
|
|
52
|
+
await asyncio.to_thread(s.srv.stop); s.srv.name = "no model"; s.tps = None
|
|
53
|
+
gone = await asyncio.to_thread(delete_model, kind, ref)
|
|
54
|
+
s.note(f"▸ deleted {gone}, freed {gb(size)}")
|
|
55
|
+
except Exception as e:
|
|
56
|
+
s.note(f"▸ couldn't delete {name}: {e}")
|
|
57
|
+
s.status()
|
|
58
|
+
|
|
59
|
+
async def hf_flow(s):
|
|
60
|
+
q = await s.push_screen_wait(Ask("Search Hugging Face, or paste org/repo", "e.g. qwen3 gguf, gemma 3 mlx, ggml-org/SmolVLM-256M-Instruct-GGUF"))
|
|
61
|
+
if not q: return
|
|
62
|
+
try:
|
|
63
|
+
if "/" in q: repo = q.removeprefix("https://huggingface.co/").strip("/")
|
|
64
|
+
else:
|
|
65
|
+
s.state = "searching…"; s.status()
|
|
66
|
+
res = await asyncio.to_thread(hf_search, q); s.state = ""; s.status()
|
|
67
|
+
if not res: return s.notify(f"no GGUF/MLX models for '{q}'")
|
|
68
|
+
repo = await s.push_screen_wait(Picker(f"results for '{q}'", res))
|
|
69
|
+
if not repo: return
|
|
70
|
+
kind, gg, mm, files = await asyncio.to_thread(hf_files, repo)
|
|
71
|
+
except Exception as e:
|
|
72
|
+
s.state = ""; s.status(); return s.note(f"▸ Hugging Face error: {e}")
|
|
73
|
+
if kind is None: return s.note(f"▸ {repo} has no GGUF or MLX weights; minu can't run it")
|
|
74
|
+
if kind == "mlx":
|
|
75
|
+
if not MLX_OK: return s.note("▸ MLX models need an Apple Silicon Mac")
|
|
76
|
+
return s.download_hf(repo, [], kind)
|
|
77
|
+
pref = lambda f: (not re.search(r"q4_k_m", f, re.I), gg[f])
|
|
78
|
+
opts = [(f"{f} {gg[f] / 1e9:.1f} GB", f) for f in sorted(gg, key=pref)]
|
|
79
|
+
f = opts[0][1] if len(opts) == 1 else await s.push_screen_wait(Picker(f"{repo}: pick a size (Q4_K_M is a good default)", opts))
|
|
80
|
+
if not f: return
|
|
81
|
+
part = re.split(r"-00001-of-", f)[0]
|
|
82
|
+
want = [g for g in files if g.startswith(part) and g.endswith(".gguf")] if "-00001-of-" in f else [f]
|
|
83
|
+
if mm: # vision projector: prefer Q8/f16 over f32
|
|
84
|
+
want.append(sorted(mm, key=lambda m: ("f32" in m.lower(), mm[m]))[0])
|
|
85
|
+
s.download_hf(repo, want, kind)
|
|
86
|
+
|
|
87
|
+
def need(s, tool): # path to tool, offering to install it if missing
|
|
88
|
+
if p := which(tool): return p
|
|
89
|
+
cmd, hint = install_cmd(tool)
|
|
90
|
+
if not cmd:
|
|
91
|
+
s.say(f"▸ {tool} isn't installed and minu can't install it here. See {hint}"); return None
|
|
92
|
+
if not s.ask(f"{tool} isn't installed. Install it with `{cmd}`?"):
|
|
93
|
+
s.say(f"▸ skipped. To install later: {cmd}"); return None
|
|
94
|
+
rc, tail = s.proc(cmd, f"installing {tool}")
|
|
95
|
+
if rc != 0 or not which(tool):
|
|
96
|
+
s.say("▸ install failed:\n" + "\n".join(tail[-5:]) + f"\nSee {hint}"); return None
|
|
97
|
+
s.say(f"▸ installed {tool}"); return which(tool)
|
|
98
|
+
|
|
99
|
+
def start_ollama(s, ask=False):
|
|
100
|
+
if ollama_up(): return True
|
|
101
|
+
exe = s.need("ollama") if ask else which("ollama")
|
|
102
|
+
if not exe: return False
|
|
103
|
+
s.ollama_proc = spawn([exe, "serve"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
104
|
+
for _ in range(60):
|
|
105
|
+
if ollama_up(): return True
|
|
106
|
+
time.sleep(0.25)
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
def _load(s, kind, ref):
|
|
110
|
+
s.set_state(f"loading {Path(ref).name}…")
|
|
111
|
+
try:
|
|
112
|
+
if kind == "gguf" and not s.need("llama-server"): raise RuntimeError("llama-server is required for GGUF models")
|
|
113
|
+
if kind == "mlx" and not importlib.util.find_spec("mlx_lm") and not s.need("mlx_lm.server"):
|
|
114
|
+
raise RuntimeError("mlx-lm is required for MLX models")
|
|
115
|
+
if kind == "ollama" and not s.start_ollama(ask=True): raise RuntimeError("Ollama isn't running")
|
|
116
|
+
s.set_state(f"loading {Path(ref).name}…")
|
|
117
|
+
s.srv.start(kind, ref); s.say(f"▸ model: {s.srv.name} ({kind})")
|
|
118
|
+
except Exception as e:
|
|
119
|
+
s.srv.name = "no model"; s.say(f"▸ failed: {e}")
|
|
120
|
+
s.set_state("")
|
|
121
|
+
|
|
122
|
+
@work(thread=True, exclusive=True, group="load")
|
|
123
|
+
def load(s, kind, ref): s._load(kind, ref)
|
|
124
|
+
|
|
125
|
+
@work(thread=True, exclusive=True, group="load")
|
|
126
|
+
def download_hf(s, repo, files, kind):
|
|
127
|
+
s.stop_ev.clear()
|
|
128
|
+
if not (exe := s.need("hf")): return
|
|
129
|
+
s.say(f"▸ downloading {repo} {' '.join(files)} (esc to cancel)")
|
|
130
|
+
rc, tail = s.proc([exe, "download", repo, *files], f"↓ {repo.split('/')[-1]}")
|
|
131
|
+
if rc != 0:
|
|
132
|
+
why = "\n".join(tail[-4:])
|
|
133
|
+
if re.search(r"401|403|gated|restricted", why, re.I): why += "\nThis repo needs a login: run `hf auth login`, then accept its license on huggingface.co"
|
|
134
|
+
return s.say(f"▸ download failed:\n{why}")
|
|
135
|
+
if kind == "mlx": return s._load("mlx", repo)
|
|
136
|
+
for _, (k, ref) in discover():
|
|
137
|
+
if k == "gguf" and ref.endswith(Path(files[0]).name): return s._load("gguf", ref)
|
|
138
|
+
s.say("▸ downloaded, but couldn't find the file in the HF cache (ctrl+o to pick it)")
|
|
139
|
+
|
|
140
|
+
@work(thread=True, exclusive=True, group="load")
|
|
141
|
+
def pull_ollama(s, name):
|
|
142
|
+
s.stop_ev.clear()
|
|
143
|
+
if not s.start_ollama(ask=True): return s.say("▸ Ollama isn't running")
|
|
144
|
+
s.say(f"▸ pulling {name} from Ollama (esc to cancel)")
|
|
145
|
+
req = urllib.request.Request(f"{OLLAMA}/api/pull", json.dumps({"model": name}).encode(), {"Content-Type": "application/json"})
|
|
146
|
+
try:
|
|
147
|
+
with urllib.request.urlopen(req, timeout=3600) as r:
|
|
148
|
+
for line in r:
|
|
149
|
+
if s.stop_ev.is_set(): s.set_state(""); return s.say("▸ pull cancelled")
|
|
150
|
+
j = json.loads(line)
|
|
151
|
+
if "error" in j: raise RuntimeError(j["error"])
|
|
152
|
+
pct = f" {100 * j['completed'] // j['total']}%" if j.get("total") and j.get("completed") else ""
|
|
153
|
+
s.set_state(f"↓ {name}: {j.get('status', '')}{pct}")
|
|
154
|
+
except Exception as e:
|
|
155
|
+
s.set_state(""); return s.say(f"▸ pull failed: {e}")
|
|
156
|
+
s._load("ollama", name)
|
minu/models.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Finding local models (HF cache, Ollama) and searching Hugging Face."""
|
|
2
|
+
import json, re, shutil, urllib.parse, urllib.request
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .config import HF, MLX_OK, OLLAMA
|
|
6
|
+
from .tools import get
|
|
7
|
+
|
|
8
|
+
def ollama_up():
|
|
9
|
+
try: get(f"{OLLAMA}/api/tags", timeout=1); return True
|
|
10
|
+
except Exception: return False
|
|
11
|
+
|
|
12
|
+
def discover():
|
|
13
|
+
ms = [] # (label, (kind, ref))
|
|
14
|
+
for d in sorted(HF.glob("models--*")) if HF.is_dir() else []:
|
|
15
|
+
repo = d.name[8:].replace("--", "/")
|
|
16
|
+
ggufs = {p.name: p for p in d.glob("snapshots/*/**/*.gguf")
|
|
17
|
+
if "mmproj" not in p.name.lower() and not re.search(r"-0000[2-9]-of-", p.name)}
|
|
18
|
+
for n, p in sorted(ggufs.items()):
|
|
19
|
+
eye = " [vision]" if any(p.parent.glob("*mmproj*.gguf")) else ""
|
|
20
|
+
ms.append((f"gguf {repo} · {n}{eye}", ("gguf", str(p))))
|
|
21
|
+
cfg = next(d.glob("snapshots/*/config.json"), None)
|
|
22
|
+
if not ggufs and cfg and ("mlx" in repo.lower() or "quantization" in cfg.read_text()):
|
|
23
|
+
ms.append((f"mlx {repo}", ("mlx", repo)))
|
|
24
|
+
try:
|
|
25
|
+
for m in json.loads(get(f"{OLLAMA}/api/tags", timeout=1))["models"]:
|
|
26
|
+
ms.append((f"ollama {m['name']}", ("ollama", m["name"])))
|
|
27
|
+
except Exception:
|
|
28
|
+
pass
|
|
29
|
+
return ms
|
|
30
|
+
|
|
31
|
+
def gb(n): return f"{n / 1e9:.1f} GB" if n >= 1e8 else f"{n / 1e6:.0f} MB"
|
|
32
|
+
|
|
33
|
+
def _repo_dir(p): # .../hub/models--org--name/snapshots/<rev>/x.gguf -> .../hub/models--org--name
|
|
34
|
+
return next(d for d in Path(p).parents if d.name.startswith("models--"))
|
|
35
|
+
|
|
36
|
+
def _shards(p): # a .gguf plus its -0000N-of-M siblings
|
|
37
|
+
p = Path(p)
|
|
38
|
+
if not (m := re.match(r"(.*)-00001-of-(\d+)\.gguf$", p.name)): return [p]
|
|
39
|
+
return sorted(p.parent.glob(f"{m[1]}-*-of-{m[2]}.gguf"))
|
|
40
|
+
|
|
41
|
+
def _du(d): return sum(f.stat().st_size for f in Path(d).rglob("*") if f.is_file() and not f.is_symlink())
|
|
42
|
+
|
|
43
|
+
def deletable(): # (label, (kind, ref, name, bytes)) for everything minu can delete
|
|
44
|
+
out = []
|
|
45
|
+
for label, (kind, ref) in discover():
|
|
46
|
+
if kind == "gguf":
|
|
47
|
+
n = sum(f.resolve().stat().st_size for f in _shards(ref) if f.exists())
|
|
48
|
+
name = f"{_repo_dir(ref).name[8:].replace('--', '/')} · {Path(ref).name}"
|
|
49
|
+
elif kind == "mlx":
|
|
50
|
+
n, name = _du(HF / ("models--" + ref.replace("/", "--"))), ref
|
|
51
|
+
else: continue
|
|
52
|
+
out.append((f"{gb(n):>8} {label}", (kind, ref, name, n)))
|
|
53
|
+
try:
|
|
54
|
+
for m in json.loads(get(f"{OLLAMA}/api/tags", timeout=1))["models"]:
|
|
55
|
+
out.append((f"{gb(m.get('size', 0)):>8} ollama {m['name']}", ("ollama", m["name"], m["name"], m.get("size", 0))))
|
|
56
|
+
except Exception: pass
|
|
57
|
+
return out
|
|
58
|
+
|
|
59
|
+
def delete_model(kind, ref): # -> what was removed
|
|
60
|
+
if kind == "ollama":
|
|
61
|
+
req = urllib.request.Request(f"{OLLAMA}/api/delete", json.dumps({"model": ref}).encode(),
|
|
62
|
+
{"Content-Type": "application/json"}, method="DELETE")
|
|
63
|
+
urllib.request.urlopen(req, timeout=30).close(); return f"ollama model {ref}"
|
|
64
|
+
if kind == "mlx":
|
|
65
|
+
repo = HF / ("models--" + ref.replace("/", "--"))
|
|
66
|
+
else: # one quant of a GGUF repo: drop it, and the whole repo once no model weights are left
|
|
67
|
+
repo = _repo_dir(ref)
|
|
68
|
+
rels = [f.relative_to(next(d for d in f.parents if d.parent.name == "snapshots")) for f in _shards(ref)]
|
|
69
|
+
links = [g for rel in rels for g in repo.glob(f"snapshots/*/{rel}")] # same file in every revision
|
|
70
|
+
blobs = {g.resolve() for g in links}
|
|
71
|
+
for g in links: g.unlink(missing_ok=True)
|
|
72
|
+
used = {g.resolve() for g in repo.glob("snapshots/*/**/*") if g.is_symlink()}
|
|
73
|
+
for b in blobs - used:
|
|
74
|
+
if b.exists() and b.parent.name == "blobs": b.unlink()
|
|
75
|
+
left = [g for g in repo.glob("snapshots/*/**/*.gguf") if "mmproj" not in g.name.lower()]
|
|
76
|
+
if left: return Path(ref).name
|
|
77
|
+
if HF not in repo.parents: raise RuntimeError(f"refusing to delete {repo}: not in the HF cache")
|
|
78
|
+
shutil.rmtree(repo); shutil.rmtree(HF / ".locks" / repo.name, ignore_errors=True)
|
|
79
|
+
return repo.name[8:].replace("--", "/")
|
|
80
|
+
|
|
81
|
+
def hf_api(path):
|
|
82
|
+
return json.loads(get("https://huggingface.co/api/" + path, timeout=15))
|
|
83
|
+
|
|
84
|
+
def hf_search(q):
|
|
85
|
+
out, kinds = [], ["gguf"] + (["mlx"] if MLX_OK else [])
|
|
86
|
+
for k in kinds:
|
|
87
|
+
for m in hf_api(f"models?search={urllib.parse.quote(q)}&filter={k}&sort=downloads&limit=15"):
|
|
88
|
+
out.append((m.get("downloads", 0), k, m["id"]))
|
|
89
|
+
out.sort(reverse=True)
|
|
90
|
+
return [(f"{k:5} {i} ↓{n:,}", i) for n, k, i in out]
|
|
91
|
+
|
|
92
|
+
def hf_files(repo):
|
|
93
|
+
info = hf_api(f"models/{repo}?blobs=true")
|
|
94
|
+
files = {s["rfilename"]: s.get("size") or 0 for s in info.get("siblings", [])}
|
|
95
|
+
gg, mm = {}, {}
|
|
96
|
+
for f, sz in files.items():
|
|
97
|
+
if not f.endswith(".gguf"): continue
|
|
98
|
+
if "mmproj" in f.lower(): mm[f] = sz; continue
|
|
99
|
+
if re.search(r"-0000[2-9]-of-", f): continue
|
|
100
|
+
part = re.split(r"-00001-of-", f)[0]
|
|
101
|
+
gg[f] = sum(z for g, z in files.items() if g.startswith(part)) if "-00001-of-" in f else sz
|
|
102
|
+
tags = info.get("tags", [])
|
|
103
|
+
kind = "gguf" if gg else ("mlx" if "mlx" in tags or "mlx" in repo.lower() else None)
|
|
104
|
+
return kind, gg, mm, files
|
minu/server.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Starting/stopping model servers and streaming chat completions."""
|
|
2
|
+
import atexit, importlib.util, json, socket, subprocess, sys, time, urllib.error, urllib.request
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .config import CTX, DIR, OLLAMA, TOOLS
|
|
6
|
+
from .installs import which
|
|
7
|
+
from .tools import get
|
|
8
|
+
|
|
9
|
+
CHILDREN = [] # every server minu starts, so they never outlive it
|
|
10
|
+
|
|
11
|
+
def spawn(cmd, **kw):
|
|
12
|
+
p = subprocess.Popen(cmd, stdin=subprocess.DEVNULL, **kw); CHILDREN.append(p); return p
|
|
13
|
+
|
|
14
|
+
@atexit.register
|
|
15
|
+
def kill_children():
|
|
16
|
+
for p in CHILDREN:
|
|
17
|
+
if p.poll() is None: p.terminate()
|
|
18
|
+
for p in CHILDREN:
|
|
19
|
+
try: p.wait(3)
|
|
20
|
+
except subprocess.TimeoutExpired: p.kill()
|
|
21
|
+
|
|
22
|
+
def free_port():
|
|
23
|
+
with socket.socket() as s:
|
|
24
|
+
s.bind(("127.0.0.1", 0)); return s.getsockname()[1]
|
|
25
|
+
|
|
26
|
+
class Server:
|
|
27
|
+
def __init__(s): s.proc, s.url, s.model, s.name, s.kind, s.ctx, s.resp, s.ref = None, None, None, "no model", None, None, None, None
|
|
28
|
+
|
|
29
|
+
def abort(s): # unblock a thread stuck reading the stream (used on quit)
|
|
30
|
+
try: s.resp.fp.raw._sock.shutdown(socket.SHUT_RDWR)
|
|
31
|
+
except Exception: pass
|
|
32
|
+
|
|
33
|
+
def stop(s):
|
|
34
|
+
if s.proc and s.proc.poll() is None:
|
|
35
|
+
s.proc.terminate()
|
|
36
|
+
try: s.proc.wait(5)
|
|
37
|
+
except subprocess.TimeoutExpired: s.proc.kill()
|
|
38
|
+
s.proc, s.url = None, None
|
|
39
|
+
|
|
40
|
+
def start(s, kind, ref):
|
|
41
|
+
s.stop(); s.ctx, s.kind, s.ref = None, kind, ref
|
|
42
|
+
if kind == "ollama":
|
|
43
|
+
s.url, s.model, s.name = f"{OLLAMA}/v1", ref, ref; return
|
|
44
|
+
if kind == "url":
|
|
45
|
+
s.url = ref.rstrip("/") + ("" if ref.rstrip("/").endswith("/v1") else "/v1")
|
|
46
|
+
s.model = json.loads(get(s.url + "/models", timeout=5))["data"][0]["id"]; s.name = s.model; return
|
|
47
|
+
port = free_port()
|
|
48
|
+
if kind == "gguf":
|
|
49
|
+
src = ["-m", ref] if Path(ref).exists() else ["-hf", ref]
|
|
50
|
+
mm = sorted(Path(ref).parent.glob("*mmproj*.gguf"), key=lambda p: ("f32" in p.name.lower(), p.stat().st_size)) if Path(ref).exists() else []
|
|
51
|
+
cmd = [which("llama-server"), *src, *(["--mmproj", str(mm[0])] if mm else []), "--jinja", "-c", str(CTX), "--port", str(port)]
|
|
52
|
+
s.model, s.name, s.ctx, health = "local", Path(ref).name.removesuffix(".gguf"), CTX, "/health"
|
|
53
|
+
else: # mlx
|
|
54
|
+
cmd = [sys.executable, "-m", "mlx_lm.server"] if importlib.util.find_spec("mlx_lm") else [which("mlx_lm.server")]
|
|
55
|
+
cmd += ["--model", ref, "--port", str(port)]
|
|
56
|
+
s.model, s.name, health = ref, ref.split("/")[-1], "/v1/models"
|
|
57
|
+
DIR.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
log = open(DIR / "server.log", "w")
|
|
59
|
+
s.proc = spawn(cmd, stdout=log, stderr=subprocess.STDOUT)
|
|
60
|
+
base = f"http://127.0.0.1:{port}"
|
|
61
|
+
for _ in range(1200):
|
|
62
|
+
if s.proc.poll() is not None:
|
|
63
|
+
tail = (DIR / "server.log").read_text(errors="replace").strip().splitlines()[-3:]
|
|
64
|
+
raise RuntimeError("server exited: " + " / ".join(tail))
|
|
65
|
+
try: get(base + health, timeout=1); break
|
|
66
|
+
except Exception: time.sleep(0.5)
|
|
67
|
+
else: raise RuntimeError("server start timed out")
|
|
68
|
+
s.url = base + "/v1"
|
|
69
|
+
|
|
70
|
+
def stream(s, msgs, native, stop):
|
|
71
|
+
body = {"model": s.model, "messages": msgs, "stream": True, "stream_options": {"include_usage": True}}
|
|
72
|
+
if native: body["tools"] = TOOLS
|
|
73
|
+
req = urllib.request.Request(s.url + "/chat/completions", json.dumps(body).encode(), {"Content-Type": "application/json"})
|
|
74
|
+
try: r = urllib.request.urlopen(req, timeout=900)
|
|
75
|
+
except urllib.error.HTTPError as e:
|
|
76
|
+
raise RuntimeError(f"{e.code}: {e.read().decode(errors='replace')[:400]}") from None
|
|
77
|
+
s.resp = r
|
|
78
|
+
with r:
|
|
79
|
+
for line in r:
|
|
80
|
+
if stop.is_set(): return
|
|
81
|
+
line = line.strip()
|
|
82
|
+
if line.startswith(b"data:"):
|
|
83
|
+
d = line[5:].strip()
|
|
84
|
+
if d == b"[DONE]": return
|
|
85
|
+
yield json.loads(d)
|
minu/sessions.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Sessions saved as ~/.minu/sessions/<project>/<timestamp>.jsonl: a meta line, then one message per line."""
|
|
2
|
+
import asyncio, json, re, threading, time
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from textual import work
|
|
6
|
+
from textual.widgets import Markdown, Static
|
|
7
|
+
|
|
8
|
+
from .config import MINU_HOME
|
|
9
|
+
from .widgets import Picker
|
|
10
|
+
|
|
11
|
+
ROOT = MINU_HOME / "sessions"
|
|
12
|
+
CWD = Path.cwd().resolve()
|
|
13
|
+
SESS = ROOT / re.sub(r"[^\w.-]", "-", str(CWD)) # one folder per project, e.g. -Users-me-code-app
|
|
14
|
+
LEGACY = CWD / ".minu" / "sessions" # where older versions saved, still listed
|
|
15
|
+
_lock = threading.Lock()
|
|
16
|
+
|
|
17
|
+
def read_jsonl(p):
|
|
18
|
+
out = []
|
|
19
|
+
for line in Path(p).read_text(errors="replace").splitlines():
|
|
20
|
+
try: out.append(json.loads(line))
|
|
21
|
+
except json.JSONDecodeError: pass # half-written line from a crash
|
|
22
|
+
return out
|
|
23
|
+
|
|
24
|
+
def session_files(): # this project's sessions, newest first
|
|
25
|
+
files = [*SESS.glob("*.jsonl"), *(LEGACY.glob("*.jsonl") if LEGACY.is_dir() else [])]
|
|
26
|
+
return sorted(files, key=lambda p: p.stat().st_mtime, reverse=True)
|
|
27
|
+
|
|
28
|
+
def text_of(c): return c if isinstance(c, str) else next((x.get("text", "") for x in c if x.get("type") == "text"), "")
|
|
29
|
+
|
|
30
|
+
def sessions(): # (label, path) for the picker
|
|
31
|
+
out = []
|
|
32
|
+
for p in session_files()[:50]:
|
|
33
|
+
msgs = [m for m in read_jsonl(p) if "role" in m]
|
|
34
|
+
first = next((text_of(m["content"]) for m in msgs if m["role"] == "user"), "").replace("\n", " ")[:60]
|
|
35
|
+
when = time.strftime("%Y-%m-%d %H:%M", time.localtime(p.stat().st_mtime))
|
|
36
|
+
out.append((f"{when} ({len(msgs)} msgs) {first}", p))
|
|
37
|
+
return out
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SessionsMixin:
|
|
41
|
+
def push(s, m):
|
|
42
|
+
s.msgs.append(m)
|
|
43
|
+
with _lock: # called from the UI and worker threads
|
|
44
|
+
if not s.session:
|
|
45
|
+
SESS.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
stamp = time.strftime("%Y-%m-%d_%H-%M-%S"); p, i = SESS / f"{stamp}.jsonl", 1
|
|
47
|
+
while p.exists(): p, i = SESS / f"{stamp}-{i}.jsonl", i + 1
|
|
48
|
+
s.session = p
|
|
49
|
+
meta = {"cwd": str(CWD), "model": s.srv.name, "created": time.strftime("%Y-%m-%dT%H:%M:%S%z")}
|
|
50
|
+
with open(p, "a") as f: f.write(json.dumps({"meta": meta}) + "\n")
|
|
51
|
+
with open(s.session, "a") as f: f.write(json.dumps(m) + "\n")
|
|
52
|
+
|
|
53
|
+
def resume(s, path):
|
|
54
|
+
s.action_clear()
|
|
55
|
+
msgs = [m for m in read_jsonl(path) if "role" in m]
|
|
56
|
+
s.msgs, s.session = s.msgs[:1] + [m for m in msgs if m["role"] != "system"], Path(path)
|
|
57
|
+
for m in s.msgs[1:]:
|
|
58
|
+
c = m.get("content") or ""
|
|
59
|
+
if m["role"] == "user" and isinstance(c, str) and c.startswith("Output:\n") or m["role"] == "tool":
|
|
60
|
+
lines = (c.removeprefix("Output:\n")).splitlines()
|
|
61
|
+
s.note("\n".join(lines[:15]) + (f"\n… ({len(lines) - 15} more lines)" if len(lines) > 15 else ""), "out")
|
|
62
|
+
elif m["role"] == "user":
|
|
63
|
+
txt = c if isinstance(c, str) else text_of(c) + f" [{len(c) - 1} image(s)]"
|
|
64
|
+
s.add(Static(f"> {txt}", classes="user", markup=False))
|
|
65
|
+
elif m["role"] == "assistant":
|
|
66
|
+
if c: s.add(Markdown(c))
|
|
67
|
+
for t in m.get("tool_calls") or []:
|
|
68
|
+
try: s.note("$ " + json.loads(t["function"]["arguments"]).get("command", ""), "cmd")
|
|
69
|
+
except Exception: pass
|
|
70
|
+
s.note(f"▸ resumed {Path(path).name} ({len(msgs)} messages)")
|
|
71
|
+
|
|
72
|
+
@work(exclusive=True, group="pick")
|
|
73
|
+
async def pick_session(s):
|
|
74
|
+
items = await asyncio.to_thread(sessions)
|
|
75
|
+
if p := await s.push_screen_wait(Picker(f"sessions for {CWD.name}", items)): s.resume(p)
|
minu/tools.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""The agent's shell tool, web search/fetch, and parsing commands out of replies."""
|
|
2
|
+
import html, json, os, re, shlex, subprocess, sys, time, urllib.parse, urllib.request
|
|
3
|
+
from html.parser import HTMLParser
|
|
4
|
+
|
|
5
|
+
from .config import DIR, HOME, OUT_MAX, UA
|
|
6
|
+
|
|
7
|
+
def get(url, data=None, timeout=20, headers=None):
|
|
8
|
+
req = urllib.request.Request(url, data=data, headers={**UA, **(headers or {})})
|
|
9
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
10
|
+
return r.read().decode("utf-8", "replace")
|
|
11
|
+
|
|
12
|
+
class _Text(HTMLParser):
|
|
13
|
+
SKIP, BR = {"script", "style", "noscript", "svg", "head"}, {"p", "br", "div", "li", "tr", "h1", "h2", "h3", "h4", "pre"}
|
|
14
|
+
def __init__(s): super().__init__(); s.out, s.skip = [], 0
|
|
15
|
+
def handle_starttag(s, t, a):
|
|
16
|
+
if t in s.SKIP: s.skip += 1
|
|
17
|
+
elif t in s.BR: s.out.append("\n")
|
|
18
|
+
def handle_endtag(s, t):
|
|
19
|
+
if t in s.SKIP and s.skip: s.skip -= 1
|
|
20
|
+
def handle_data(s, d):
|
|
21
|
+
if not s.skip: s.out.append(d)
|
|
22
|
+
|
|
23
|
+
def fetch(url):
|
|
24
|
+
p = _Text(); p.feed(get(url))
|
|
25
|
+
t = re.sub(r"[ \t]+", " ", "".join(p.out))
|
|
26
|
+
return re.sub(r"\s*\n\s*", "\n", t).strip()[:8000]
|
|
27
|
+
|
|
28
|
+
def _ddg(q):
|
|
29
|
+
for _ in range(2): # DuckDuckGo throttles bursts; retry once
|
|
30
|
+
h = get("https://html.duckduckgo.com/html/", urllib.parse.urlencode({"q": q}).encode())
|
|
31
|
+
if "result__a" in h: break
|
|
32
|
+
time.sleep(2)
|
|
33
|
+
out = []
|
|
34
|
+
for u, t, s in re.findall(r'class="result__a" href="([^"]+)"[^>]*>(.*?)</a>.*?class="result__snippet"[^>]*>(.*?)</a>', h, re.S):
|
|
35
|
+
out.append((t, urllib.parse.parse_qs(urllib.parse.urlparse(html.unescape(u)).query).get("uddg", [u])[0], s))
|
|
36
|
+
return out
|
|
37
|
+
|
|
38
|
+
def _wikipedia(q):
|
|
39
|
+
j = json.loads(get("https://en.wikipedia.org/w/api.php?" + urllib.parse.urlencode(
|
|
40
|
+
{"action": "query", "list": "search", "srsearch": q, "format": "json", "srlimit": 5})))
|
|
41
|
+
return [(r["title"], "https://en.wikipedia.org/wiki/" + urllib.parse.quote(r["title"].replace(" ", "_")), r["snippet"])
|
|
42
|
+
for r in j["query"]["search"]]
|
|
43
|
+
|
|
44
|
+
def _searxng(q): # optional: MINU_SEARXNG=https://your-instance
|
|
45
|
+
j = json.loads(get(os.environ["MINU_SEARXNG"].rstrip("/") + "/search?" + urllib.parse.urlencode({"q": q, "format": "json"})))
|
|
46
|
+
return [(r.get("title", ""), r.get("url", ""), r.get("content", "")) for r in j.get("results", [])]
|
|
47
|
+
|
|
48
|
+
def search(q): # first engine that answers wins
|
|
49
|
+
clean = lambda x: html.unescape(re.sub(r"<.*?>", "", x)).strip()
|
|
50
|
+
engines = ([_searxng] if os.environ.get("MINU_SEARXNG") else []) + [_ddg, _wikipedia]
|
|
51
|
+
for engine in engines:
|
|
52
|
+
try: res = engine(q)
|
|
53
|
+
except Exception: res = []
|
|
54
|
+
if res:
|
|
55
|
+
return f"[{engine.__name__[1:]}]\n\n" + "\n\n".join(f"{clean(t)}\n{u}\n{clean(s)}" for t, u, s in res[:8])
|
|
56
|
+
return "search failed: no engine answered (rate-limited?). Try again shortly, or minu-fetch a URL you know."
|
|
57
|
+
|
|
58
|
+
def shims():
|
|
59
|
+
(DIR / "bin").mkdir(parents=True, exist_ok=True)
|
|
60
|
+
for n in ("search", "fetch"):
|
|
61
|
+
p = DIR / "bin" / f"minu-{n}"
|
|
62
|
+
p.write_text(f'#!/bin/sh\nexec "{sys.executable}" -m minu {n} "$*"\n'); p.chmod(0o755)
|
|
63
|
+
|
|
64
|
+
def sh_env():
|
|
65
|
+
return {**os.environ, "PATH": f"{DIR / 'bin'}:{os.environ['PATH']}:{HOME}/.local/bin"}
|
|
66
|
+
|
|
67
|
+
def sh(cmd, timeout=120):
|
|
68
|
+
try:
|
|
69
|
+
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout,
|
|
70
|
+
env=sh_env(), executable="/bin/bash", stdin=subprocess.DEVNULL)
|
|
71
|
+
out = (r.stdout + r.stderr).strip() + ("" if r.returncode == 0 else f"\n[exit {r.returncode}]")
|
|
72
|
+
except subprocess.TimeoutExpired:
|
|
73
|
+
out = f"[timed out after {timeout}s]"
|
|
74
|
+
out = out.strip() or "[no output]"
|
|
75
|
+
if len(out) > OUT_MAX:
|
|
76
|
+
out = out[:OUT_MAX // 2] + f"\n...[{len(out) - OUT_MAX} chars cut]...\n" + out[-OUT_MAX // 2:]
|
|
77
|
+
return out
|
|
78
|
+
|
|
79
|
+
def to_cmd(m): # ```sh block, or a model's own <tool_call> / JSON call -> shell command
|
|
80
|
+
if m.group(1) is not None: return m.group(1).strip()
|
|
81
|
+
t = m.group(2).strip()
|
|
82
|
+
try:
|
|
83
|
+
j = json.loads(t); name, args = j.get("name", ""), j.get("arguments") or {}
|
|
84
|
+
except json.JSONDecodeError:
|
|
85
|
+
name = (re.search(r"<function=([^>\s]+)>", t) or re.search(r"(\S+)", t)).group(1)
|
|
86
|
+
args = dict(re.findall(r"<parameter=([^>]+)>\s*(.*?)\s*</parameter>", t, re.S))
|
|
87
|
+
if isinstance(args, str): args = {"command": args}
|
|
88
|
+
if name in ("sh", "bash", "shell", "run", "exec", "terminal") or "command" in args:
|
|
89
|
+
return str(args.get("command") or next(iter(args.values()), ""))
|
|
90
|
+
return " ".join([name, *(shlex.quote(str(v)) for v in args.values())])
|
|
91
|
+
|
|
92
|
+
def split_think(t): # reply -> (thinking, answer); handles an unclosed <think> while streaming
|
|
93
|
+
head = t.split("</think>")[0]
|
|
94
|
+
if "</think>" in t and "<think>" not in head: t = "<think>" + t # chat template already opened <think>
|
|
95
|
+
think = "".join(re.findall(r"<think>(.*?)</think>", t, re.S))
|
|
96
|
+
rest = re.sub(r"<think>.*?</think>", "", t, flags=re.S)
|
|
97
|
+
if "<think>" in rest: rest, tail = rest.split("<think>", 1); think += tail
|
|
98
|
+
return think.strip(), rest.strip()
|
|
99
|
+
|
|
100
|
+
def strip_think(t): return split_think(t)[1]
|
minu/widgets.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Input box, list picker and text prompt."""
|
|
2
|
+
from textual.binding import Binding
|
|
3
|
+
from textual.containers import Vertical
|
|
4
|
+
from textual.message import Message
|
|
5
|
+
from textual.screen import ModalScreen
|
|
6
|
+
from textual.widgets import Input, OptionList, Static, TextArea
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Prompt(TextArea): # multi-line input: enter sends, ctrl+j newline; drives the / menu
|
|
10
|
+
BINDINGS = [Binding("enter", "send", "send", priority=True), Binding("ctrl+j", "newline", "newline", priority=True),
|
|
11
|
+
Binding("up", "menu(-1)", show=False, priority=True), Binding("down", "menu(1)", show=False, priority=True),
|
|
12
|
+
Binding("tab", "complete", show=False, priority=True), Binding("escape", "close_menu", show=False, priority=True)]
|
|
13
|
+
class Submitted(Message):
|
|
14
|
+
def __init__(s, value): super().__init__(); s.value = value
|
|
15
|
+
def menu_open(s): return s.app.query_one("#slash").display
|
|
16
|
+
def check_action(s, action, params): # menu keys only act while the menu is open
|
|
17
|
+
return s.menu_open() if action in ("menu", "complete", "close_menu") else True
|
|
18
|
+
def action_send(s):
|
|
19
|
+
if s.menu_open(): return s.app.slash_choose()
|
|
20
|
+
s.post_message(s.Submitted(s.text)); s.clear()
|
|
21
|
+
def action_newline(s): s.insert("\n")
|
|
22
|
+
def action_menu(s, d): s.app.slash_move(d)
|
|
23
|
+
def action_complete(s): s.app.slash_choose(complete_only=True)
|
|
24
|
+
def action_close_menu(s): s.app.query_one("#slash").display = False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Picker(ModalScreen):
|
|
28
|
+
CSS = "Picker { align: center middle; } OptionList { width: 90%; max-height: 80%; border: round $accent; }"
|
|
29
|
+
BINDINGS = [("escape", "dismiss(None)", "close")]
|
|
30
|
+
def __init__(s, title, items): super().__init__(); s.title_, s.items = title, items
|
|
31
|
+
def compose(s):
|
|
32
|
+
ol = OptionList(*[i[0] for i in s.items] or ["(nothing found)"]); ol.border_title = s.title_; yield ol
|
|
33
|
+
def on_option_list_option_selected(s, e):
|
|
34
|
+
s.dismiss(s.items[e.option_index][1] if s.items else None)
|
|
35
|
+
|
|
36
|
+
class Ask(ModalScreen):
|
|
37
|
+
CSS = "Ask { align: center middle; } #box { width: 80%; height: auto; border: round $accent; padding: 0 1; }"
|
|
38
|
+
BINDINGS = [("escape", "dismiss(None)", "close")]
|
|
39
|
+
def __init__(s, title, hint): super().__init__(); s.title_, s.hint = title, hint
|
|
40
|
+
def compose(s):
|
|
41
|
+
with Vertical(id="box"):
|
|
42
|
+
yield Static(s.title_); yield Input(placeholder=s.hint)
|
|
43
|
+
def on_input_submitted(s, e): e.stop(); s.dismiss(e.value.strip() or None)
|
|
@@ -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).
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
minu/__init__.py,sha256=GyE9d88zAWJoowKwK8J5f7Z_xCtp2t_rC6h_xfNlif8,226
|
|
2
|
+
minu/__main__.py,sha256=bYt9eEaoRQWdejEHFD8REx9jxVEdZptECFsV7F49Ink,30
|
|
3
|
+
minu/agent.py,sha256=0pqmSmzz6mD3PIFu8z7UmcHhbGHPbpabYU29bW5Cy_o,6509
|
|
4
|
+
minu/app.py,sha256=-51EOKHFy1hHnenN45-ylUAUYDCI5gfSAS2E7NMzu1c,10280
|
|
5
|
+
minu/cli.py,sha256=kYw53JLOuYgHwswZdirGroJl1OIKkclkEx0zU_LbRp4,2652
|
|
6
|
+
minu/config.py,sha256=rZhcl2qWzh6W54huuuyfigm_fd-5qsounOjBNX0kPdA,3087
|
|
7
|
+
minu/images.py,sha256=9jNu5Omw8pYOcFv74TFZlx9whhIs3Aq-uzy42IpJidk,1204
|
|
8
|
+
minu/installs.py,sha256=nVwTl2BSsnZYnOXRg3p1sVvlzSOmkDc0SgUIjtZD0fo,1118
|
|
9
|
+
minu/model_flows.py,sha256=7sk-qA_UqN5S6XZUJ5TydTIYV3y5UaHOuKvP8w3iufE,8195
|
|
10
|
+
minu/models.py,sha256=OZK4hdtYKjEbnb5Ndqf4T3TETwBkjziAWsISLl8ZPyU,5157
|
|
11
|
+
minu/server.py,sha256=fdeph0KW8YFbHjKUG8EbfJDKdN7L9jg4jpJutZIw7tE,4005
|
|
12
|
+
minu/sessions.py,sha256=1u3OZCHKW6D2drnqCli9e7KVWGPADx4y-LAn8oMYMq4,3624
|
|
13
|
+
minu/tools.py,sha256=iNd52HCR785_1Q537x2xrvtQ9PBVhqlhlbZt0QbU4Ak,5078
|
|
14
|
+
minu/widgets.py,sha256=UpwwaKWFWxgWdZcpPcsRgccAVgTpgRf6GJwhAorj7xs,2432
|
|
15
|
+
minu-0.1.0.dist-info/licenses/LICENSE,sha256=crU8zqxRSIoEGZ34dc8XNktSD4NtLdhED1yI0j2VW2Y,1068
|
|
16
|
+
minu-0.1.0.dist-info/WHEEL,sha256=MWx7psoF7KNST9G72pGtiBYuaXRXQXBk9pw5R6FzYko,80
|
|
17
|
+
minu-0.1.0.dist-info/entry_points.txt,sha256=q6FlCvTV19v7656P8nIU2i92b3KMi7DoiGE-MobyOoU,40
|
|
18
|
+
minu-0.1.0.dist-info/METADATA,sha256=JB4bRPcI-J1OMfTLx-ThhVD6vf0slYo-aJ--Du7gDdE,7098
|
|
19
|
+
minu-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|