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.
- package/dist/cli.js +298 -63
- 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(`
|
|
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(`
|
|
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
|
|
282
|
-
NOT_RUNNING = "Ollama
|
|
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 `
|
|
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(`
|
|
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(`
|
|
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(`
|
|
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("
|
|
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(`
|
|
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("
|
|
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(`
|
|
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
|
-
|
|
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"
|
|
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
|
|
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: `
|
|
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: `
|
|
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
|
-
|
|
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)
|
|
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
|
-
[
|
|
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}
|
|
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
|
|
1014
|
-
const
|
|
1015
|
-
const
|
|
1016
|
-
const
|
|
1017
|
-
|
|
1018
|
-
|
|
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
|
-
|
|
1132
|
-
|
|
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:
|
|
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 `
|
|
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 `
|
|
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
|
|
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
|
|
3736
|
-
|
|
3737
|
-
|
|
3738
|
-
const
|
|
3739
|
-
const
|
|
3740
|
-
|
|
3741
|
-
|
|
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
|
-
|
|
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.
|
|
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": {
|