miii-agent 0.1.25 → 0.1.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +6 -0
  2. package/dist/cli.js +194 -20
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  <p align="center">
10
10
  <a href="https://www.npmjs.com/package/miii-agent"><img src="https://img.shields.io/npm/v/miii-agent" alt="npm version"></a>
11
+ <a href="https://www.npmjs.com/package/miii-agent"><img src="https://img.shields.io/npm/dt/miii-agent" alt="npm total downloads"></a>
11
12
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="license"></a>
12
13
  <a href="https://nodejs.org"><img src="https://img.shields.io/badge/node-%3E%3D18-brightgreen" alt="node version"></a>
13
14
  <a href="https://ollama.com"><img src="https://img.shields.io/badge/powered%20by-Ollama-black" alt="powered by Ollama"></a>
@@ -68,6 +69,10 @@ It doesn't just chat, either — it decomposes the problem, calls tools, and che
68
69
  miii doctor # grade every installed model
69
70
  miii doctor qwen2.5-coder:7b # grade one
70
71
  ```
72
+ - **🖼️ Paste images** — copy a screenshot and hit `Ctrl+V` to attach it to your message, or paste an image file path. Great for "why does this UI look broken?" or reading an error screenshot. **Needs a vision-capable model** (`llava`, `llama3.2-vision`, `qwen2-vl`, …) — text-only models silently ignore the image.
73
+ ```bash
74
+ ollama pull llava # or llama3.2-vision
75
+ ```
71
76
  - **💧 Lossless output spill** — that 50K-line test log won't get truncated and leave the model guessing. miii spills the full output to disk and lets the model page through it. Nothing is ever lost.
72
77
  - **🔒 Permission-gated tools** — you approve what the agent can touch; "always" approvals persist. File tools are confined to your working directory.
73
78
  - **📄 `MIII.md`** — drop one in your repo to teach miii your conventions, build/test commands, and do's & don'ts. Same idea as `CLAUDE.md`, read every turn.
@@ -96,6 +101,7 @@ File tools (`read_file`, `write_file`, `edit_file`) reject `../` traversal and a
96
101
  |-----|--------|
97
102
  | `Enter` | Send prompt |
98
103
  | `@filename` | Attach file to context |
104
+ | `Ctrl+V` | Paste clipboard image (needs a vision model) |
99
105
  | `/models` | Switch active model |
100
106
  | `/clear` | Reset conversation |
101
107
  | `Esc` | Stop generation or tool run |
package/dist/cli.js CHANGED
@@ -129,6 +129,15 @@ function isConnectionError(err) {
129
129
  const msg = err instanceof Error ? err.message : String(err);
130
130
  return msg.includes("ECONNREFUSED") || msg.includes("fetch failed") || msg.includes("connect");
131
131
  }
132
+ function isNoVisionError(err) {
133
+ const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
134
+ return msg.includes("does not support image") || msg.includes("image input");
135
+ }
136
+ function noVisionError(model) {
137
+ return new Error(
138
+ `"${model}" can't read images \u2014 it has no vision support. Switch to a vision-capable model (e.g. llava, llama3.2-vision, qwen2-vl) with /models, then resend. Pull one with: ollama pull llava`
139
+ );
140
+ }
132
141
  async function listModels(entry) {
133
142
  try {
134
143
  const { models } = await makeClient(entry).list();
@@ -208,6 +217,7 @@ async function* chat(entry, model, messages, tools, opts) {
208
217
  }
209
218
  } catch (err) {
210
219
  if (signal?.aborted) return;
220
+ if (isNoVisionError(err)) throw noVisionError(model);
211
221
  if (isConnectionError(err)) {
212
222
  throw new Error(NOT_RUNNING);
213
223
  }
@@ -233,6 +243,7 @@ async function* chat(entry, model, messages, tools, opts) {
233
243
  }
234
244
  } catch (err) {
235
245
  if (opts?.signal?.aborted) return;
246
+ if (isNoVisionError(err)) throw noVisionError(model);
236
247
  if (isConnectionError(err)) {
237
248
  throw new Error(NOT_RUNNING);
238
249
  }
@@ -1429,7 +1440,9 @@ function toOllamaMessages(history, system) {
1429
1440
  const out = [{ role: "system", content: system }];
1430
1441
  for (const msg of history) {
1431
1442
  if (typeof msg.content === "string") {
1432
- out.push({ role: msg.role === "system" ? "system" : msg.role, content: msg.content });
1443
+ const om = { role: msg.role === "system" ? "system" : msg.role, content: msg.content };
1444
+ if (msg.role === "user" && msg.images && msg.images.length > 0) om.images = msg.images;
1445
+ out.push(om);
1433
1446
  continue;
1434
1447
  }
1435
1448
  if (msg.role === "assistant") {
@@ -1615,7 +1628,11 @@ async function* runAgent(opts) {
1615
1628
  const effort = EFFORT_OPTIONS[loadConfig().effort ?? "medium"];
1616
1629
  const history = [
1617
1630
  ...opts.history,
1618
- { role: "user", content: opts.userText }
1631
+ {
1632
+ role: "user",
1633
+ content: opts.userText,
1634
+ ...opts.images && opts.images.length > 0 ? { images: opts.images } : {}
1635
+ }
1619
1636
  ];
1620
1637
  let promptTokens = 0;
1621
1638
  let evalTokens = 0;
@@ -1833,14 +1850,14 @@ var init_loop = __esm({
1833
1850
  });
1834
1851
 
1835
1852
  // eval/runner.ts
1836
- import { mkdtempSync, writeFileSync as writeFileSync7, mkdirSync as mkdirSync6, rmSync as rmSync3 } from "fs";
1837
- import { dirname as dirname3, join as join8 } from "path";
1838
- import { tmpdir } from "os";
1853
+ import { mkdtempSync, writeFileSync as writeFileSync7, mkdirSync as mkdirSync6, rmSync as rmSync4 } from "fs";
1854
+ import { dirname as dirname3, join as join10 } from "path";
1855
+ import { tmpdir as tmpdir2 } from "os";
1839
1856
  async function runScenario(model, s) {
1840
- const dir = mkdtempSync(join8(tmpdir(), "miii-eval-"));
1857
+ const dir = mkdtempSync(join10(tmpdir2(), "miii-eval-"));
1841
1858
  const prevCwd = process.cwd();
1842
1859
  for (const [rel, content] of Object.entries(s.files ?? {})) {
1843
- const abs = join8(dir, rel);
1860
+ const abs = join10(dir, rel);
1844
1861
  mkdirSync6(dirname3(abs), { recursive: true });
1845
1862
  writeFileSync7(abs, content, "utf-8");
1846
1863
  }
@@ -1880,7 +1897,7 @@ async function runScenario(model, s) {
1880
1897
  r.durationMs = Date.now() - start;
1881
1898
  if (r.error) {
1882
1899
  r.reason = `loop error: ${r.error}`;
1883
- rmSync3(dir, { recursive: true, force: true });
1900
+ rmSync4(dir, { recursive: true, force: true });
1884
1901
  return r;
1885
1902
  }
1886
1903
  try {
@@ -1890,7 +1907,7 @@ async function runScenario(model, s) {
1890
1907
  } catch (err) {
1891
1908
  r.reason = `check threw: ${err instanceof Error ? err.message : String(err)}`;
1892
1909
  }
1893
- rmSync3(dir, { recursive: true, force: true });
1910
+ rmSync4(dir, { recursive: true, force: true });
1894
1911
  return r;
1895
1912
  }
1896
1913
  var autoYes;
@@ -1903,13 +1920,13 @@ var init_runner = __esm({
1903
1920
  });
1904
1921
 
1905
1922
  // eval/scenarios.ts
1906
- import { readFileSync as readFileSync7, existsSync as existsSync6 } from "fs";
1907
- import { join as join9 } from "path";
1923
+ import { readFileSync as readFileSync9, existsSync as existsSync8 } from "fs";
1924
+ import { join as join11 } from "path";
1908
1925
  var read, scenarios;
1909
1926
  var init_scenarios = __esm({
1910
1927
  "eval/scenarios.ts"() {
1911
1928
  "use strict";
1912
- read = (dir, f) => existsSync6(join9(dir, f)) ? readFileSync7(join9(dir, f), "utf-8") : null;
1929
+ read = (dir, f) => existsSync8(join11(dir, f)) ? readFileSync9(join11(dir, f), "utf-8") : null;
1913
1930
  scenarios = [
1914
1931
  {
1915
1932
  name: "edit-exact-string",
@@ -2059,7 +2076,7 @@ init_client();
2059
2076
  init_config();
2060
2077
  import { useState as useState5, useEffect as useEffect4, useRef as useRef2 } from "react";
2061
2078
  import { Box as Box13, Text as Text13, useApp } from "ink";
2062
- import { homedir as homedir6 } from "os";
2079
+ import { homedir as homedir7 } from "os";
2063
2080
  import { sep as sep2 } from "path";
2064
2081
 
2065
2082
  // src/ui/WelcomeBlock.tsx
@@ -2346,7 +2363,8 @@ function persistSession(id, messages, title) {
2346
2363
  };
2347
2364
  const lines = [JSON.stringify({ type: "meta", ...meta })];
2348
2365
  for (const message of messages) {
2349
- lines.push(JSON.stringify({ type: "message", message }));
2366
+ const { images: _img, ...rest } = message;
2367
+ lines.push(JSON.stringify({ type: "message", message: rest }));
2350
2368
  }
2351
2369
  writeFileSync2(sessionPath(id), lines.join("\n") + "\n", "utf-8");
2352
2370
  }
@@ -3240,6 +3258,7 @@ var EMPTY_STATE_HINTS = [
3240
3258
  "\u2022 /models \u2014 switch model or effort",
3241
3259
  "\u2022 /new \u2014 start a new chat",
3242
3260
  "\u2022 /sessions \u2014 view saved chats",
3261
+ "\u2022 ctrl+v \u2014 paste an image (needs a vision model)",
3243
3262
  "\u2022 ctrl+t \u2014 toggle thinking"
3244
3263
  ];
3245
3264
  var EMPTY_STATE_TITLE = "Ask anything, or try:";
@@ -3685,7 +3704,7 @@ function useAgentRunner(model, activeCtx) {
3685
3704
  setPendingPermission(null);
3686
3705
  req.resolve(answers[cursor]);
3687
3706
  }
3688
- async function sendMessage(text) {
3707
+ async function sendMessage(text, images) {
3689
3708
  if (busyRef.current || !model) return;
3690
3709
  busyRef.current = true;
3691
3710
  setBusy(true);
@@ -3743,6 +3762,7 @@ function useAgentRunner(model, activeCtx) {
3743
3762
  cwd: process.cwd(),
3744
3763
  history: agentHistory,
3745
3764
  userText: text,
3765
+ images,
3746
3766
  permissions: { ask: askPermission },
3747
3767
  signal: controller.signal,
3748
3768
  num_ctx: activeCtx ?? void 0
@@ -3872,16 +3892,141 @@ function useAgentRunner(model, activeCtx) {
3872
3892
  }
3873
3893
 
3874
3894
  // src/ui/hooks/useKeyboard.ts
3875
- init_config();
3895
+ import { existsSync as existsSync7, readFileSync as readFileSync8 } from "fs";
3896
+ import { basename, join as join9 } from "path";
3897
+ import { homedir as homedir6 } from "os";
3876
3898
  import { useInput, useStdout } from "ink";
3899
+
3900
+ // src/ui/clipboard.ts
3901
+ import { execFileSync as execFileSync2 } from "child_process";
3902
+ import { existsSync as existsSync6, readFileSync as readFileSync7, rmSync as rmSync3 } from "fs";
3903
+ import { join as join8 } from "path";
3904
+ import { tmpdir } from "os";
3905
+ function safeRm(p) {
3906
+ try {
3907
+ rmSync3(p, { force: true });
3908
+ } catch {
3909
+ }
3910
+ }
3911
+ function consume(p) {
3912
+ try {
3913
+ const b64 = readFileSync7(p).toString("base64");
3914
+ return b64.length > 0 ? b64 : null;
3915
+ } catch {
3916
+ return null;
3917
+ } finally {
3918
+ safeRm(p);
3919
+ }
3920
+ }
3921
+ function readMac(out) {
3922
+ try {
3923
+ execFileSync2("pngpaste", [out], { stdio: "ignore" });
3924
+ if (existsSync6(out)) return consume(out);
3925
+ } catch {
3926
+ }
3927
+ const png = "\xABclass PNGf\xBB";
3928
+ const script = [
3929
+ "try",
3930
+ `set f to open for access (POSIX file "${out}") with write permission`,
3931
+ `set theData to (the clipboard as ${png})`,
3932
+ "write theData to f",
3933
+ "close access f",
3934
+ "on error",
3935
+ "try",
3936
+ "close access f",
3937
+ "end try",
3938
+ 'return "NOIMG"',
3939
+ "end try"
3940
+ ];
3941
+ try {
3942
+ const res = execFileSync2("osascript", script.flatMap((s) => ["-e", s]), { encoding: "utf8" });
3943
+ if (res.includes("NOIMG")) {
3944
+ safeRm(out);
3945
+ return null;
3946
+ }
3947
+ if (existsSync6(out)) return consume(out);
3948
+ } catch {
3949
+ }
3950
+ safeRm(out);
3951
+ return null;
3952
+ }
3953
+ function readLinux(out) {
3954
+ for (const [cmd2, args2] of [
3955
+ ["wl-paste", ["--type", "image/png"]],
3956
+ ["xclip", ["-selection", "clipboard", "-t", "image/png", "-o"]]
3957
+ ]) {
3958
+ try {
3959
+ const buf = execFileSync2(cmd2, args2, { maxBuffer: 64 * 1024 * 1024 });
3960
+ if (buf.length > 0) return buf.toString("base64");
3961
+ } catch {
3962
+ }
3963
+ }
3964
+ return null;
3965
+ }
3966
+ function readWindows(out) {
3967
+ const ps = [
3968
+ "Add-Type -AssemblyName System.Windows.Forms,System.Drawing;",
3969
+ "$img = [System.Windows.Forms.Clipboard]::GetImage();",
3970
+ // Single-quoted PS string → backslashes are literal, no escaping needed.
3971
+ `if ($img -ne $null) { $img.Save('${out}', [System.Drawing.Imaging.ImageFormat]::Png); 'OK' } else { 'NOIMG' }`
3972
+ ].join(" ");
3973
+ try {
3974
+ const res = execFileSync2(
3975
+ "powershell",
3976
+ ["-NoProfile", "-NonInteractive", "-STA", "-Command", ps],
3977
+ { encoding: "utf8" }
3978
+ );
3979
+ if (res.includes("NOIMG")) {
3980
+ safeRm(out);
3981
+ return null;
3982
+ }
3983
+ if (existsSync6(out)) return consume(out);
3984
+ } catch {
3985
+ }
3986
+ safeRm(out);
3987
+ return null;
3988
+ }
3989
+ function readClipboardImage() {
3990
+ const out = join8(tmpdir(), `miii-clip-${Date.now()}-${Math.random().toString(36).slice(2)}.png`);
3991
+ if (process.platform === "darwin") return readMac(out);
3992
+ if (process.platform === "linux") return readLinux(out);
3993
+ if (process.platform === "win32") return readWindows(out);
3994
+ return null;
3995
+ }
3996
+
3997
+ // src/ui/hooks/useKeyboard.ts
3998
+ init_config();
3877
3999
  var EFFORTS = ["low", "medium", "high"];
3878
4000
  var PASTE_CHIP_LINES = 4;
3879
4001
  var PASTE_CHIP_CHARS = 200;
3880
4002
  var pasteStore = /* @__PURE__ */ new Map();
3881
4003
  var pasteCounter = 0;
4004
+ var imageStore = /* @__PURE__ */ new Map();
4005
+ var imageCounter = 0;
4006
+ var IMAGE_EXT_RE = /\.(png|jpe?g|webp|gif|bmp)$/i;
3882
4007
  function clearPasteStore() {
3883
4008
  pasteStore.clear();
3884
4009
  pasteCounter = 0;
4010
+ imageStore.clear();
4011
+ imageCounter = 0;
4012
+ }
4013
+ function tryImagePaste(cleaned) {
4014
+ let p = cleaned.trim();
4015
+ if (p.startsWith('"') && p.endsWith('"') || p.startsWith("'") && p.endsWith("'")) {
4016
+ p = p.slice(1, -1);
4017
+ }
4018
+ p = p.replace(/\\ /g, " ");
4019
+ if (p.includes("\n") || !IMAGE_EXT_RE.test(p)) return null;
4020
+ if (p.startsWith("~/")) p = join9(homedir6(), p.slice(2));
4021
+ if (!existsSync7(p)) return null;
4022
+ try {
4023
+ const b64 = readFileSync8(p).toString("base64");
4024
+ const chip = `[Image #${++imageCounter} \xB7 ${basename(p)}]`;
4025
+ imageStore.set(chip, b64);
4026
+ return chip;
4027
+ } catch {
4028
+ return null;
4029
+ }
3885
4030
  }
3886
4031
  var inputHistory = [];
3887
4032
  var historyIndex = -1;
@@ -3902,6 +4047,8 @@ function stripControls(chunk) {
3902
4047
  function sanitizePaste(chunk) {
3903
4048
  if (chunk.length <= 1) return chunk;
3904
4049
  const cleaned = stripControls(chunk).replace(/\r/g, "");
4050
+ const imageChip = tryImagePaste(cleaned);
4051
+ if (imageChip) return imageChip;
3905
4052
  const lines = cleaned.split("\n").length;
3906
4053
  if (lines > PASTE_CHIP_LINES || cleaned.length > PASTE_CHIP_CHARS) {
3907
4054
  const chip = `[Pasted #${++pasteCounter} \xB7 ${lines} line${lines === 1 ? "" : "s"}]`;
@@ -4136,6 +4283,19 @@ function useKeyboard(opts) {
4136
4283
  }
4137
4284
  if (state === "ready") {
4138
4285
  if (busyRef.current) return;
4286
+ if (key.ctrl && char === "v") {
4287
+ const b64 = readClipboardImage();
4288
+ if (!b64) {
4289
+ setNotice("no image in clipboard");
4290
+ return;
4291
+ }
4292
+ const chip = `[Image #${++imageCounter} \xB7 clipboard]`;
4293
+ imageStore.set(chip, b64);
4294
+ historyIndex = -1;
4295
+ setInput((s) => s.slice(0, caret) + chip + s.slice(caret));
4296
+ setCaret((i) => i + chip.length);
4297
+ return;
4298
+ }
4139
4299
  const paletteOpen = input.startsWith("/");
4140
4300
  const matches2 = paletteOpen ? filteredCommands(input) : [];
4141
4301
  const mention = !paletteOpen ? parseMention(input) : null;
@@ -4248,8 +4408,16 @@ function useKeyboard(opts) {
4248
4408
  }
4249
4409
  } else if (trimmed) {
4250
4410
  setNotice(null);
4251
- const message = expandPastes(trimmed);
4252
- sendMessage(message);
4411
+ const images = [];
4412
+ let textPart = trimmed;
4413
+ for (const [chip, b64] of imageStore) {
4414
+ if (textPart.includes(chip)) {
4415
+ images.push(b64);
4416
+ textPart = textPart.split(chip).join("").trim();
4417
+ }
4418
+ }
4419
+ const message = expandPastes(textPart) || "Describe the attached image.";
4420
+ sendMessage(message, images.length ? images : void 0);
4253
4421
  }
4254
4422
  clearPasteStore();
4255
4423
  setInput(() => "");
@@ -4283,8 +4451,14 @@ function useKeyboard(opts) {
4283
4451
  for (const chip of pasteStore.keys()) {
4284
4452
  if (before.endsWith(chip) && chip.length > match.length) match = chip;
4285
4453
  }
4454
+ for (const chip of imageStore.keys()) {
4455
+ if (before.endsWith(chip) && chip.length > match.length) match = chip;
4456
+ }
4286
4457
  const cut = match ? match.length : 1;
4287
- if (match) pasteStore.delete(match);
4458
+ if (match) {
4459
+ pasteStore.delete(match);
4460
+ imageStore.delete(match);
4461
+ }
4288
4462
  setInput((s) => s.slice(0, caret - cut) + s.slice(caret));
4289
4463
  setCaret((i) => Math.max(0, i - cut));
4290
4464
  } else if (char && !key.ctrl && !key.meta && !key.tab) {
@@ -4340,7 +4514,7 @@ async function checkForUpdate() {
4340
4514
  import { Fragment as Fragment3, jsx as jsx13, jsxs as jsxs13 } from "react/jsx-runtime";
4341
4515
  function App() {
4342
4516
  const { exit } = useApp();
4343
- const cwd = process.cwd().replace(homedir6(), "~").split(sep2).join("/");
4517
+ const cwd = process.cwd().replace(homedir7(), "~").split(sep2).join("/");
4344
4518
  const [cfg, setCfg] = useState5(loadConfig());
4345
4519
  const [models, setModels] = useState5([]);
4346
4520
  const [contexts, setContexts] = useState5(() => cfg.modelContexts ?? {});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "miii-agent",
3
- "version": "0.1.25",
3
+ "version": "0.1.26",
4
4
  "description": "Cursor / Claude Code, but local. An offline AI pair-programmer in your terminal, powered by Ollama. Private by default, free forever.",
5
5
  "type": "module",
6
6
  "bin": {