miii-agent 0.1.31 → 0.1.32

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 +151 -33
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -790,6 +790,43 @@ function nearMiss(src, old_str) {
790
790
  Closest text in file (lines ${from + 1}-${to}):
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: `old_str not unique \u2014 add surrounding context to disambiguate.` };
798
+ }
799
+ return [first, first + old_str.length];
800
+ }
801
+ const fuzzy = fuzzyRange(src, old_str);
802
+ if (fuzzy) return fuzzy;
803
+ return { error: `old_str not found.${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: `edits[${i}] must have string old_str and new_str.` };
811
+ }
812
+ if (old_str === "") return { error: `edits[${i}].old_str is empty.` };
813
+ if (old_str === new_str) return { error: `edits[${i}] old_str and new_str are identical \u2014 nothing to change.` };
814
+ const r = locate(src, old_str);
815
+ if (!Array.isArray(r)) return { error: `edits[${i}]: ${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: `edits overlap in the file \u2014 split them into separate calls or widen the context.` };
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,19 +835,42 @@ 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: `edit_file needs old_str and new_str (or an edits[] array) for ${path}.`, is_error: true };
873
+ }
814
874
  if (old_str === new_str) {
815
875
  return {
816
876
  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.`,
@@ -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,6 +949,19 @@ 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 too large to attach (${buf.length} bytes > ${MAX_IMAGE_BYTES}). Resize it first.`,
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
966
  return { content: `${path} looks binary (${buf.length} bytes); not reading as text.`, is_error: true };
883
967
  }
@@ -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 \u2014 process tree killed]` : aborted ? `
1134
+ [aborted \u2014 process tree killed]` : "";
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
  };
@@ -1535,6 +1647,9 @@ function toOllamaMessages(history, system) {
1535
1647
  const texts = msg.content.filter((b) => b.type === "text");
1536
1648
  for (const tr of tool_results) {
1537
1649
  out.push({ role: "tool", content: tr.content, tool_call_id: tr.tool_use_id });
1650
+ if (tr.images && tr.images.length > 0) {
1651
+ out.push({ role: "user", content: "Image content from the previous tool result:", images: tr.images });
1652
+ }
1538
1653
  }
1539
1654
  if (texts.length > 0) {
1540
1655
  out.push({ role: "user", content: texts.map((t) => t.text).join("") });
@@ -1977,12 +2092,13 @@ async function* runAgent(opts) {
1977
2092
  }
1978
2093
  let r;
1979
2094
  try {
1980
- const out = await tool.handler(use.input);
2095
+ const out = await tool.handler(use.input, { signal });
1981
2096
  r = {
1982
2097
  type: "tool_result",
1983
2098
  tool_use_id: use.id,
1984
2099
  content: out.content,
1985
- is_error: out.is_error
2100
+ is_error: out.is_error,
2101
+ ...out.images && out.images.length > 0 ? { images: out.images } : {}
1986
2102
  };
1987
2103
  } catch (err) {
1988
2104
  r = {
@@ -3732,14 +3848,16 @@ function ToolUseLine({ use, result }) {
3732
3848
  }
3733
3849
  if (use.name === "edit_file" && !result?.is_error) {
3734
3850
  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
- ];
3851
+ 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 ?? "" }];
3852
+ let added = 0;
3853
+ let removed = 0;
3854
+ const preview = [];
3855
+ for (const { oldS, newS } of pairs) {
3856
+ added += countLines(newS);
3857
+ removed += countLines(oldS);
3858
+ preview.push(...oldS.split("\n").map((t) => ({ sign: "-", text: t })));
3859
+ preview.push(...newS.split("\n").map((t) => ({ sign: "+", text: t })));
3860
+ }
3743
3861
  return /* @__PURE__ */ jsx9(FileEditBlock, { label: "Update", path: input.path ?? "", added, removed, previewLines: preview });
3744
3862
  }
3745
3863
  const { label, arg } = toolHeader(use);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "miii-agent",
3
- "version": "0.1.31",
3
+ "version": "0.1.32",
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": {