miii-agent 0.1.31 → 0.1.33

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 (2) hide show
  1. package/dist/cli.js +298 -63
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -184,7 +184,7 @@ async function modelContext(entry, model) {
184
184
  }
185
185
  const msg = err instanceof Error ? err.message : String(err);
186
186
  if (msg.toLowerCase().includes("not found") || msg.toLowerCase().includes("unknown model")) {
187
- throw new Error(`Model "${model}" not found. Run: ollama pull ${model}`);
187
+ throw new Error(`I don't see a model called "${model}" on Ollama. Pull it first with: ollama pull ${model}`);
188
188
  }
189
189
  throw err;
190
190
  }
@@ -240,7 +240,7 @@ async function* chat(entry, model, messages, tools, opts) {
240
240
  }
241
241
  const msg = err instanceof Error ? err.message : String(err);
242
242
  if (msg.toLowerCase().includes("not found") || msg.toLowerCase().includes("unknown model")) {
243
- throw new Error(`Model "${model}" not found. Run: ollama pull ${model}`);
243
+ throw new Error(`I don't see a model called "${model}" on Ollama. Pull it first with: ollama pull ${model}`);
244
244
  }
245
245
  throw err;
246
246
  }
@@ -278,8 +278,8 @@ var NOT_INSTALLED, NOT_RUNNING, LOCAL_HOST_RE, HARMONY_RE, CHANNEL_LABEL_RE;
278
278
  var init_ollama = __esm({
279
279
  "src/llm/ollama.ts"() {
280
280
  "use strict";
281
- NOT_INSTALLED = "Ollama is not installed. Install it with: npm i -g ollama\nOr download from https://ollama.com/download";
282
- NOT_RUNNING = "Ollama is not running. Start it with: ollama serve";
281
+ NOT_INSTALLED = "I can't find Ollama on this machine. Install it with: npm i -g ollama\nOr download it from https://ollama.com/download";
282
+ NOT_RUNNING = "Ollama isn't running, so I can't reach it. Start it with: ollama serve";
283
283
  LOCAL_HOST_RE = /localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]/;
284
284
  HARMONY_RE = /<\|?\/?(?:channel|message|start|end|return|constrain|assistant|user|system|developer|tool|tool_call|tool_response|final|analysis|commentary)\|?>/gi;
285
285
  CHANNEL_LABEL_RE = /^(?:analysis|commentary|final)\s*(?=\w)/i;
@@ -288,7 +288,7 @@ var init_ollama = __esm({
288
288
 
289
289
  // src/llm/openai.ts
290
290
  function notAvailable(entry) {
291
- return `Cannot reach OpenAI-compatible provider at ${entry.baseUrl}. Make sure the server is running and the baseUrl is correct.`;
291
+ return `I couldn't reach the provider at ${entry.baseUrl}. Check that the server is running and the baseUrl is correct.`;
292
292
  }
293
293
  function headers(entry) {
294
294
  const h = { "Content-Type": "application/json" };
@@ -314,7 +314,7 @@ async function listModels2(entry) {
314
314
  detail = await res.text();
315
315
  } catch {
316
316
  }
317
- throw new Error(`Provider error (HTTP ${res.status}): ${detail || res.statusText}`);
317
+ throw new Error(`The provider came back with an error (HTTP ${res.status}): ${detail || res.statusText}`);
318
318
  }
319
319
  const body = await res.json();
320
320
  return body.data.map((m) => m.id);
@@ -406,12 +406,12 @@ async function* chat2(entry, model, messages, tools, opts) {
406
406
  } catch {
407
407
  }
408
408
  if (res.status === 404) {
409
- throw new Error(`Model "${model}" not found at ${entry.baseUrl}. Make sure it's available.`);
409
+ throw new Error(`The provider at ${entry.baseUrl} has no model called "${model}". Double-check the name, or pick another with /models.`);
410
410
  }
411
- throw new Error(`Provider error (HTTP ${res.status}): ${detail || res.statusText}`);
411
+ throw new Error(`The provider came back with an error (HTTP ${res.status}): ${detail || res.statusText}`);
412
412
  }
413
413
  const reader = res.body?.getReader();
414
- if (!reader) throw new Error("No response body");
414
+ if (!reader) throw new Error("The provider answered but sent no body to read.");
415
415
  const decoder = new TextDecoder();
416
416
  let buffer = "";
417
417
  try {
@@ -470,7 +470,7 @@ async function* chat2(entry, model, messages, tools, opts) {
470
470
  throw new Error(notAvailable(entry));
471
471
  }
472
472
  if (timeoutSignal.aborted && !opts?.signal?.aborted) {
473
- throw new Error(`Provider request timed out after ${TIMEOUT_MS / 1e3}s. The model may still be loading or thinking.`);
473
+ throw new Error(`The provider didn't respond within ${TIMEOUT_MS / 1e3}s, so I stopped waiting. The model may still be loading or thinking \u2014 give it another try.`);
474
474
  }
475
475
  throw err;
476
476
  }
@@ -678,14 +678,14 @@ function isUnder(parent, child) {
678
678
  }
679
679
  function confinePath(p) {
680
680
  if (typeof p !== "string" || p.length === 0) {
681
- throw new Error("Path is required.");
681
+ throw new Error("I need a file path here, but none was given.");
682
682
  }
683
683
  const root = process.cwd();
684
684
  const abs = resolve(root, p);
685
685
  if (isUnder(root, abs) || isUnder(SPILL_DIR, abs)) {
686
686
  return abs;
687
687
  }
688
- throw new Error(`Path "${p}" is outside the working directory (${root}). Access denied.`);
688
+ throw new Error(`"${p}" sits outside the working directory (${root}), so I can't touch it. Stay within the project folder.`);
689
689
  }
690
690
  var SPILL_DIR;
691
691
  var init_paths = __esm({
@@ -787,9 +787,46 @@ function nearMiss(src, old_str) {
787
787
  const width = String(to).length;
788
788
  const ctx = srcLines.slice(from, to).map((l, i) => `${String(from + i + 1).padStart(width, " ")} ${l}`).join("\n");
789
789
  return `
790
- Closest text in file (lines ${from + 1}-${to}):
790
+ Here's the closest text I could find (lines ${from + 1}-${to}) \u2014 copy it exactly:
791
791
  ${ctx}`;
792
792
  }
793
+ function locate(src, old_str) {
794
+ const first = src.indexOf(old_str);
795
+ if (first !== -1) {
796
+ if (src.indexOf(old_str, first + 1) !== -1) {
797
+ return { error: `That text shows up in more than one place, so I can't tell which one you mean. Add a line or two around it to make it unique.` };
798
+ }
799
+ return [first, first + old_str.length];
800
+ }
801
+ const fuzzy = fuzzyRange(src, old_str);
802
+ if (fuzzy) return fuzzy;
803
+ return { error: `I couldn't find that text \u2014 it may differ by whitespace or a stray character.${nearMiss(src, old_str)}` };
804
+ }
805
+ function applyBatch(src, edits) {
806
+ const ranges = [];
807
+ for (let i = 0; i < edits.length; i++) {
808
+ const { old_str, new_str } = edits[i];
809
+ if (typeof old_str !== "string" || typeof new_str !== "string") {
810
+ return { error: `Edit #${i + 1} is missing text \u2014 each edit needs both an old_str and a new_str string.` };
811
+ }
812
+ if (old_str === "") return { error: `Edit #${i + 1} has an empty old_str, so there's nothing to look for. Give it the exact text you want to replace.` };
813
+ if (old_str === new_str) return { error: `Edit #${i + 1} has the same old_str and new_str, so it wouldn't change anything.` };
814
+ const r = locate(src, old_str);
815
+ if (!Array.isArray(r)) return { error: `Edit #${i + 1}: ${r.error}` };
816
+ ranges.push({ start: r[0], end: r[1], new_str });
817
+ }
818
+ const sorted = [...ranges].sort((a, b) => a.start - b.start);
819
+ for (let i = 1; i < sorted.length; i++) {
820
+ if (sorted[i].start < sorted[i - 1].end) {
821
+ return { error: `Two of these edits touch the same spot, so I can't apply them together. Split them into separate calls, or widen each old_str so they don't overlap.` };
822
+ }
823
+ }
824
+ let out = src;
825
+ for (const r of [...ranges].sort((a, b) => b.start - a.start)) {
826
+ out = out.slice(0, r.start) + r.new_str + out.slice(r.end);
827
+ }
828
+ return { out, count: ranges.length };
829
+ }
793
830
  var edit_file;
794
831
  var init_edit_file = __esm({
795
832
  "src/tools/edit_file.ts"() {
@@ -798,22 +835,45 @@ var init_edit_file = __esm({
798
835
  init_verifyHint();
799
836
  edit_file = {
800
837
  name: "edit_file",
801
- description: "Replace an exact string in a file. old_str must be unique unless replace_all is set. On no match, returns the closest text in the file.",
838
+ description: "Replace an exact string in a file. old_str must be unique unless replace_all is set. On no match, returns the closest text in the file. To make several edits to one file at once, pass an `edits` array of {old_str,new_str} \u2014 they apply atomically (all or nothing).",
802
839
  input_schema: {
803
840
  type: "object",
804
841
  properties: {
805
842
  path: { type: "string", description: "File path" },
806
- old_str: { type: "string", description: "Exact text to replace (whitespace-sensitive)" },
807
- new_str: { type: "string", description: "Replacement text" },
808
- replace_all: { type: "boolean", description: "Replace every occurrence instead of requiring uniqueness" }
843
+ old_str: { type: "string", description: "Exact text to replace (whitespace-sensitive). Omit when using edits[]." },
844
+ new_str: { type: "string", description: "Replacement text. Omit when using edits[]." },
845
+ replace_all: { type: "boolean", description: "Replace every occurrence instead of requiring uniqueness" },
846
+ edits: {
847
+ type: "array",
848
+ description: "Batch mode: several edits applied atomically. Each old_str must be unique in the file. Alternative to old_str/new_str.",
849
+ items: {
850
+ type: "object",
851
+ properties: {
852
+ old_str: { type: "string", description: "Exact text to replace (whitespace-sensitive)" },
853
+ new_str: { type: "string", description: "Replacement text" }
854
+ },
855
+ required: ["old_str", "new_str"]
856
+ }
857
+ }
809
858
  },
810
- required: ["path", "old_str", "new_str"]
859
+ required: ["path"]
811
860
  },
812
- handler: ({ path, old_str, new_str, replace_all }) => {
861
+ handler: ({ path, old_str, new_str, replace_all, edits }) => {
813
862
  try {
863
+ if (Array.isArray(edits) && edits.length > 0) {
864
+ const abs2 = confinePath(path);
865
+ const src2 = readFileSync4(abs2, "utf-8");
866
+ const res = applyBatch(src2, edits);
867
+ if ("error" in res) return { content: `${res.error} (in ${path})`, is_error: true };
868
+ writeFileSync4(abs2, res.out, "utf-8");
869
+ return { content: `Edited ${path} (${res.count} edits).${verifyHint(path)}` };
870
+ }
871
+ if (typeof old_str !== "string" || typeof new_str !== "string") {
872
+ return { content: `To edit ${path} I need either an old_str/new_str pair or an edits[] array \u2014 neither came through.`, is_error: true };
873
+ }
814
874
  if (old_str === new_str) {
815
875
  return {
816
- content: `old_str and new_str are identical \u2014 nothing to change in ${path}. If the file is already correct, do NOT edit again: finish with the respond action and tell the user it is done.`,
876
+ content: `old_str and new_str are the same, so there's nothing to change in ${path}. If the file is already correct, don't edit it again \u2014 finish with the respond action and tell the user it's done.`,
817
877
  is_error: true
818
878
  };
819
879
  }
@@ -830,12 +890,12 @@ var init_edit_file = __esm({
830
890
  return { content: `Edited ${path} (whitespace-tolerant match).${verifyHint(path)}` };
831
891
  }
832
892
  }
833
- return { content: `old_str not found in ${path}.${nearMiss(src, old_str)}`, is_error: true };
893
+ return { content: `I couldn't find that text in ${path} \u2014 it may differ by whitespace or a stray character.${nearMiss(src, old_str)}`, is_error: true };
834
894
  }
835
895
  const all = replace_all === true;
836
896
  if (!all && src.indexOf(old_str, first + 1) !== -1) {
837
897
  return {
838
- content: `old_str not unique in ${path}. Add surrounding context to disambiguate, or set replace_all.`,
898
+ content: `That text appears more than once in ${path}, so I don't know which one to change. Add a line or two around it to single it out, or set replace_all to change them all.`,
839
899
  is_error: true
840
900
  };
841
901
  }
@@ -857,11 +917,22 @@ function numbered(lines, start) {
857
917
  const width = String(start + lines.length - 1).length;
858
918
  return lines.map((l, i) => `${String(start + i).padStart(width, " ")} ${l}`).join("\n");
859
919
  }
860
- var read_file;
920
+ function looksImage(buf) {
921
+ if (buf.length < 4) return false;
922
+ if (buf[0] === 137 && buf[1] === 80) return true;
923
+ if (buf[0] === 255 && buf[1] === 216) return true;
924
+ if (buf[0] === 71 && buf[1] === 73) return true;
925
+ if (buf[0] === 66 && buf[1] === 77) return true;
926
+ if (buf.length >= 12 && buf.toString("ascii", 0, 4) === "RIFF" && buf.toString("ascii", 8, 12) === "WEBP") return true;
927
+ return false;
928
+ }
929
+ var IMAGE_EXT, MAX_IMAGE_BYTES, read_file;
861
930
  var init_read_file = __esm({
862
931
  "src/tools/read_file.ts"() {
863
932
  "use strict";
864
933
  init_paths();
934
+ IMAGE_EXT = /* @__PURE__ */ new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp"]);
935
+ MAX_IMAGE_BYTES = 8 * 1024 * 1024;
865
936
  read_file = {
866
937
  name: "read_file",
867
938
  description: "Read file contents as UTF-8 text with line numbers. Use offset/limit to read a range of a large file instead of the whole thing.",
@@ -878,8 +949,21 @@ var init_read_file = __esm({
878
949
  try {
879
950
  const MAX_CHARS = 2e5;
880
951
  const buf = readFileSync5(confinePath(path));
952
+ const ext = path.slice(path.lastIndexOf(".") + 1).toLowerCase();
953
+ if (IMAGE_EXT.has(ext) || looksImage(buf)) {
954
+ if (buf.length > MAX_IMAGE_BYTES) {
955
+ return {
956
+ content: `${path} is an image, but at ${buf.length} bytes it's too big to attach (limit is ${MAX_IMAGE_BYTES}). Resize it and try again.`,
957
+ is_error: true
958
+ };
959
+ }
960
+ return {
961
+ content: `[image ${path} \u2014 ${buf.length} bytes, attached for viewing]`,
962
+ images: [buf.toString("base64")]
963
+ };
964
+ }
881
965
  if (buf.subarray(0, 8e3).includes(0)) {
882
- return { content: `${path} looks binary (${buf.length} bytes); not reading as text.`, is_error: true };
966
+ return { content: `${path} looks like a binary file (${buf.length} bytes), so I'm not reading it as text.`, is_error: true };
883
967
  }
884
968
  const raw = buf.toString("utf-8").replace(/\r\n/g, "\n");
885
969
  const allLines = raw.split("\n");
@@ -891,7 +975,7 @@ var init_read_file = __esm({
891
975
  let body = numbered(slice, start);
892
976
  if (body.length > MAX_CHARS) {
893
977
  body = body.slice(0, MAX_CHARS) + `
894
- [truncated: output exceeded ${MAX_CHARS} chars \u2014 use offset/limit]`;
978
+ [There's more \u2014 this hit the ${MAX_CHARS}-char limit. Use offset/limit to read the rest.]`;
895
979
  }
896
980
  if (ranged) {
897
981
  const end = start - 1 + slice.length;
@@ -963,7 +1047,7 @@ function spillIfLarge(full, label = "output", budget = INLINE_BUDGET) {
963
1047
  const head = Math.floor(budget * HEAD_FRACTION);
964
1048
  const tail = budget - head;
965
1049
  const preview = full.slice(0, head) + "\n\u2026\n" + full.slice(-tail);
966
- const notice = path ? `[${label} truncated: ${full.length} bytes. Full output at ${path} \u2014 read it with read_file offset/limit to see the elided middle.]` : `[${label} truncated to ${budget} bytes; spill to disk failed, middle is lost.]`;
1050
+ const notice = path ? `[This ${label} was long (${full.length} bytes), so I'm showing the start and end. The full text is saved at ${path} \u2014 read it with read_file offset/limit to see the middle.]` : `[This ${label} was too long to show in full, and I couldn't save the rest to disk, so the middle is missing.]`;
967
1051
  return `${preview}
968
1052
  ${notice}`;
969
1053
  }
@@ -992,6 +1076,17 @@ var init_spill = __esm({
992
1076
 
993
1077
  // src/tools/run_bash.ts
994
1078
  import { execa } from "execa";
1079
+ function killTree(pid, isWin) {
1080
+ if (!pid) return;
1081
+ try {
1082
+ if (isWin) {
1083
+ execa("taskkill", ["/pid", String(pid), "/T", "/F"], { reject: false });
1084
+ } else {
1085
+ process.kill(-pid, "SIGKILL");
1086
+ }
1087
+ } catch {
1088
+ }
1089
+ }
995
1090
  var run_bash;
996
1091
  var init_run_bash = __esm({
997
1092
  "src/tools/run_bash.ts"() {
@@ -1008,27 +1103,44 @@ var init_run_bash = __esm({
1008
1103
  },
1009
1104
  required: ["command"]
1010
1105
  },
1011
- handler: async ({ command, timeout_ms }) => {
1106
+ handler: async ({ command, timeout_ms }, ctx) => {
1107
+ const isWin = process.platform === "win32";
1108
+ const shell = isWin ? "cmd" : "bash";
1109
+ const shellArgs = isWin ? ["/c", command] : ["-c", command];
1110
+ const timeout = timeout_ms ?? 12e4;
1111
+ const child = execa(shell, shellArgs, {
1112
+ reject: false,
1113
+ all: true,
1114
+ detached: !isWin
1115
+ // POSIX: new process group so killTree(-pid) hits the whole tree
1116
+ });
1117
+ let timedOut = false;
1118
+ let aborted = false;
1119
+ const timer = setTimeout(() => {
1120
+ timedOut = true;
1121
+ killTree(child.pid, isWin);
1122
+ }, timeout);
1123
+ const onAbort = () => {
1124
+ aborted = true;
1125
+ killTree(child.pid, isWin);
1126
+ };
1127
+ ctx?.signal?.addEventListener("abort", onAbort, { once: true });
1012
1128
  try {
1013
- const isWin = process.platform === "win32";
1014
- const shell = isWin ? "cmd" : "bash";
1015
- const shellArgs = isWin ? ["/c", command] : ["-c", command];
1016
- const { stdout, stderr, exitCode } = await execa(shell, shellArgs, {
1017
- timeout: timeout_ms ?? 12e4,
1018
- reject: false,
1019
- all: false
1020
- });
1021
- const out = [stdout, stderr].filter(Boolean).join("\n");
1022
- const is_error = exitCode !== 0;
1129
+ const { all, exitCode } = await child;
1130
+ const out = all ?? "";
1131
+ const is_error = aborted || timedOut || exitCode !== 0;
1132
+ const note = timedOut ? `
1133
+ [Timed out after ${timeout}ms, so I stopped the command and everything it started. Raise timeout_ms for long builds or test suites.]` : aborted ? `
1134
+ [Cancelled \u2014 I stopped the command and everything it started.]` : "";
1023
1135
  const body = out || (is_error ? `(no output)` : "");
1024
1136
  const content = `${spillIfLarge(body, "command output")}
1025
- [exit ${exitCode}]`;
1026
- return {
1027
- content,
1028
- is_error
1029
- };
1137
+ [exit ${exitCode ?? "killed"}]${note}`;
1138
+ return { content, is_error };
1030
1139
  } catch (err) {
1031
1140
  return { content: err instanceof Error ? err.message : String(err), is_error: true };
1141
+ } finally {
1142
+ clearTimeout(timer);
1143
+ ctx?.signal?.removeEventListener("abort", onAbort);
1032
1144
  }
1033
1145
  }
1034
1146
  };
@@ -1128,8 +1240,9 @@ var init_grep = __esm({
1128
1240
  const lines = (res.stdout ?? "").split("\n").slice(0, limit);
1129
1241
  const out = lines.join("\n");
1130
1242
  const code = res.exitCode ?? 0;
1131
- if (!out && code === 1) return { content: "No matches." };
1132
- return { content: out || res.stderr || "No matches.", is_error: code > 1 };
1243
+ const noMatch = `Nothing matched "${pattern}". Try a looser pattern, case_insensitive, or a wider path.`;
1244
+ if (!out && code === 1) return { content: noMatch };
1245
+ return { content: out || res.stderr || noMatch, is_error: code > 1 };
1133
1246
  } catch (err) {
1134
1247
  return { content: err instanceof Error ? err.message : String(err), is_error: true };
1135
1248
  }
@@ -1204,7 +1317,7 @@ var init_glob = __esm({
1204
1317
  res = await tryFind();
1205
1318
  }
1206
1319
  const all = (res.stdout ?? "").split("\n").filter(Boolean);
1207
- if (all.length === 0) return { content: "No files matched." };
1320
+ if (all.length === 0) return { content: `Nothing matched "${pattern}". Try a broader pattern or a different path.` };
1208
1321
  const lines = byMtimeDesc(all).slice(0, limit);
1209
1322
  return { content: lines.join("\n") };
1210
1323
  } catch (err) {
@@ -1215,6 +1328,70 @@ var init_glob = __esm({
1215
1328
  }
1216
1329
  });
1217
1330
 
1331
+ // src/tools/write_todos.ts
1332
+ function render(todos) {
1333
+ const done = todos.filter((t) => t.status === "completed").length;
1334
+ const header = `Todos (${done}/${todos.length} done):`;
1335
+ const lines = todos.map((t) => `${MARK[t.status]} ${t.content}`);
1336
+ return [header, ...lines].join("\n");
1337
+ }
1338
+ var STATUSES, MARK, write_todos;
1339
+ var init_write_todos = __esm({
1340
+ "src/tools/write_todos.ts"() {
1341
+ "use strict";
1342
+ STATUSES = ["pending", "in_progress", "completed"];
1343
+ MARK = {
1344
+ completed: "[x]",
1345
+ in_progress: "[~]",
1346
+ pending: "[ ]"
1347
+ };
1348
+ write_todos = {
1349
+ name: "write_todos",
1350
+ description: "Create and maintain a task list for the current session, shown to the user as a live checklist. Pass the FULL list every call \u2014 it replaces the previous one (all or nothing), so include finished items too. Each todo is {content, status} where status is pending | in_progress | completed. Keep exactly one item in_progress at a time and mark it completed before starting the next. Use for multi-step work (features, refactors, multi-file changes) so the user can see what is done, in progress, and left. Skip it for single trivial actions.",
1351
+ input_schema: {
1352
+ type: "object",
1353
+ properties: {
1354
+ todos: {
1355
+ type: "array",
1356
+ description: "The full, ordered task list. Replaces the prior list entirely.",
1357
+ items: {
1358
+ type: "object",
1359
+ properties: {
1360
+ content: { type: "string", description: 'Short task description (imperative, e.g. "Add webfetch tool").' },
1361
+ status: { type: "string", enum: STATUSES, description: "pending | in_progress | completed" }
1362
+ },
1363
+ required: ["content", "status"]
1364
+ }
1365
+ }
1366
+ },
1367
+ required: ["todos"]
1368
+ },
1369
+ handler: ({ todos }) => {
1370
+ if (!Array.isArray(todos)) {
1371
+ return { content: "I need a todos array here \u2014 pass the full list of tasks.", is_error: true };
1372
+ }
1373
+ if (todos.length === 0) {
1374
+ return { content: "The todo list is empty \u2014 give me at least one task.", is_error: true };
1375
+ }
1376
+ for (let i = 0; i < todos.length; i++) {
1377
+ const t = todos[i];
1378
+ if (!t || typeof t.content !== "string" || t.content.trim() === "") {
1379
+ return { content: `Task #${i + 1} needs some text \u2014 its content can't be empty.`, is_error: true };
1380
+ }
1381
+ if (!STATUSES.includes(t.status)) {
1382
+ return { content: `Task #${i + 1} has an unknown status. Use one of: ${STATUSES.join(", ")}.`, is_error: true };
1383
+ }
1384
+ }
1385
+ const active2 = todos.filter((t) => t.status === "in_progress").length;
1386
+ if (active2 > 1) {
1387
+ return { content: `Keep just one task in progress at a time \u2014 I see ${active2}. Finish one before starting the next.`, is_error: true };
1388
+ }
1389
+ return { content: render(todos) };
1390
+ }
1391
+ };
1392
+ }
1393
+ });
1394
+
1218
1395
  // src/tools/registry.ts
1219
1396
  function getTool(name) {
1220
1397
  return TOOLS.find((t) => t.name === name);
@@ -1243,13 +1420,15 @@ var init_registry = __esm({
1243
1420
  init_run_bash();
1244
1421
  init_grep();
1245
1422
  init_glob();
1423
+ init_write_todos();
1246
1424
  TOOLS = [
1247
1425
  edit_file,
1248
1426
  read_file,
1249
1427
  write_file,
1250
1428
  run_bash,
1251
1429
  grep,
1252
- glob
1430
+ glob,
1431
+ write_todos
1253
1432
  ];
1254
1433
  }
1255
1434
  });
@@ -1312,7 +1491,7 @@ function validateInput(schema, input) {
1312
1491
  const result = toZod(schema).safeParse(input ?? {});
1313
1492
  if (result.success) return null;
1314
1493
  const issues = result.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
1315
- return `Invalid arguments: ${issues}`;
1494
+ return `Some arguments didn't look right: ${issues}`;
1316
1495
  }
1317
1496
  var init_validate = __esm({
1318
1497
  "src/tools/validate.ts"() {
@@ -1451,6 +1630,14 @@ Ask in a numbered list. One round of questions per turn. Then wait.
1451
1630
  You have access to the following tools. Call them via the function-calling interface.
1452
1631
  ${toolLines}
1453
1632
 
1633
+ # Task list (write_todos)
1634
+ For multi-step work \u2014 a feature, a refactor, anything touching multiple files or more than ~3 steps \u2014 track it with write_todos so the user sees a live checklist of what is done, in progress, and left.
1635
+ - Create the list right after your plan, before the first real action. Each item is {content, status}: short imperative content, status pending | in_progress | completed.
1636
+ - Pass the FULL list every call; it replaces the previous one. Include already-finished items so nothing disappears.
1637
+ - Mark exactly ONE item in_progress at a time. Set it in_progress when you start it, completed the moment it is done, then move the next to in_progress. Never leave a finished task pending or two tasks in_progress.
1638
+ - Keep items outcome-sized, not tool-sized \u2014 "Add webfetch tool and wire it in", not "call edit_file". Aim for a handful, not twenty.
1639
+ - Do NOT use write_todos for trivial single-step work \u2014 a lone read, one edit, a quick search. It is overhead there.
1640
+
1454
1641
  # Loop semantics
1455
1642
  - When you need to act on the filesystem or run a command, emit a tool call.
1456
1643
  - After each tool result, decide: more tool calls, or a final plain-text answer.
@@ -1535,6 +1722,9 @@ function toOllamaMessages(history, system) {
1535
1722
  const texts = msg.content.filter((b) => b.type === "text");
1536
1723
  for (const tr of tool_results) {
1537
1724
  out.push({ role: "tool", content: tr.content, tool_call_id: tr.tool_use_id });
1725
+ if (tr.images && tr.images.length > 0) {
1726
+ out.push({ role: "user", content: "Image content from the previous tool result:", images: tr.images });
1727
+ }
1538
1728
  }
1539
1729
  if (texts.length > 0) {
1540
1730
  out.push({ role: "user", content: texts.map((t) => t.text).join("") });
@@ -1743,7 +1933,7 @@ function readGuard(name, input, seen) {
1743
1933
  if (seen.has(abs)) return null;
1744
1934
  if (name === "write_file" && !existsSync5(abs)) return null;
1745
1935
  const verb = name === "edit_file" ? "edit" : "overwrite";
1746
- return `Refusing to ${verb} ${p}: you have not read it this turn. Call read_file on ${p} first, then retry the ${name}.`;
1936
+ return `I won't ${verb} ${p} without seeing it first \u2014 I don't want to clobber something. Read it with read_file, then retry the ${name}.`;
1747
1937
  }
1748
1938
  function unwrapEnvelope(name, input) {
1749
1939
  if (!("arguments" in input)) return input;
@@ -1925,7 +2115,7 @@ async function* runAgent(opts) {
1925
2115
  const r2 = {
1926
2116
  type: "tool_result",
1927
2117
  tool_use_id: use.id,
1928
- content: `Unknown tool: ${use.name}`,
2118
+ content: `Unknown tool: ${use.name}. There's no tool by that name \u2014 check the spelling against the tools you were given.`,
1929
2119
  is_error: true
1930
2120
  };
1931
2121
  results.push(r2);
@@ -1951,7 +2141,7 @@ async function* runAgent(opts) {
1951
2141
  const r2 = {
1952
2142
  type: "tool_result",
1953
2143
  tool_use_id: use.id,
1954
- content: `Permission denied for ${use.name}.`,
2144
+ content: `Permission denied \u2014 the user chose not to run ${use.name}. Try a different approach, or ask them what they'd prefer.`,
1955
2145
  is_error: true
1956
2146
  };
1957
2147
  results.push(r2);
@@ -1977,12 +2167,13 @@ async function* runAgent(opts) {
1977
2167
  }
1978
2168
  let r;
1979
2169
  try {
1980
- const out = await tool.handler(use.input);
2170
+ const out = await tool.handler(use.input, { signal });
1981
2171
  r = {
1982
2172
  type: "tool_result",
1983
2173
  tool_use_id: use.id,
1984
2174
  content: out.content,
1985
- is_error: out.is_error
2175
+ is_error: out.is_error,
2176
+ ...out.images && out.images.length > 0 ? { images: out.images } : {}
1986
2177
  };
1987
2178
  } catch (err) {
1988
2179
  r = {
@@ -2252,7 +2443,7 @@ var init_run = __esm({
2252
2443
  });
2253
2444
 
2254
2445
  // src/cli.tsx
2255
- import { render } from "ink";
2446
+ import { render as render2 } from "ink";
2256
2447
  import { createElement } from "react";
2257
2448
 
2258
2449
  // src/ui/App.tsx
@@ -3542,7 +3733,8 @@ var TOOL_LABEL = {
3542
3733
  read_file: "Read",
3543
3734
  run_bash: "Bash",
3544
3735
  glob: "Glob",
3545
- grep: "Grep"
3736
+ grep: "Grep",
3737
+ write_todos: "Todos"
3546
3738
  };
3547
3739
  var EXT_LANG = {
3548
3740
  ts: "typescript",
@@ -3644,6 +3836,43 @@ function FileEditBlock({
3644
3836
  ] }) })
3645
3837
  ] });
3646
3838
  }
3839
+ function TodoBlock({ todos }) {
3840
+ const done = todos.filter((t) => t.status === "completed").length;
3841
+ const doing = todos.filter((t) => t.status === "in_progress").length;
3842
+ const glyph = { completed: "\u2714", in_progress: "\u25B6", pending: "\u25CB" };
3843
+ const color = { completed: "green", in_progress: "yellow", pending: "gray" };
3844
+ return /* @__PURE__ */ jsxs9(Box9, { flexDirection: "column", marginLeft: 2, children: [
3845
+ /* @__PURE__ */ jsxs9(Box9, { children: [
3846
+ /* @__PURE__ */ jsx9(Text9, { color: "green", children: "\u25CF " }),
3847
+ /* @__PURE__ */ jsx9(Text9, { color: "white", children: "Todos " }),
3848
+ /* @__PURE__ */ jsxs9(Text9, { dimColor: true, children: [
3849
+ "(",
3850
+ done,
3851
+ "/",
3852
+ todos.length,
3853
+ " done",
3854
+ doing > 0 ? `, ${doing} in progress` : "",
3855
+ ")"
3856
+ ] })
3857
+ ] }),
3858
+ todos.map((t, i) => /* @__PURE__ */ jsxs9(Box9, { marginLeft: 4, children: [
3859
+ /* @__PURE__ */ jsxs9(Text9, { color: color[t.status], children: [
3860
+ glyph[t.status],
3861
+ " "
3862
+ ] }),
3863
+ /* @__PURE__ */ jsx9(
3864
+ Text9,
3865
+ {
3866
+ color: t.status === "in_progress" ? "yellow" : void 0,
3867
+ dimColor: t.status !== "in_progress",
3868
+ strikethrough: t.status === "completed",
3869
+ bold: t.status === "in_progress",
3870
+ children: t.content
3871
+ }
3872
+ )
3873
+ ] }, i))
3874
+ ] });
3875
+ }
3647
3876
  function toolHeader(use) {
3648
3877
  const label = TOOL_LABEL[use.name] ?? use.name;
3649
3878
  const input = use.input ?? {};
@@ -3723,6 +3952,10 @@ function ToolResultBlock({ result, toolName }) {
3723
3952
  ] });
3724
3953
  }
3725
3954
  function ToolUseLine({ use, result }) {
3955
+ if (use.name === "write_todos" && !result?.is_error) {
3956
+ const todos = use.input.todos;
3957
+ if (Array.isArray(todos) && todos.length > 0) return /* @__PURE__ */ jsx9(TodoBlock, { todos });
3958
+ }
3726
3959
  if (use.name === "write_file" && !result?.is_error) {
3727
3960
  const input = use.input;
3728
3961
  const content = input.content ?? "";
@@ -3732,14 +3965,16 @@ function ToolUseLine({ use, result }) {
3732
3965
  }
3733
3966
  if (use.name === "edit_file" && !result?.is_error) {
3734
3967
  const input = use.input;
3735
- const oldS = input.old_str ?? "";
3736
- const newS = input.new_str ?? "";
3737
- const added = countLines(newS);
3738
- const removed = countLines(oldS);
3739
- const preview = [
3740
- ...oldS.split("\n").map((t) => ({ sign: "-", text: t })),
3741
- ...newS.split("\n").map((t) => ({ sign: "+", text: t }))
3742
- ];
3968
+ const pairs = Array.isArray(input.edits) && input.edits.length > 0 ? input.edits.map((e) => ({ oldS: e.old_str ?? "", newS: e.new_str ?? "" })) : [{ oldS: input.old_str ?? "", newS: input.new_str ?? "" }];
3969
+ let added = 0;
3970
+ let removed = 0;
3971
+ const preview = [];
3972
+ for (const { oldS, newS } of pairs) {
3973
+ added += countLines(newS);
3974
+ removed += countLines(oldS);
3975
+ preview.push(...oldS.split("\n").map((t) => ({ sign: "-", text: t })));
3976
+ preview.push(...newS.split("\n").map((t) => ({ sign: "+", text: t })));
3977
+ }
3743
3978
  return /* @__PURE__ */ jsx9(FileEditBlock, { label: "Update", path: input.path ?? "", added, removed, previewLines: preview });
3744
3979
  }
3745
3980
  const { label, arg } = toolHeader(use);
@@ -5078,5 +5313,5 @@ if (cmd === "version" || cmd === "--version" || cmd === "-v") {
5078
5313
  process.on("exit", () => {
5079
5314
  if (process.stdout.isTTY) process.stdout.write("\x1B]2;\x07");
5080
5315
  });
5081
- render(createElement(App));
5316
+ render2(createElement(App));
5082
5317
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "miii-agent",
3
- "version": "0.1.31",
3
+ "version": "0.1.33",
4
4
  "description": "Local AI coding agent for your terminal — an open-source, offline alternative to Claude Code, Cursor, and GitHub Copilot, powered by Ollama. Private by default, free forever.",
5
5
  "type": "module",
6
6
  "bin": {