miii-agent 0.1.25 → 0.1.27

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 +22 -1
  2. package/dist/cli.js +377 -31
  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>
@@ -29,10 +30,12 @@ Your code never leaves your disk. There's nothing to log in to. Pull a model, ty
29
30
 
30
31
  ```bash
31
32
  ollama pull qwen2.5-coder:14b # any coding model works
32
- npm install -g miii-agent
33
+ curl -fsSL https://raw.githubusercontent.com/maruakshay/miii-cli/main/install.sh | sh
33
34
  miii
34
35
  ```
35
36
 
37
+ Prefer npm? `npm install -g miii-agent`.
38
+
36
39
  Then just talk to it:
37
40
 
38
41
  ```
@@ -43,6 +46,19 @@ Then just talk to it:
43
46
 
44
47
  > **Needs:** Node ≥ 18 and [Ollama](https://ollama.com/download) running locally.
45
48
 
49
+ ## Staying up to date
50
+
51
+ miii checks npm on launch and, when a newer release exists, pulls it in the
52
+ background — it applies the next time you start. Manual options:
53
+
54
+ ```bash
55
+ miii update # update now
56
+ miii --version # what you're running
57
+ ```
58
+
59
+ Opt out of background updates by adding `"autoUpdate": false` to `~/.miii/config.json`,
60
+ or re-run the install script (`curl … | sh`) any time to update by hand.
61
+
46
62
  ## Why local-first?
47
63
 
48
64
  Most "AI coding tools" are just wrappers around a cloud API — slow, metered, and they ship your private codebase to someone else's server.
@@ -68,6 +84,10 @@ It doesn't just chat, either — it decomposes the problem, calls tools, and che
68
84
  miii doctor # grade every installed model
69
85
  miii doctor qwen2.5-coder:7b # grade one
70
86
  ```
87
+ - **🖼️ 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.
88
+ ```bash
89
+ ollama pull llava # or llama3.2-vision
90
+ ```
71
91
  - **💧 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
92
  - **🔒 Permission-gated tools** — you approve what the agent can touch; "always" approvals persist. File tools are confined to your working directory.
73
93
  - **📄 `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 +116,7 @@ File tools (`read_file`, `write_file`, `edit_file`) reject `../` traversal and a
96
116
  |-----|--------|
97
117
  | `Enter` | Send prompt |
98
118
  | `@filename` | Attach file to context |
119
+ | `Ctrl+V` | Paste clipboard image (needs a vision model) |
99
120
  | `/models` | Switch active model |
100
121
  | `/clear` | Reset conversation |
101
122
  | `Esc` | Stop generation or tool run |
package/dist/cli.js CHANGED
@@ -35,9 +35,13 @@ function migrate(raw) {
35
35
  provider: raw.provider,
36
36
  effort: raw.effort,
37
37
  providers,
38
- modelContexts: raw.modelContexts
38
+ modelContexts: raw.modelContexts,
39
+ autoUpdate: raw.autoUpdate
39
40
  };
40
41
  }
42
+ function autoUpdateEnabled(cfg = loadConfig()) {
43
+ return cfg.autoUpdate !== false;
44
+ }
41
45
  function readRawConfig() {
42
46
  if (!existsSync(CONFIG_PATH)) return {};
43
47
  try {
@@ -129,6 +133,15 @@ function isConnectionError(err) {
129
133
  const msg = err instanceof Error ? err.message : String(err);
130
134
  return msg.includes("ECONNREFUSED") || msg.includes("fetch failed") || msg.includes("connect");
131
135
  }
136
+ function isNoVisionError(err) {
137
+ const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
138
+ return msg.includes("does not support image") || msg.includes("image input");
139
+ }
140
+ function noVisionError(model) {
141
+ return new Error(
142
+ `"${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`
143
+ );
144
+ }
132
145
  async function listModels(entry) {
133
146
  try {
134
147
  const { models } = await makeClient(entry).list();
@@ -208,6 +221,7 @@ async function* chat(entry, model, messages, tools, opts) {
208
221
  }
209
222
  } catch (err) {
210
223
  if (signal?.aborted) return;
224
+ if (isNoVisionError(err)) throw noVisionError(model);
211
225
  if (isConnectionError(err)) {
212
226
  throw new Error(NOT_RUNNING);
213
227
  }
@@ -233,6 +247,7 @@ async function* chat(entry, model, messages, tools, opts) {
233
247
  }
234
248
  } catch (err) {
235
249
  if (opts?.signal?.aborted) return;
250
+ if (isNoVisionError(err)) throw noVisionError(model);
236
251
  if (isConnectionError(err)) {
237
252
  throw new Error(NOT_RUNNING);
238
253
  }
@@ -1292,7 +1307,8 @@ Ask in a numbered list. One round of questions per turn. Then wait.
1292
1307
 
1293
1308
  # Tool calls
1294
1309
  - When you need a tool, emit the tool call directly. No preamble, no narration, no "I will use X".
1295
- - Never describe a tool call instead of emitting it. If you cannot emit the call, answer in plain text.
1310
+ - Use the native function-calling interface as the ONLY channel for tool calls. Never print a tool call as text \u2014 not as JSON, not as a fenced code block, not as \`call:name{...}\`, not in any custom or tagged syntax. Text-form calls do NOT execute; they leak to the user and nothing happens.
1311
+ - If you cannot emit a real function call, do not fake one in prose \u2014 answer in plain text instead.
1296
1312
  - After a tool result, move directly to the next tool call or the final answer. Do not restate what the previous tool did.
1297
1313
 
1298
1314
  # Tools
@@ -1429,7 +1445,9 @@ function toOllamaMessages(history, system) {
1429
1445
  const out = [{ role: "system", content: system }];
1430
1446
  for (const msg of history) {
1431
1447
  if (typeof msg.content === "string") {
1432
- out.push({ role: msg.role === "system" ? "system" : msg.role, content: msg.content });
1448
+ const om = { role: msg.role === "system" ? "system" : msg.role, content: msg.content };
1449
+ if (msg.role === "user" && msg.images && msg.images.length > 0) om.images = msg.images;
1450
+ out.push(om);
1433
1451
  continue;
1434
1452
  }
1435
1453
  if (msg.role === "assistant") {
@@ -1462,6 +1480,11 @@ function parseTextToolCalls(text, knownToolNames) {
1462
1480
  if (!text) return { calls: [], cleanedText: text };
1463
1481
  const calls = [];
1464
1482
  let cleaned = text;
1483
+ const callSyntax = parseCallSyntax(cleaned, knownToolNames);
1484
+ if (callSyntax.calls.length > 0) {
1485
+ calls.push(...callSyntax.calls);
1486
+ cleaned = callSyntax.cleanedText;
1487
+ }
1465
1488
  const tagRe = /<\|?tool_call\|?>\s*([\s\S]*?)\s*<\|?\/?tool_call\|?>/g;
1466
1489
  cleaned = cleaned.replace(tagRe, (_m, body) => {
1467
1490
  const c = tryParse(body, knownToolNames);
@@ -1502,6 +1525,74 @@ function tryParse(raw, knownToolNames) {
1502
1525
  return null;
1503
1526
  }
1504
1527
  }
1528
+ function parseCallSyntax(text, knownToolNames) {
1529
+ const calls = [];
1530
+ const headRe = /call:\s*([a-zA-Z_]\w*)\s*\{/g;
1531
+ let m;
1532
+ let cleaned = "";
1533
+ let lastEnd = 0;
1534
+ while ((m = headRe.exec(text)) !== null) {
1535
+ const name = m[1];
1536
+ const body = parseCallBody(text, m.index + m[0].length);
1537
+ if (!body) continue;
1538
+ headRe.lastIndex = body.end;
1539
+ if (!knownToolNames.includes(name)) continue;
1540
+ calls.push({ function: { name, arguments: body.args } });
1541
+ cleaned += text.slice(lastEnd, m.index);
1542
+ lastEnd = body.end;
1543
+ }
1544
+ cleaned += text.slice(lastEnd);
1545
+ return { calls, cleanedText: cleaned.trim() };
1546
+ }
1547
+ function parseCallBody(text, start) {
1548
+ const args2 = {};
1549
+ let i = start;
1550
+ const isWs = (c) => c === " " || c === " " || c === "\n" || c === "\r";
1551
+ while (i < text.length) {
1552
+ while (i < text.length && (isWs(text[i]) || text[i] === ",")) i++;
1553
+ if (i >= text.length) return null;
1554
+ if (text[i] === "}") return { args: args2, end: i + 1 };
1555
+ const keyStart = i;
1556
+ while (i < text.length && text[i] !== ":" && text[i] !== "}") i++;
1557
+ if (i >= text.length || text[i] !== ":") return null;
1558
+ const key = text.slice(keyStart, i).trim().replace(/^["']|["']$/g, "");
1559
+ i++;
1560
+ while (i < text.length && isWs(text[i])) i++;
1561
+ if (text.startsWith(VAL_DELIM, i)) {
1562
+ const vStart = i + VAL_DELIM.length;
1563
+ const vEnd = text.indexOf(VAL_DELIM, vStart);
1564
+ if (vEnd === -1) return null;
1565
+ args2[key] = text.slice(vStart, vEnd);
1566
+ i = vEnd + VAL_DELIM.length;
1567
+ } else {
1568
+ const vStart = i;
1569
+ let depth = 0;
1570
+ let inStr = null;
1571
+ let esc = false;
1572
+ while (i < text.length) {
1573
+ const ch = text[i];
1574
+ if (inStr) {
1575
+ if (esc) esc = false;
1576
+ else if (ch === "\\") esc = true;
1577
+ else if (ch === inStr) inStr = null;
1578
+ } else if (ch === '"' || ch === "'") inStr = ch;
1579
+ else if (ch === "{" || ch === "[") depth++;
1580
+ else if (ch === "}" || ch === "]") {
1581
+ if (depth === 0) break;
1582
+ depth--;
1583
+ } else if (ch === "," && depth === 0) break;
1584
+ i++;
1585
+ }
1586
+ const rawVal = text.slice(vStart, i).trim();
1587
+ try {
1588
+ args2[key] = JSON.parse(rawVal);
1589
+ } catch {
1590
+ args2[key] = rawVal.replace(/^["']|["']$/g, "");
1591
+ }
1592
+ }
1593
+ }
1594
+ return null;
1595
+ }
1505
1596
  function extractFirstJsonObject(s) {
1506
1597
  const start = s.indexOf("{");
1507
1598
  if (start === -1) return null;
@@ -1528,6 +1619,18 @@ function extractFirstJsonObject(s) {
1528
1619
  }
1529
1620
  return null;
1530
1621
  }
1622
+ function looksLikeLeakedToolCall(text, knownToolNames) {
1623
+ if (!text) return false;
1624
+ if (text.includes(VAL_DELIM)) return true;
1625
+ if (/<\|?tool_call/i.test(text)) return true;
1626
+ const callMatch = text.match(/\bcall:\s*(\w+)\s*\{/);
1627
+ if (callMatch && knownToolNames.includes(callMatch[1])) return true;
1628
+ if (/"name"\s*:\s*"([^"]+)"/.test(text) && /"(arguments|parameters|input)"\s*:/.test(text)) {
1629
+ const name = text.match(/"name"\s*:\s*"([^"]+)"/)?.[1];
1630
+ if (name && knownToolNames.includes(name)) return true;
1631
+ }
1632
+ return false;
1633
+ }
1531
1634
  function blocksFromOllama(text, tool_calls, knownToolNames = []) {
1532
1635
  const blocks = [];
1533
1636
  let finalText = text;
@@ -1550,9 +1653,11 @@ function blocksFromOllama(text, tool_calls, knownToolNames = []) {
1550
1653
  }
1551
1654
  return blocks;
1552
1655
  }
1656
+ var VAL_DELIM;
1553
1657
  var init_adapter = __esm({
1554
1658
  "src/agent/adapter.ts"() {
1555
1659
  "use strict";
1660
+ VAL_DELIM = '<|"|>';
1556
1661
  }
1557
1662
  });
1558
1663
 
@@ -1615,12 +1720,17 @@ async function* runAgent(opts) {
1615
1720
  const effort = EFFORT_OPTIONS[loadConfig().effort ?? "medium"];
1616
1721
  const history = [
1617
1722
  ...opts.history,
1618
- { role: "user", content: opts.userText }
1723
+ {
1724
+ role: "user",
1725
+ content: opts.userText,
1726
+ ...opts.images && opts.images.length > 0 ? { images: opts.images } : {}
1727
+ }
1619
1728
  ];
1620
1729
  let promptTokens = 0;
1621
1730
  let evalTokens = 0;
1622
1731
  let lastAssistantSig = "";
1623
1732
  let repeatCount = 0;
1733
+ let leakNudges = 0;
1624
1734
  const seenPaths = /* @__PURE__ */ new Set();
1625
1735
  for (let turn = 0; turn < MAX_TURNS; turn++) {
1626
1736
  let text = "";
@@ -1705,6 +1815,16 @@ async function* runAgent(opts) {
1705
1815
  continue;
1706
1816
  }
1707
1817
  if (tool_uses.length === 0) {
1818
+ const assistantText = blocks.filter((b) => b.type === "text").map((b) => b.text).join("");
1819
+ if (leakNudges < MAX_LEAK_NUDGES && looksLikeLeakedToolCall(assistantText, toolNames)) {
1820
+ leakNudges++;
1821
+ history.push({
1822
+ role: "user",
1823
+ content: "That tool call was written as plain text, so it did not run and nothing happened. Re-issue it using the function-calling interface only \u2014 do not print the call as text, JSON, or any custom syntax. If you did not mean to call a tool, answer in prose."
1824
+ });
1825
+ yield { type: "turn-end", stop_reason: "tool_use" };
1826
+ continue;
1827
+ }
1708
1828
  yield { type: "turn-end", stop_reason: "end_turn" };
1709
1829
  break;
1710
1830
  }
@@ -1812,7 +1932,7 @@ async function* runAgent(opts) {
1812
1932
  yield { type: "done", prompt_tokens: promptTokens, eval_tokens: evalTokens };
1813
1933
  return history;
1814
1934
  }
1815
- var MAX_TURNS, REPEAT_TAIL, REPEAT_KILL, BIG_WRITE_TOOLS;
1935
+ var MAX_TURNS, REPEAT_TAIL, REPEAT_KILL, MAX_LEAK_NUDGES, BIG_WRITE_TOOLS;
1816
1936
  var init_loop = __esm({
1817
1937
  "src/agent/loop.ts"() {
1818
1938
  "use strict";
@@ -1828,21 +1948,22 @@ var init_loop = __esm({
1828
1948
  MAX_TURNS = 25;
1829
1949
  REPEAT_TAIL = 120;
1830
1950
  REPEAT_KILL = 4;
1951
+ MAX_LEAK_NUDGES = 2;
1831
1952
  BIG_WRITE_TOOLS = /* @__PURE__ */ new Set(["write_file", "edit_file"]);
1832
1953
  }
1833
1954
  });
1834
1955
 
1835
1956
  // 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";
1957
+ import { mkdtempSync, writeFileSync as writeFileSync8, mkdirSync as mkdirSync7, rmSync as rmSync4 } from "fs";
1958
+ import { dirname as dirname3, join as join11 } from "path";
1959
+ import { tmpdir as tmpdir2 } from "os";
1839
1960
  async function runScenario(model, s) {
1840
- const dir = mkdtempSync(join8(tmpdir(), "miii-eval-"));
1961
+ const dir = mkdtempSync(join11(tmpdir2(), "miii-eval-"));
1841
1962
  const prevCwd = process.cwd();
1842
1963
  for (const [rel, content] of Object.entries(s.files ?? {})) {
1843
- const abs = join8(dir, rel);
1844
- mkdirSync6(dirname3(abs), { recursive: true });
1845
- writeFileSync7(abs, content, "utf-8");
1964
+ const abs = join11(dir, rel);
1965
+ mkdirSync7(dirname3(abs), { recursive: true });
1966
+ writeFileSync8(abs, content, "utf-8");
1846
1967
  }
1847
1968
  const r = {
1848
1969
  name: s.name,
@@ -1880,7 +2001,7 @@ async function runScenario(model, s) {
1880
2001
  r.durationMs = Date.now() - start;
1881
2002
  if (r.error) {
1882
2003
  r.reason = `loop error: ${r.error}`;
1883
- rmSync3(dir, { recursive: true, force: true });
2004
+ rmSync4(dir, { recursive: true, force: true });
1884
2005
  return r;
1885
2006
  }
1886
2007
  try {
@@ -1890,7 +2011,7 @@ async function runScenario(model, s) {
1890
2011
  } catch (err) {
1891
2012
  r.reason = `check threw: ${err instanceof Error ? err.message : String(err)}`;
1892
2013
  }
1893
- rmSync3(dir, { recursive: true, force: true });
2014
+ rmSync4(dir, { recursive: true, force: true });
1894
2015
  return r;
1895
2016
  }
1896
2017
  var autoYes;
@@ -1903,13 +2024,13 @@ var init_runner = __esm({
1903
2024
  });
1904
2025
 
1905
2026
  // eval/scenarios.ts
1906
- import { readFileSync as readFileSync7, existsSync as existsSync6 } from "fs";
1907
- import { join as join9 } from "path";
2027
+ import { readFileSync as readFileSync10, existsSync as existsSync8 } from "fs";
2028
+ import { join as join12 } from "path";
1908
2029
  var read, scenarios;
1909
2030
  var init_scenarios = __esm({
1910
2031
  "eval/scenarios.ts"() {
1911
2032
  "use strict";
1912
- read = (dir, f) => existsSync6(join9(dir, f)) ? readFileSync7(join9(dir, f), "utf-8") : null;
2033
+ read = (dir, f) => existsSync8(join12(dir, f)) ? readFileSync10(join12(dir, f), "utf-8") : null;
1913
2034
  scenarios = [
1914
2035
  {
1915
2036
  name: "edit-exact-string",
@@ -2059,13 +2180,25 @@ init_client();
2059
2180
  init_config();
2060
2181
  import { useState as useState5, useEffect as useEffect4, useRef as useRef2 } from "react";
2061
2182
  import { Box as Box13, Text as Text13, useApp } from "ink";
2062
- import { homedir as homedir6 } from "os";
2183
+ import { homedir as homedir8 } from "os";
2063
2184
  import { sep as sep2 } from "path";
2064
2185
 
2065
2186
  // src/ui/WelcomeBlock.tsx
2066
2187
  import { Box, Text } from "ink";
2067
2188
  import { jsx, jsxs } from "react/jsx-runtime";
2068
- function WelcomeBlock({ model, activeCtx, effort, cwd, updateAvailable }) {
2189
+ function updateBannerText(version, status) {
2190
+ switch (status) {
2191
+ case "downloading":
2192
+ return `\u2191 v${version} downloading in the background \u2014 restart miii to apply`;
2193
+ case "installed":
2194
+ return `\u2713 v${version} installed \u2014 restart miii to apply`;
2195
+ case "failed":
2196
+ return `\u2191 v${version} update failed \u2014 run \`miii update\` manually`;
2197
+ default:
2198
+ return `\u2191 v${version} available \u2014 run \`miii update\` to upgrade`;
2199
+ }
2200
+ }
2201
+ function WelcomeBlock({ model, activeCtx, effort, cwd, updateAvailable, updateStatus = "idle" }) {
2069
2202
  const ctxLabel = activeCtx != null ? `${Math.round(activeCtx / 1024)}k ctx` : "\u2014 ctx";
2070
2203
  return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [
2071
2204
  /* @__PURE__ */ jsxs(
@@ -2092,7 +2225,7 @@ function WelcomeBlock({ model, activeCtx, effort, cwd, updateAvailable }) {
2092
2225
  ]
2093
2226
  }
2094
2227
  ),
2095
- updateAvailable && /* @__PURE__ */ jsx(Text, { color: "yellow", children: `\u2191 update available: v${updateAvailable} \u2014 run: miii --update` })
2228
+ updateAvailable && /* @__PURE__ */ jsx(Text, { color: updateStatus === "failed" ? "red" : updateStatus === "installed" ? "green" : "yellow", children: updateBannerText(updateAvailable, updateStatus) })
2096
2229
  ] });
2097
2230
  }
2098
2231
 
@@ -2346,7 +2479,8 @@ function persistSession(id, messages, title) {
2346
2479
  };
2347
2480
  const lines = [JSON.stringify({ type: "meta", ...meta })];
2348
2481
  for (const message of messages) {
2349
- lines.push(JSON.stringify({ type: "message", message }));
2482
+ const { images: _img, ...rest } = message;
2483
+ lines.push(JSON.stringify({ type: "message", message: rest }));
2350
2484
  }
2351
2485
  writeFileSync2(sessionPath(id), lines.join("\n") + "\n", "utf-8");
2352
2486
  }
@@ -3240,6 +3374,7 @@ var EMPTY_STATE_HINTS = [
3240
3374
  "\u2022 /models \u2014 switch model or effort",
3241
3375
  "\u2022 /new \u2014 start a new chat",
3242
3376
  "\u2022 /sessions \u2014 view saved chats",
3377
+ "\u2022 ctrl+v \u2014 paste an image (needs a vision model)",
3243
3378
  "\u2022 ctrl+t \u2014 toggle thinking"
3244
3379
  ];
3245
3380
  var EMPTY_STATE_TITLE = "Ask anything, or try:";
@@ -3685,7 +3820,7 @@ function useAgentRunner(model, activeCtx) {
3685
3820
  setPendingPermission(null);
3686
3821
  req.resolve(answers[cursor]);
3687
3822
  }
3688
- async function sendMessage(text) {
3823
+ async function sendMessage(text, images) {
3689
3824
  if (busyRef.current || !model) return;
3690
3825
  busyRef.current = true;
3691
3826
  setBusy(true);
@@ -3743,6 +3878,7 @@ function useAgentRunner(model, activeCtx) {
3743
3878
  cwd: process.cwd(),
3744
3879
  history: agentHistory,
3745
3880
  userText: text,
3881
+ images,
3746
3882
  permissions: { ask: askPermission },
3747
3883
  signal: controller.signal,
3748
3884
  num_ctx: activeCtx ?? void 0
@@ -3872,16 +4008,141 @@ function useAgentRunner(model, activeCtx) {
3872
4008
  }
3873
4009
 
3874
4010
  // src/ui/hooks/useKeyboard.ts
3875
- init_config();
4011
+ import { existsSync as existsSync7, readFileSync as readFileSync8 } from "fs";
4012
+ import { basename, join as join9 } from "path";
4013
+ import { homedir as homedir6 } from "os";
3876
4014
  import { useInput, useStdout } from "ink";
4015
+
4016
+ // src/ui/clipboard.ts
4017
+ import { execFileSync as execFileSync2 } from "child_process";
4018
+ import { existsSync as existsSync6, readFileSync as readFileSync7, rmSync as rmSync3 } from "fs";
4019
+ import { join as join8 } from "path";
4020
+ import { tmpdir } from "os";
4021
+ function safeRm(p) {
4022
+ try {
4023
+ rmSync3(p, { force: true });
4024
+ } catch {
4025
+ }
4026
+ }
4027
+ function consume(p) {
4028
+ try {
4029
+ const b64 = readFileSync7(p).toString("base64");
4030
+ return b64.length > 0 ? b64 : null;
4031
+ } catch {
4032
+ return null;
4033
+ } finally {
4034
+ safeRm(p);
4035
+ }
4036
+ }
4037
+ function readMac(out) {
4038
+ try {
4039
+ execFileSync2("pngpaste", [out], { stdio: "ignore" });
4040
+ if (existsSync6(out)) return consume(out);
4041
+ } catch {
4042
+ }
4043
+ const png = "\xABclass PNGf\xBB";
4044
+ const script = [
4045
+ "try",
4046
+ `set f to open for access (POSIX file "${out}") with write permission`,
4047
+ `set theData to (the clipboard as ${png})`,
4048
+ "write theData to f",
4049
+ "close access f",
4050
+ "on error",
4051
+ "try",
4052
+ "close access f",
4053
+ "end try",
4054
+ 'return "NOIMG"',
4055
+ "end try"
4056
+ ];
4057
+ try {
4058
+ const res = execFileSync2("osascript", script.flatMap((s) => ["-e", s]), { encoding: "utf8" });
4059
+ if (res.includes("NOIMG")) {
4060
+ safeRm(out);
4061
+ return null;
4062
+ }
4063
+ if (existsSync6(out)) return consume(out);
4064
+ } catch {
4065
+ }
4066
+ safeRm(out);
4067
+ return null;
4068
+ }
4069
+ function readLinux(out) {
4070
+ for (const [cmd2, args2] of [
4071
+ ["wl-paste", ["--type", "image/png"]],
4072
+ ["xclip", ["-selection", "clipboard", "-t", "image/png", "-o"]]
4073
+ ]) {
4074
+ try {
4075
+ const buf = execFileSync2(cmd2, args2, { maxBuffer: 64 * 1024 * 1024 });
4076
+ if (buf.length > 0) return buf.toString("base64");
4077
+ } catch {
4078
+ }
4079
+ }
4080
+ return null;
4081
+ }
4082
+ function readWindows(out) {
4083
+ const ps = [
4084
+ "Add-Type -AssemblyName System.Windows.Forms,System.Drawing;",
4085
+ "$img = [System.Windows.Forms.Clipboard]::GetImage();",
4086
+ // Single-quoted PS string → backslashes are literal, no escaping needed.
4087
+ `if ($img -ne $null) { $img.Save('${out}', [System.Drawing.Imaging.ImageFormat]::Png); 'OK' } else { 'NOIMG' }`
4088
+ ].join(" ");
4089
+ try {
4090
+ const res = execFileSync2(
4091
+ "powershell",
4092
+ ["-NoProfile", "-NonInteractive", "-STA", "-Command", ps],
4093
+ { encoding: "utf8" }
4094
+ );
4095
+ if (res.includes("NOIMG")) {
4096
+ safeRm(out);
4097
+ return null;
4098
+ }
4099
+ if (existsSync6(out)) return consume(out);
4100
+ } catch {
4101
+ }
4102
+ safeRm(out);
4103
+ return null;
4104
+ }
4105
+ function readClipboardImage() {
4106
+ const out = join8(tmpdir(), `miii-clip-${Date.now()}-${Math.random().toString(36).slice(2)}.png`);
4107
+ if (process.platform === "darwin") return readMac(out);
4108
+ if (process.platform === "linux") return readLinux(out);
4109
+ if (process.platform === "win32") return readWindows(out);
4110
+ return null;
4111
+ }
4112
+
4113
+ // src/ui/hooks/useKeyboard.ts
4114
+ init_config();
3877
4115
  var EFFORTS = ["low", "medium", "high"];
3878
4116
  var PASTE_CHIP_LINES = 4;
3879
4117
  var PASTE_CHIP_CHARS = 200;
3880
4118
  var pasteStore = /* @__PURE__ */ new Map();
3881
4119
  var pasteCounter = 0;
4120
+ var imageStore = /* @__PURE__ */ new Map();
4121
+ var imageCounter = 0;
4122
+ var IMAGE_EXT_RE = /\.(png|jpe?g|webp|gif|bmp)$/i;
3882
4123
  function clearPasteStore() {
3883
4124
  pasteStore.clear();
3884
4125
  pasteCounter = 0;
4126
+ imageStore.clear();
4127
+ imageCounter = 0;
4128
+ }
4129
+ function tryImagePaste(cleaned) {
4130
+ let p = cleaned.trim();
4131
+ if (p.startsWith('"') && p.endsWith('"') || p.startsWith("'") && p.endsWith("'")) {
4132
+ p = p.slice(1, -1);
4133
+ }
4134
+ p = p.replace(/\\ /g, " ");
4135
+ if (p.includes("\n") || !IMAGE_EXT_RE.test(p)) return null;
4136
+ if (p.startsWith("~/")) p = join9(homedir6(), p.slice(2));
4137
+ if (!existsSync7(p)) return null;
4138
+ try {
4139
+ const b64 = readFileSync8(p).toString("base64");
4140
+ const chip = `[Image #${++imageCounter} \xB7 ${basename(p)}]`;
4141
+ imageStore.set(chip, b64);
4142
+ return chip;
4143
+ } catch {
4144
+ return null;
4145
+ }
3885
4146
  }
3886
4147
  var inputHistory = [];
3887
4148
  var historyIndex = -1;
@@ -3902,6 +4163,8 @@ function stripControls(chunk) {
3902
4163
  function sanitizePaste(chunk) {
3903
4164
  if (chunk.length <= 1) return chunk;
3904
4165
  const cleaned = stripControls(chunk).replace(/\r/g, "");
4166
+ const imageChip = tryImagePaste(cleaned);
4167
+ if (imageChip) return imageChip;
3905
4168
  const lines = cleaned.split("\n").length;
3906
4169
  if (lines > PASTE_CHIP_LINES || cleaned.length > PASTE_CHIP_CHARS) {
3907
4170
  const chip = `[Pasted #${++pasteCounter} \xB7 ${lines} line${lines === 1 ? "" : "s"}]`;
@@ -4136,6 +4399,19 @@ function useKeyboard(opts) {
4136
4399
  }
4137
4400
  if (state === "ready") {
4138
4401
  if (busyRef.current) return;
4402
+ if (key.ctrl && char === "v") {
4403
+ const b64 = readClipboardImage();
4404
+ if (!b64) {
4405
+ setNotice("no image in clipboard");
4406
+ return;
4407
+ }
4408
+ const chip = `[Image #${++imageCounter} \xB7 clipboard]`;
4409
+ imageStore.set(chip, b64);
4410
+ historyIndex = -1;
4411
+ setInput((s) => s.slice(0, caret) + chip + s.slice(caret));
4412
+ setCaret((i) => i + chip.length);
4413
+ return;
4414
+ }
4139
4415
  const paletteOpen = input.startsWith("/");
4140
4416
  const matches2 = paletteOpen ? filteredCommands(input) : [];
4141
4417
  const mention = !paletteOpen ? parseMention(input) : null;
@@ -4248,8 +4524,16 @@ function useKeyboard(opts) {
4248
4524
  }
4249
4525
  } else if (trimmed) {
4250
4526
  setNotice(null);
4251
- const message = expandPastes(trimmed);
4252
- sendMessage(message);
4527
+ const images = [];
4528
+ let textPart = trimmed;
4529
+ for (const [chip, b64] of imageStore) {
4530
+ if (textPart.includes(chip)) {
4531
+ images.push(b64);
4532
+ textPart = textPart.split(chip).join("").trim();
4533
+ }
4534
+ }
4535
+ const message = expandPastes(textPart) || "Describe the attached image.";
4536
+ sendMessage(message, images.length ? images : void 0);
4253
4537
  }
4254
4538
  clearPasteStore();
4255
4539
  setInput(() => "");
@@ -4283,8 +4567,14 @@ function useKeyboard(opts) {
4283
4567
  for (const chip of pasteStore.keys()) {
4284
4568
  if (before.endsWith(chip) && chip.length > match.length) match = chip;
4285
4569
  }
4570
+ for (const chip of imageStore.keys()) {
4571
+ if (before.endsWith(chip) && chip.length > match.length) match = chip;
4572
+ }
4286
4573
  const cut = match ? match.length : 1;
4287
- if (match) pasteStore.delete(match);
4574
+ if (match) {
4575
+ pasteStore.delete(match);
4576
+ imageStore.delete(match);
4577
+ }
4288
4578
  setInput((s) => s.slice(0, caret - cut) + s.slice(caret));
4289
4579
  setCaret((i) => Math.max(0, i - cut));
4290
4580
  } else if (char && !key.ctrl && !key.meta && !key.tab) {
@@ -4303,8 +4593,28 @@ function useKeyboard(opts) {
4303
4593
 
4304
4594
  // src/updateCheck.ts
4305
4595
  import { createRequire } from "module";
4596
+ import { homedir as homedir7 } from "os";
4597
+ import { join as join10 } from "path";
4598
+ import { readFileSync as readFileSync9, writeFileSync as writeFileSync7, mkdirSync as mkdirSync6 } from "fs";
4306
4599
  var require2 = createRequire(import.meta.url);
4307
4600
  var PKG_NAME = "miii-agent";
4601
+ var UPDATE_ATTEMPT_PATH = join10(homedir7(), ".miii", ".last-update-attempt");
4602
+ var UPDATE_ATTEMPT_COOLDOWN_MS = 10 * 60 * 1e3;
4603
+ function recentlyAttemptedUpdate() {
4604
+ try {
4605
+ const last = Number(readFileSync9(UPDATE_ATTEMPT_PATH, "utf8").trim());
4606
+ return Number.isFinite(last) && Date.now() - last < UPDATE_ATTEMPT_COOLDOWN_MS;
4607
+ } catch {
4608
+ return false;
4609
+ }
4610
+ }
4611
+ function markUpdateAttempt() {
4612
+ try {
4613
+ mkdirSync6(join10(homedir7(), ".miii"), { recursive: true });
4614
+ writeFileSync7(UPDATE_ATTEMPT_PATH, String(Date.now()));
4615
+ } catch {
4616
+ }
4617
+ }
4308
4618
  function currentVersion() {
4309
4619
  try {
4310
4620
  return require2("../package.json").version;
@@ -4320,6 +4630,30 @@ function newerVersion(current, latest) {
4320
4630
  if (lb !== cb) return lb > cb;
4321
4631
  return lc > cc;
4322
4632
  }
4633
+ function autoUpdate(onDone) {
4634
+ if (recentlyAttemptedUpdate()) return false;
4635
+ try {
4636
+ markUpdateAttempt();
4637
+ const { spawn } = require2("child_process");
4638
+ const child = spawn("npm", ["i", "-g", `${PKG_NAME}@latest`], {
4639
+ detached: true,
4640
+ stdio: "ignore",
4641
+ shell: process.platform === "win32"
4642
+ });
4643
+ let settled = false;
4644
+ const settle = (ok) => {
4645
+ if (settled) return;
4646
+ settled = true;
4647
+ onDone?.(ok);
4648
+ };
4649
+ child.on("error", () => settle(false));
4650
+ child.on("exit", (code) => settle(code === 0));
4651
+ child.unref();
4652
+ return true;
4653
+ } catch {
4654
+ return false;
4655
+ }
4656
+ }
4323
4657
  async function checkForUpdate() {
4324
4658
  try {
4325
4659
  const res = await fetch(`https://registry.npmjs.org/${PKG_NAME}/latest`, {
@@ -4340,7 +4674,7 @@ async function checkForUpdate() {
4340
4674
  import { Fragment as Fragment3, jsx as jsx13, jsxs as jsxs13 } from "react/jsx-runtime";
4341
4675
  function App() {
4342
4676
  const { exit } = useApp();
4343
- const cwd = process.cwd().replace(homedir6(), "~").split(sep2).join("/");
4677
+ const cwd = process.cwd().replace(homedir8(), "~").split(sep2).join("/");
4344
4678
  const [cfg, setCfg] = useState5(loadConfig());
4345
4679
  const [models, setModels] = useState5([]);
4346
4680
  const [contexts, setContexts] = useState5(() => cfg.modelContexts ?? {});
@@ -4351,6 +4685,7 @@ function App() {
4351
4685
  const [cursor, setCursor] = useState5(0);
4352
4686
  const [pickerQuery, setPickerQuery] = useState5("");
4353
4687
  const [updateAvailable, setUpdateAvailable] = useState5(null);
4688
+ const [updateStatus, setUpdateStatus] = useState5("idle");
4354
4689
  const [providerDown, setProviderDown] = useState5(false);
4355
4690
  const [sessionId, setSessionId] = useState5(() => newSessionId());
4356
4691
  const [sessions, setSessions] = useState5([]);
@@ -4362,7 +4697,12 @@ function App() {
4362
4697
  const agent = useAgentRunner(cfg.model, activeCtx);
4363
4698
  useEffect4(() => {
4364
4699
  checkForUpdate().then((v) => {
4365
- if (v) setUpdateAvailable(v);
4700
+ if (!v) return;
4701
+ setUpdateAvailable(v);
4702
+ if (autoUpdateEnabled()) {
4703
+ const started = autoUpdate((ok) => setUpdateStatus(ok ? "installed" : "failed"));
4704
+ if (started) setUpdateStatus("downloading");
4705
+ }
4366
4706
  });
4367
4707
  }, []);
4368
4708
  const titledSessions = useRef2(/* @__PURE__ */ new Set());
@@ -4479,7 +4819,7 @@ function App() {
4479
4819
  return Math.round(used / activeCtx * 100);
4480
4820
  })();
4481
4821
  return /* @__PURE__ */ jsxs13(Box13, { flexDirection: "column", paddingX: 1, children: [
4482
- state !== "ready" && /* @__PURE__ */ jsx13(WelcomeBlock, { model: cfg.model, activeCtx, effort, cwd, error: agent.error, updateAvailable }),
4822
+ state !== "ready" && /* @__PURE__ */ jsx13(WelcomeBlock, { model: cfg.model, activeCtx, effort, cwd, error: agent.error, updateAvailable, updateStatus }),
4483
4823
  state === "loading" && !agent.error && /* @__PURE__ */ jsx13(Box13, { marginLeft: 2, marginBottom: 1, children: /* @__PURE__ */ jsx13(Text13, { dimColor: true, children: `connecting to ${provName}\u2026` }) }),
4484
4824
  agent.error && state !== "ready" && /* @__PURE__ */ jsx13(
4485
4825
  ChatView,
@@ -4540,7 +4880,8 @@ function App() {
4540
4880
  return /* @__PURE__ */ jsx13(FilePicker, { matches: searchFiles(process.cwd(), m.query), cursor: filePickerCursor });
4541
4881
  })(),
4542
4882
  /* @__PURE__ */ jsx13(InputBar, { input, caret, disabled: agent.busy, processingLabel: agent.processingLabel }),
4543
- !agent.busy && /* @__PURE__ */ jsx13(Box13, { marginLeft: 2, marginBottom: 1, children: /* @__PURE__ */ jsx13(Text13, { dimColor: true, children: providerDown ? "provider unavailable \u2014 /provider to switch \xB7 /models to pick a model" : "type / to see commands" }) })
4883
+ !agent.busy && /* @__PURE__ */ jsx13(Box13, { marginLeft: 2, marginBottom: 1, children: /* @__PURE__ */ jsx13(Text13, { dimColor: true, children: providerDown ? "provider unavailable \u2014 /provider to switch \xB7 /models to pick a model" : "type / to see commands" }) }),
4884
+ updateAvailable && /* @__PURE__ */ jsx13(Box13, { marginLeft: 2, marginBottom: 1, children: /* @__PURE__ */ jsx13(Text13, { color: updateStatus === "failed" ? "red" : updateStatus === "installed" ? "green" : "yellow", children: updateBannerText(updateAvailable, updateStatus) }) })
4544
4885
  ] })
4545
4886
  ] });
4546
4887
  }
@@ -4559,7 +4900,12 @@ for (let i = 0; i < args.length; i++) {
4559
4900
  cmd = args[i];
4560
4901
  }
4561
4902
  }
4562
- if (cmd === "update" || cmd === "--update" || cmd === "-u") {
4903
+ if (cmd === "version" || cmd === "--version" || cmd === "-v") {
4904
+ const { createRequire: createRequire2 } = await import("module");
4905
+ const pkg = createRequire2(import.meta.url)("../package.json");
4906
+ console.log(pkg.version);
4907
+ process.exit(0);
4908
+ } else if (cmd === "update" || cmd === "--update" || cmd === "-u") {
4563
4909
  const { spawnSync } = await import("child_process");
4564
4910
  console.log("Updating miii-agent\u2026");
4565
4911
  const r = spawnSync("npm", ["i", "-g", "miii-agent@latest"], { stdio: "inherit", shell: process.platform === "win32" });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "miii-agent",
3
- "version": "0.1.25",
3
+ "version": "0.1.27",
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": {