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 ADDED
@@ -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"
minu/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.13.7
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ minu = minu.cli:main
3
+
@@ -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.