miii-agent 0.1.10 → 0.1.12

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 +27 -1
  2. package/dist/cli.js +346 -100
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -92,6 +92,7 @@ Once inside the TUI, just type naturally:
92
92
  | `/models` | Switch active Ollama model |
93
93
  | `/clear` | Reset conversation history |
94
94
  | `Esc` | Stop current generation or tool run |
95
+ | `Ctrl+O` | Toggle full tool output view |
95
96
  | `Ctrl+C` | Quit |
96
97
 
97
98
  ---
@@ -124,7 +125,7 @@ miii ships with a built-in tool suite the agent can invoke autonomously:
124
125
  |------|-------------|
125
126
  | `read_file` | Read any file in your workspace |
126
127
  | `write_file` | Create new files |
127
- | `edit_file` | Precise string-level edits (no rewrites) |
128
+ | `edit_file` | Precise string-level edits with whitespace tolerance (no rewrites) |
128
129
  | `glob` | Pattern-match files across the project |
129
130
  | `grep` | Regex search across files |
130
131
  | `run_bash` | Execute shell commands |
@@ -133,6 +134,24 @@ Every sensitive operation is gated by a permission system — you approve what t
133
134
 
134
135
  ---
135
136
 
137
+ ## Lossless output spill
138
+
139
+ Big tool outputs used to get truncated — a 50K-line test log chopped to 32K, the middle gone, the model guessing at what it missed. miii doesn't truncate. It **spills**.
140
+
141
+ When a tool result exceeds the inline budget (~10K bytes), the full output is written to `~/.miii/output/<id>.txt`. Only a head + tail **preview** is inlined into the conversation, followed by a pointer:
142
+
143
+ ```
144
+ [command output truncated: 5184 lines / 412900 bytes.
145
+ Full output at ~/.miii/output/9f3a1c.txt — read it with
146
+ read_file offset/limit to see the elided middle.]
147
+ ```
148
+
149
+ The head shows where output started; the tail catches the errors and summaries that live at the bottom. If the model needs the elided middle, it pages through it with `read_file` ranged reads — nothing is ever lost. The inline budget becomes "how much to show," not "how much exists."
150
+
151
+ Spill files are confined to the app-owned `~/.miii/output` dir and garbage-collected after 24h. If the spill write fails (e.g. read-only home), miii falls back to a lossy head+tail and says so explicitly, so the context window is never blown.
152
+
153
+ ---
154
+
136
155
  ## Checking your setup
137
156
 
138
157
  miii is model-agnostic — but not every local model can actually drive an agent. A model that can't emit clean tool calls will chat at you instead of editing files. `miii doctor` tells you which of *your* installed models are up to the job, before you waste time wondering why nothing happens.
@@ -195,6 +214,10 @@ graph TD
195
214
  ReadFile -.-> Confine["Path Confinement\n(tools/paths.ts)"]
196
215
  WriteFile -.-> Confine
197
216
  EditFile -.-> Confine
217
+ RunBash -->|"large output"| SpillMod["Output Spill\n(tools/spill.ts)"]
218
+ Grep -->|"large output"| SpillMod
219
+ Glob -->|"large output"| SpillMod
220
+ SpillMod -.->|"head+tail preview\n+ read pointer"| ToolRegistry
198
221
  end
199
222
 
200
223
  Adapter -->|"HTTP streaming"| Ollama["Ollama\n(local LLM server)"]
@@ -206,10 +229,13 @@ graph TD
206
229
  subgraph Storage ["Local Storage"]
207
230
  Config["~/.miii/config.json\n(model, host, effort)"]
208
231
  Rules["~/.miii/permissions.json\n(saved allow rules)"]
232
+ Spill["~/.miii/output/\n(spilled tool output)"]
209
233
  end
210
234
 
211
235
  App -.->|"reads"| Config
212
236
  Policy -.->|"reads / persists 'always'"| Rules
237
+ SpillMod -.->|"writes full output"| Spill
238
+ ReadFile -.->|"pages elided middle\n(offset/limit)"| Spill
213
239
  ```
214
240
 
215
241
  ---
package/dist/cli.js CHANGED
@@ -145,57 +145,176 @@ var init_client = __esm({
145
145
  });
146
146
 
147
147
  // src/tools/paths.ts
148
- import { resolve, relative as relative2, isAbsolute, sep } from "path";
148
+ import { resolve, relative as relative2, isAbsolute, sep, join as join4 } from "path";
149
+ import { homedir as homedir3 } from "os";
150
+ function isUnder(parent, child) {
151
+ const rel = relative2(parent, child);
152
+ return rel === "" || !rel.startsWith(".." + sep) && rel !== ".." && !isAbsolute(rel);
153
+ }
149
154
  function confinePath(p) {
150
155
  if (typeof p !== "string" || p.length === 0) {
151
156
  throw new Error("Path is required.");
152
157
  }
153
158
  const root = process.cwd();
154
159
  const abs = resolve(root, p);
155
- const rel = relative2(root, abs);
156
- if (rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel)) {
157
- throw new Error(`Path "${p}" is outside the working directory (${root}). Access denied.`);
160
+ if (isUnder(root, abs) || isUnder(SPILL_DIR, abs)) {
161
+ return abs;
158
162
  }
159
- return abs;
163
+ throw new Error(`Path "${p}" is outside the working directory (${root}). Access denied.`);
160
164
  }
165
+ var SPILL_DIR;
161
166
  var init_paths = __esm({
162
167
  "src/tools/paths.ts"() {
163
168
  "use strict";
169
+ SPILL_DIR = resolve(join4(homedir3(), ".miii", "output"));
170
+ }
171
+ });
172
+
173
+ // src/tools/verifyHint.ts
174
+ function verifyHint(path) {
175
+ const ext = path.slice(path.lastIndexOf(".") + 1).toLowerCase();
176
+ const cmds = {
177
+ ts: `npx tsc --noEmit`,
178
+ tsx: `npx tsc --noEmit`,
179
+ js: `node --check ${path}`,
180
+ jsx: `node --check ${path}`,
181
+ mjs: `node --check ${path}`,
182
+ cjs: `node --check ${path}`,
183
+ py: `python -m py_compile ${path}`,
184
+ go: `go build ./...`,
185
+ rs: `cargo check`,
186
+ rb: `ruby -c ${path}`,
187
+ php: `php -l ${path}`,
188
+ sh: `bash -n ${path}`,
189
+ bash: `bash -n ${path}`,
190
+ c: `gcc -fsyntax-only ${path}`,
191
+ h: `gcc -fsyntax-only ${path}`,
192
+ cpp: `g++ -fsyntax-only ${path}`,
193
+ cc: `g++ -fsyntax-only ${path}`,
194
+ java: `javac -d /tmp ${path}`
195
+ };
196
+ const cmd2 = cmds[ext];
197
+ if (!cmd2) return "";
198
+ return ` Now verify via run_bash: ${cmd2} \u2014 fix any errors it reports before continuing.`;
199
+ }
200
+ var init_verifyHint = __esm({
201
+ "src/tools/verifyHint.ts"() {
202
+ "use strict";
164
203
  }
165
204
  });
166
205
 
167
206
  // src/tools/edit_file.ts
168
207
  import { readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
208
+ function similarity(a, b) {
209
+ const x = a.trim();
210
+ const y = b.trim();
211
+ if (!x && !y) return 1;
212
+ const len = Math.max(x.length, y.length);
213
+ if (len === 0) return 0;
214
+ let same = 0;
215
+ for (let i = 0; i < Math.min(x.length, y.length); i++) if (x[i] === y[i]) same++;
216
+ return same / len;
217
+ }
218
+ function fuzzyRange(src, old_str) {
219
+ const srcLines = src.split("\n");
220
+ const oldLines = old_str.split("\n");
221
+ const norm = (l) => l.trim();
222
+ const oldNorm = oldLines.map(norm);
223
+ const offsets = new Array(srcLines.length);
224
+ let acc = 0;
225
+ for (let i = 0; i < srcLines.length; i++) {
226
+ offsets[i] = acc;
227
+ acc += srcLines[i].length + 1;
228
+ }
229
+ const matches2 = [];
230
+ const window = oldLines.length;
231
+ for (let i = 0; i + window <= srcLines.length; i++) {
232
+ let ok = true;
233
+ for (let j = 0; j < window; j++) {
234
+ if (norm(srcLines[i + j]) !== oldNorm[j]) {
235
+ ok = false;
236
+ break;
237
+ }
238
+ }
239
+ if (!ok) continue;
240
+ const start = offsets[i];
241
+ const last = i + window - 1;
242
+ const end = offsets[last] + srcLines[last].length;
243
+ matches2.push([start, end]);
244
+ }
245
+ return matches2.length === 1 ? matches2[0] : null;
246
+ }
247
+ function nearMiss(src, old_str) {
248
+ const srcLines = src.split("\n");
249
+ const needle = old_str.split("\n").find((l) => l.trim()) ?? old_str;
250
+ let bestIdx = -1;
251
+ let bestScore = 0;
252
+ for (let i = 0; i < srcLines.length; i++) {
253
+ const s = similarity(srcLines[i], needle);
254
+ if (s > bestScore) {
255
+ bestScore = s;
256
+ bestIdx = i;
257
+ }
258
+ }
259
+ if (bestIdx === -1 || bestScore < 0.4) return "";
260
+ const from = Math.max(0, bestIdx - 3);
261
+ const to = Math.min(srcLines.length, bestIdx + 4);
262
+ const width = String(to).length;
263
+ const ctx = srcLines.slice(from, to).map((l, i) => `${String(from + i + 1).padStart(width, " ")} ${l}`).join("\n");
264
+ return `
265
+ Closest text in file (lines ${from + 1}-${to}):
266
+ ${ctx}`;
267
+ }
169
268
  var edit_file;
170
269
  var init_edit_file = __esm({
171
270
  "src/tools/edit_file.ts"() {
172
271
  "use strict";
173
272
  init_paths();
273
+ init_verifyHint();
174
274
  edit_file = {
175
275
  name: "edit_file",
176
- description: "Replace an exact string in a file. old_str must be unique.",
276
+ 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.",
177
277
  input_schema: {
178
278
  type: "object",
179
279
  properties: {
180
280
  path: { type: "string", description: "File path" },
181
- old_str: { type: "string", description: "Exact text to replace" },
182
- new_str: { type: "string", description: "Replacement text" }
281
+ old_str: { type: "string", description: "Exact text to replace (whitespace-sensitive)" },
282
+ new_str: { type: "string", description: "Replacement text" },
283
+ replace_all: { type: "boolean", description: "Replace every occurrence instead of requiring uniqueness" }
183
284
  },
184
285
  required: ["path", "old_str", "new_str"]
185
286
  },
186
- handler: ({ path, old_str, new_str }) => {
287
+ handler: ({ path, old_str, new_str, replace_all }) => {
187
288
  try {
289
+ if (old_str === new_str) {
290
+ return { content: `old_str and new_str are identical \u2014 nothing to change in ${path}.`, is_error: true };
291
+ }
188
292
  const abs = confinePath(path);
189
293
  const src = readFileSync3(abs, "utf-8");
190
294
  const first = src.indexOf(old_str);
191
295
  if (first === -1) {
192
- return { content: `old_str not found in ${path}`, is_error: true };
296
+ if (replace_all !== true) {
297
+ const fuzzy = fuzzyRange(src, old_str);
298
+ if (fuzzy) {
299
+ const [s, e] = fuzzy;
300
+ const out2 = src.slice(0, s) + new_str + src.slice(e);
301
+ writeFileSync3(abs, out2, "utf-8");
302
+ return { content: `Edited ${path} (whitespace-tolerant match).${verifyHint(path)}` };
303
+ }
304
+ }
305
+ return { content: `old_str not found in ${path}.${nearMiss(src, old_str)}`, is_error: true };
193
306
  }
194
- if (src.indexOf(old_str, first + 1) !== -1) {
195
- return { content: `old_str not unique in ${path}`, is_error: true };
307
+ const all = replace_all === true;
308
+ if (!all && src.indexOf(old_str, first + 1) !== -1) {
309
+ return {
310
+ content: `old_str not unique in ${path}. Add surrounding context to disambiguate, or set replace_all.`,
311
+ is_error: true
312
+ };
196
313
  }
197
- writeFileSync3(abs, src.slice(0, first) + new_str + src.slice(first + old_str.length), "utf-8");
198
- return { content: `Edited ${path}` };
314
+ const out = all ? src.split(old_str).join(new_str) : src.slice(0, first) + new_str + src.slice(first + old_str.length);
315
+ const n = all ? src.split(old_str).length - 1 : 1;
316
+ writeFileSync3(abs, out, "utf-8");
317
+ return { content: `Edited ${path}${all ? ` (${n} occurrences)` : ""}.${verifyHint(path)}` };
199
318
  } catch (err) {
200
319
  return { content: err instanceof Error ? err.message : String(err), is_error: true };
201
320
  }
@@ -206,6 +325,10 @@ var init_edit_file = __esm({
206
325
 
207
326
  // src/tools/read_file.ts
208
327
  import { readFileSync as readFileSync4 } from "fs";
328
+ function numbered(lines, start) {
329
+ const width = String(start + lines.length - 1).length;
330
+ return lines.map((l, i) => `${String(start + i).padStart(width, " ")} ${l}`).join("\n");
331
+ }
209
332
  var read_file;
210
333
  var init_read_file = __esm({
211
334
  "src/tools/read_file.ts"() {
@@ -213,21 +336,40 @@ var init_read_file = __esm({
213
336
  init_paths();
214
337
  read_file = {
215
338
  name: "read_file",
216
- description: "Read entire file contents as UTF-8 text.",
339
+ 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.",
217
340
  input_schema: {
218
341
  type: "object",
219
342
  properties: {
220
- path: { type: "string", description: "File path" }
343
+ path: { type: "string", description: "File path" },
344
+ offset: { type: "number", description: "1-based line to start from (default 1)" },
345
+ limit: { type: "number", description: "Max lines to return (default all / capped)" }
221
346
  },
222
347
  required: ["path"]
223
348
  },
224
- handler: ({ path }) => {
349
+ handler: ({ path, offset, limit }) => {
225
350
  try {
226
- const MAX = 2e5;
227
- const raw = readFileSync4(confinePath(path), "utf-8");
228
- const truncated = raw.length > MAX;
229
- const body = truncated ? raw.slice(0, MAX) + `
230
- [truncated: ${raw.length - MAX} more chars]` : raw;
351
+ const MAX_CHARS = 2e5;
352
+ const buf = readFileSync4(confinePath(path));
353
+ if (buf.subarray(0, 8e3).includes(0)) {
354
+ return { content: `${path} looks binary (${buf.length} bytes); not reading as text.`, is_error: true };
355
+ }
356
+ const raw = buf.toString("utf-8").replace(/\r\n/g, "\n");
357
+ const allLines = raw.split("\n");
358
+ const total = allLines.length;
359
+ const start = Math.max(1, Math.floor(offset ?? 1));
360
+ const ranged = offset != null || limit != null;
361
+ const count = limit != null ? Math.max(0, Math.floor(limit)) : total;
362
+ const slice = allLines.slice(start - 1, start - 1 + count);
363
+ let body = numbered(slice, start);
364
+ if (body.length > MAX_CHARS) {
365
+ body = body.slice(0, MAX_CHARS) + `
366
+ [truncated: output exceeded ${MAX_CHARS} chars \u2014 use offset/limit]`;
367
+ }
368
+ if (ranged) {
369
+ const end = start - 1 + slice.length;
370
+ body += `
371
+ [showing lines ${start}-${end} of ${total}]`;
372
+ }
231
373
  return { content: body };
232
374
  } catch (err) {
233
375
  return { content: err instanceof Error ? err.message : String(err), is_error: true };
@@ -245,6 +387,7 @@ var init_write_file = __esm({
245
387
  "src/tools/write_file.ts"() {
246
388
  "use strict";
247
389
  init_paths();
390
+ init_verifyHint();
248
391
  write_file = {
249
392
  name: "write_file",
250
393
  description: "Create or overwrite a file with the given content. Parent dirs auto-created.",
@@ -261,7 +404,7 @@ var init_write_file = __esm({
261
404
  const abs = confinePath(path);
262
405
  mkdirSync3(dirname(abs), { recursive: true });
263
406
  writeFileSync4(abs, content, "utf-8");
264
- return { content: `Wrote ${path} (${content.length} bytes)` };
407
+ return { content: `Wrote ${path} (${content.length} bytes).${verifyHint(path)}` };
265
408
  } catch (err) {
266
409
  return { content: err instanceof Error ? err.message : String(err), is_error: true };
267
410
  }
@@ -270,12 +413,63 @@ var init_write_file = __esm({
270
413
  }
271
414
  });
272
415
 
416
+ // src/tools/spill.ts
417
+ import { writeFileSync as writeFileSync5, mkdirSync as mkdirSync4, rmSync as rmSync2, readdirSync as readdirSync3, statSync } from "fs";
418
+ import { join as join5 } from "path";
419
+ import { homedir as homedir4 } from "os";
420
+ import { randomBytes } from "crypto";
421
+ function ensureDir() {
422
+ mkdirSync4(OUTPUT_DIR, { recursive: true });
423
+ return OUTPUT_DIR;
424
+ }
425
+ function spillIfLarge(full, label = "output", budget = INLINE_BUDGET) {
426
+ if (full.length <= budget) return full;
427
+ const id = randomBytes(6).toString("hex");
428
+ const file = join5(ensureDir(), `${id}.txt`);
429
+ let path = file;
430
+ try {
431
+ writeFileSync5(file, full, "utf-8");
432
+ } catch {
433
+ path = "";
434
+ }
435
+ const head = Math.floor(budget * HEAD_FRACTION);
436
+ const tail = budget - head;
437
+ const totalLines = full.split("\n").length;
438
+ const preview = full.slice(0, head) + "\n\u2026\n" + full.slice(-tail);
439
+ const notice = path ? `[${label} truncated: ${totalLines} lines / ${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.]`;
440
+ return `${preview}
441
+ ${notice}`;
442
+ }
443
+ function cleanupSpill(maxAgeMs = 24 * 60 * 60 * 1e3) {
444
+ try {
445
+ const now = Date.now();
446
+ for (const name of readdirSync3(OUTPUT_DIR)) {
447
+ const f = join5(OUTPUT_DIR, name);
448
+ try {
449
+ if (now - statSync(f).mtimeMs > maxAgeMs) rmSync2(f, { force: true });
450
+ } catch {
451
+ }
452
+ }
453
+ } catch {
454
+ }
455
+ }
456
+ var OUTPUT_DIR, INLINE_BUDGET, HEAD_FRACTION;
457
+ var init_spill = __esm({
458
+ "src/tools/spill.ts"() {
459
+ "use strict";
460
+ OUTPUT_DIR = join5(homedir4(), ".miii", "output");
461
+ INLINE_BUDGET = 1e4;
462
+ HEAD_FRACTION = 0.3;
463
+ }
464
+ });
465
+
273
466
  // src/tools/run_bash.ts
274
467
  import { execa } from "execa";
275
468
  var run_bash;
276
469
  var init_run_bash = __esm({
277
470
  "src/tools/run_bash.ts"() {
278
471
  "use strict";
472
+ init_spill();
279
473
  run_bash = {
280
474
  name: "run_bash",
281
475
  description: "Execute a shell command (bash on Unix, cmd on Windows). Returns stdout+stderr. Non-interactive only.",
@@ -300,10 +494,10 @@ var init_run_bash = __esm({
300
494
  const out = [stdout, stderr].filter(Boolean).join("\n");
301
495
  const is_error = exitCode !== 0;
302
496
  const body = out || (is_error ? `(no output)` : "");
303
- const content = `${body}
497
+ const content = `${spillIfLarge(body, "command output")}
304
498
  [exit ${exitCode}]`;
305
499
  return {
306
- content: content.slice(0, 32e3),
500
+ content,
307
501
  is_error
308
502
  };
309
503
  } catch (err) {
@@ -329,7 +523,7 @@ var init_grep = __esm({
329
523
  pattern: { type: "string", description: "Regex pattern" },
330
524
  path: { type: "string", description: "Root path to search (default cwd)" },
331
525
  glob: { type: "string", description: 'File glob filter, e.g. "*.ts"' },
332
- case_insensitive: { type: "string", description: 'Set "true" for case-insensitive' },
526
+ case_insensitive: { type: "boolean", description: "Case-insensitive match" },
333
527
  max_results: { type: "number", description: "Max matching lines (default 200)" }
334
528
  },
335
529
  required: ["pattern"]
@@ -377,8 +571,25 @@ var init_grep = __esm({
377
571
 
378
572
  // src/tools/glob.ts
379
573
  import { execa as execa3 } from "execa";
380
- function globToFindName(glob2) {
381
- return glob2;
574
+ import { statSync as statSync2 } from "fs";
575
+ function byMtimeDesc(paths) {
576
+ const mtime = /* @__PURE__ */ new Map();
577
+ for (const p of paths) {
578
+ try {
579
+ mtime.set(p, statSync2(p).mtimeMs);
580
+ } catch {
581
+ mtime.set(p, 0);
582
+ }
583
+ }
584
+ return [...paths].sort((a, b) => (mtime.get(b) ?? 0) - (mtime.get(a) ?? 0));
585
+ }
586
+ function globToFindArgs(root, glob2) {
587
+ const stripped = glob2.replace(/^\*\*\//, "");
588
+ if (!stripped.includes("/")) {
589
+ return [root, "-type", "f", "-name", stripped];
590
+ }
591
+ const pathPat = "*/" + glob2.replace(/\*\*/g, "*");
592
+ return [root, "-type", "f", "-path", pathPat];
382
593
  }
383
594
  var glob;
384
595
  var init_glob = __esm({
@@ -403,13 +614,10 @@ var init_glob = __esm({
403
614
  reject: false,
404
615
  timeout: 2e4
405
616
  });
406
- const tryFind = () => {
407
- const name = globToFindName(pattern.replace(/^\*\*\//, ""));
408
- return execa3("find", [root, "-type", "f", "-name", name], {
409
- reject: false,
410
- timeout: 2e4
411
- });
412
- };
617
+ const tryFind = () => execa3("find", globToFindArgs(root, pattern), {
618
+ reject: false,
619
+ timeout: 2e4
620
+ });
413
621
  try {
414
622
  let res;
415
623
  try {
@@ -420,8 +628,9 @@ var init_glob = __esm({
420
628
  } catch {
421
629
  res = await tryFind();
422
630
  }
423
- const lines = (res.stdout ?? "").split("\n").filter(Boolean).slice(0, limit);
424
- if (lines.length === 0) return { content: "No files matched." };
631
+ const all = (res.stdout ?? "").split("\n").filter(Boolean);
632
+ if (all.length === 0) return { content: "No files matched." };
633
+ const lines = byMtimeDesc(all).slice(0, limit);
425
634
  return { content: lines.join("\n") };
426
635
  } catch (err) {
427
636
  return { content: err instanceof Error ? err.message : String(err), is_error: true };
@@ -576,10 +785,14 @@ ${toolLines}
576
785
  # Rules
577
786
  - Always read a file before updating it. Never edit, overwrite, or create-over a file you have not read first this turn.
578
787
  - Prefer editing existing files over creating new ones.
579
- - For edit_file, ensure old_str is unique within the target file.
788
+ - For edit_file, make old_str unique by including surrounding context, or set replace_all to change every occurrence.
580
789
  - Never invent file paths. Read, glob, or grep before editing.
581
790
  - No filler, no pleasantries, no apologies.
582
791
 
792
+ # Context discipline
793
+ - read_file returns line numbers and accepts offset/limit. For large files, grep or glob to the relevant region first, then read only that range with offset/limit. Do not read a whole large file when you need a few functions \u2014 it wastes the context window.
794
+ - Reference code by the line numbers read_file returns.
795
+
583
796
  # Testing and verification
584
797
  - Always test the code after a change. Run the project's tests (e.g. npm test, pytest, go test) or the relevant script via run_bash before declaring a task done.
585
798
  - If no test exists for the change, run the affected entry point via run_bash to verify it behaves correctly.
@@ -597,9 +810,9 @@ var init_system = __esm({
597
810
  });
598
811
 
599
812
  // src/permissions/policy.ts
600
- import { readFileSync as readFileSync5, writeFileSync as writeFileSync5, mkdirSync as mkdirSync4, existsSync as existsSync3, renameSync } from "fs";
601
- import { join as join4 } from "path";
602
- import { homedir as homedir3 } from "os";
813
+ import { readFileSync as readFileSync5, writeFileSync as writeFileSync6, mkdirSync as mkdirSync5, existsSync as existsSync3, renameSync } from "fs";
814
+ import { join as join6 } from "path";
815
+ import { homedir as homedir5 } from "os";
603
816
  function loadRules() {
604
817
  if (!existsSync3(RULES_PATH)) return [];
605
818
  try {
@@ -610,9 +823,9 @@ function loadRules() {
610
823
  }
611
824
  }
612
825
  function saveRules(rules) {
613
- mkdirSync4(RULES_DIR, { recursive: true });
826
+ mkdirSync5(RULES_DIR, { recursive: true });
614
827
  const tmp = RULES_PATH + ".tmp";
615
- writeFileSync5(tmp, JSON.stringify({ rules }, null, 2), "utf-8");
828
+ writeFileSync6(tmp, JSON.stringify({ rules }, null, 2), "utf-8");
616
829
  renameSync(tmp, RULES_PATH);
617
830
  }
618
831
  function addRule(tool, pattern) {
@@ -654,8 +867,8 @@ var RULES_DIR, RULES_PATH, ALWAYS_ALLOW;
654
867
  var init_policy = __esm({
655
868
  "src/permissions/policy.ts"() {
656
869
  "use strict";
657
- RULES_DIR = join4(homedir3(), ".miii");
658
- RULES_PATH = join4(RULES_DIR, "permissions.json");
870
+ RULES_DIR = join6(homedir5(), ".miii");
871
+ RULES_PATH = join6(RULES_DIR, "permissions.json");
659
872
  ALWAYS_ALLOW = /* @__PURE__ */ new Set(["read_file", "grep", "glob"]);
660
873
  }
661
874
  });
@@ -985,16 +1198,16 @@ var init_loop = __esm({
985
1198
  });
986
1199
 
987
1200
  // eval/runner.ts
988
- import { mkdtempSync, writeFileSync as writeFileSync6, mkdirSync as mkdirSync5, rmSync as rmSync2 } from "fs";
989
- import { dirname as dirname2, join as join5 } from "path";
1201
+ import { mkdtempSync, writeFileSync as writeFileSync7, mkdirSync as mkdirSync6, rmSync as rmSync3 } from "fs";
1202
+ import { dirname as dirname2, join as join7 } from "path";
990
1203
  import { tmpdir } from "os";
991
1204
  async function runScenario(model, s) {
992
- const dir = mkdtempSync(join5(tmpdir(), "miii-eval-"));
1205
+ const dir = mkdtempSync(join7(tmpdir(), "miii-eval-"));
993
1206
  const prevCwd = process.cwd();
994
1207
  for (const [rel, content] of Object.entries(s.files ?? {})) {
995
- const abs = join5(dir, rel);
996
- mkdirSync5(dirname2(abs), { recursive: true });
997
- writeFileSync6(abs, content, "utf-8");
1208
+ const abs = join7(dir, rel);
1209
+ mkdirSync6(dirname2(abs), { recursive: true });
1210
+ writeFileSync7(abs, content, "utf-8");
998
1211
  }
999
1212
  const r = {
1000
1213
  name: s.name,
@@ -1032,7 +1245,7 @@ async function runScenario(model, s) {
1032
1245
  r.durationMs = Date.now() - start;
1033
1246
  if (r.error) {
1034
1247
  r.reason = `loop error: ${r.error}`;
1035
- rmSync2(dir, { recursive: true, force: true });
1248
+ rmSync3(dir, { recursive: true, force: true });
1036
1249
  return r;
1037
1250
  }
1038
1251
  try {
@@ -1042,7 +1255,7 @@ async function runScenario(model, s) {
1042
1255
  } catch (err) {
1043
1256
  r.reason = `check threw: ${err instanceof Error ? err.message : String(err)}`;
1044
1257
  }
1045
- rmSync2(dir, { recursive: true, force: true });
1258
+ rmSync3(dir, { recursive: true, force: true });
1046
1259
  return r;
1047
1260
  }
1048
1261
  var autoYes;
@@ -1056,12 +1269,12 @@ var init_runner = __esm({
1056
1269
 
1057
1270
  // eval/scenarios.ts
1058
1271
  import { readFileSync as readFileSync6, existsSync as existsSync4 } from "fs";
1059
- import { join as join6 } from "path";
1272
+ import { join as join8 } from "path";
1060
1273
  var read, scenarios;
1061
1274
  var init_scenarios = __esm({
1062
1275
  "eval/scenarios.ts"() {
1063
1276
  "use strict";
1064
- read = (dir, f) => existsSync4(join6(dir, f)) ? readFileSync6(join6(dir, f), "utf-8") : null;
1277
+ read = (dir, f) => existsSync4(join8(dir, f)) ? readFileSync6(join8(dir, f), "utf-8") : null;
1065
1278
  scenarios = [
1066
1279
  {
1067
1280
  name: "edit-exact-string",
@@ -1208,9 +1421,9 @@ import { createElement } from "react";
1208
1421
 
1209
1422
  // src/ui/App.tsx
1210
1423
  init_client();
1211
- import { useState as useState4, useEffect as useEffect3 } from "react";
1424
+ import { useState as useState5, useEffect as useEffect4 } from "react";
1212
1425
  import { Box as Box10, Text as Text10, useApp } from "ink";
1213
- import { homedir as homedir4 } from "os";
1426
+ import { homedir as homedir6 } from "os";
1214
1427
  import { sep as sep2 } from "path";
1215
1428
 
1216
1429
  // src/config.ts
@@ -1650,6 +1863,7 @@ function FilePicker({ matches: matches2, cursor }) {
1650
1863
  }
1651
1864
 
1652
1865
  // src/ui/ChatView.tsx
1866
+ import { useState as useState3, useEffect as useEffect3 } from "react";
1653
1867
  import { Box as Box9, Text as Text9 } from "ink";
1654
1868
 
1655
1869
  // src/ui/ThinkingBlock.tsx
@@ -1710,6 +1924,24 @@ var EMPTY_STATE_TITLE = "Ask anything, or try:";
1710
1924
 
1711
1925
  // src/ui/ChatView.tsx
1712
1926
  import { jsx as jsx9, jsxs as jsxs9 } from "react/jsx-runtime";
1927
+ var COLLAPSED_LINES = 3;
1928
+ var globalToolExpanded = false;
1929
+ var toolExpandListeners = /* @__PURE__ */ new Set();
1930
+ function toggleToolExpanded() {
1931
+ globalToolExpanded = !globalToolExpanded;
1932
+ toolExpandListeners.forEach((fn) => fn());
1933
+ }
1934
+ function useToolExpanded() {
1935
+ const [expanded, setExpanded] = useState3(globalToolExpanded);
1936
+ useEffect3(() => {
1937
+ const handler = () => setExpanded(globalToolExpanded);
1938
+ toolExpandListeners.add(handler);
1939
+ return () => {
1940
+ toolExpandListeners.delete(handler);
1941
+ };
1942
+ }, []);
1943
+ return expanded;
1944
+ }
1713
1945
  function formatTokens(n) {
1714
1946
  if (n >= 1e3) return (n / 1e3).toFixed(n >= 1e4 ? 0 : 1) + "k";
1715
1947
  return String(n);
@@ -1732,8 +1964,8 @@ function FileEditBlock({
1732
1964
  removed,
1733
1965
  previewLines
1734
1966
  }) {
1735
- const MAX = 16;
1736
- const shown = previewLines.slice(0, MAX);
1967
+ const expanded = useToolExpanded();
1968
+ const shown = expanded ? previewLines : previewLines.slice(0, COLLAPSED_LINES);
1737
1969
  const extra = previewLines.length - shown.length;
1738
1970
  return /* @__PURE__ */ jsxs9(Box9, { flexDirection: "column", marginLeft: 2, children: [
1739
1971
  /* @__PURE__ */ jsxs9(Box9, { children: [
@@ -1750,15 +1982,22 @@ function FileEditBlock({
1750
1982
  "\u23BF ",
1751
1983
  removed > 0 ? `Added ${added} lines, removed ${removed} lines` : `Added ${added} lines`
1752
1984
  ] }) }),
1753
- shown.map((ln, i) => /* @__PURE__ */ jsx9(Box9, { marginLeft: 4, children: /* @__PURE__ */ jsxs9(Text9, { color: ln.sign === "+" ? "green" : ln.sign === "-" ? "red" : void 0, dimColor: ln.sign === " ", children: [
1754
- ln.sign,
1755
- " ",
1756
- ln.text
1757
- ] }) }, i)),
1985
+ shown.map((ln, i) => {
1986
+ const width = (process.stdout.columns ?? 80) - 6;
1987
+ const content = `${ln.sign} ${ln.text}`.padEnd(width);
1988
+ return /* @__PURE__ */ jsx9(Box9, { marginLeft: 4, children: /* @__PURE__ */ jsx9(
1989
+ Text9,
1990
+ {
1991
+ backgroundColor: ln.sign === "+" ? "#13351f" : ln.sign === "-" ? "#3b1414" : void 0,
1992
+ dimColor: ln.sign === " ",
1993
+ children: content
1994
+ }
1995
+ ) }, i);
1996
+ }),
1758
1997
  extra > 0 && /* @__PURE__ */ jsx9(Box9, { marginLeft: 4, children: /* @__PURE__ */ jsxs9(Text9, { dimColor: true, children: [
1759
1998
  "\u2026 ",
1760
1999
  extra,
1761
- " more lines"
2000
+ " more lines \xB7 ctrl+o to expand"
1762
2001
  ] }) })
1763
2002
  ] });
1764
2003
  }
@@ -1824,6 +2063,7 @@ function summarizeResult(res, toolName) {
1824
2063
  return extra > 0 ? `${head} (+${extra} lines)` : head;
1825
2064
  }
1826
2065
  function ToolResultBlock({ result, toolName }) {
2066
+ const expanded = useToolExpanded();
1827
2067
  const content = result.content ?? "";
1828
2068
  const lines = content.split("\n");
1829
2069
  const showMulti = (toolName === "run_bash" || toolName === "grep" || toolName === "glob" || result.is_error) && lines.length > 1;
@@ -1833,9 +2073,9 @@ function ToolResultBlock({ result, toolName }) {
1833
2073
  summarizeResult(result, toolName)
1834
2074
  ] }) });
1835
2075
  }
1836
- const MAX_LINES = 10;
1837
2076
  const MAX_LINE_WIDTH = 200;
1838
- const shown = lines.slice(0, MAX_LINES).map((l) => truncate(l, MAX_LINE_WIDTH));
2077
+ const visible = expanded ? lines : lines.slice(0, COLLAPSED_LINES);
2078
+ const shown = visible.map((l) => truncate(l, MAX_LINE_WIDTH));
1839
2079
  const extra = lines.length - shown.length;
1840
2080
  return /* @__PURE__ */ jsxs9(Box9, { flexDirection: "column", marginLeft: 2, children: [
1841
2081
  /* @__PURE__ */ jsxs9(Text9, { color: result.is_error ? "red" : void 0, dimColor: !result.is_error, children: [
@@ -1846,7 +2086,7 @@ function ToolResultBlock({ result, toolName }) {
1846
2086
  extra > 0 && /* @__PURE__ */ jsx9(Box9, { marginLeft: 4, children: /* @__PURE__ */ jsxs9(Text9, { dimColor: true, children: [
1847
2087
  "\u2026 ",
1848
2088
  extra,
1849
- " more lines"
2089
+ " more lines \xB7 ctrl+o to expand"
1850
2090
  ] }) })
1851
2091
  ] });
1852
2092
  }
@@ -1987,22 +2227,22 @@ function ChatView({
1987
2227
 
1988
2228
  // src/ui/hooks/useAgentRunner.ts
1989
2229
  init_loop();
1990
- import { useState as useState3, useRef } from "react";
2230
+ import { useState as useState4, useRef } from "react";
1991
2231
  var FLUSH_MS = 100;
1992
2232
  function useAgentRunner(model, activeCtx) {
1993
- const [messages, setMessages] = useState3([]);
1994
- const [thinking, setThinking] = useState3(false);
1995
- const [thinkingContent, setThinkingContent] = useState3("");
1996
- const [streaming, setStreaming] = useState3(false);
1997
- const [streamingContent, setStreamingContent] = useState3("");
1998
- const [error, setError] = useState3(null);
1999
- const [busy, setBusy] = useState3(false);
2000
- const [processingLabel, setProcessingLabel] = useState3(void 0);
2001
- const [agentHistory, setAgentHistory] = useState3([]);
2002
- const [pendingPermission, setPendingPermission] = useState3(null);
2003
- const [permissionCursor, setPermissionCursor] = useState3(0);
2004
- const [activeToolUses, setActiveToolUses] = useState3([]);
2005
- const [activeToolResults, setActiveToolResults] = useState3([]);
2233
+ const [messages, setMessages] = useState4([]);
2234
+ const [thinking, setThinking] = useState4(false);
2235
+ const [thinkingContent, setThinkingContent] = useState4("");
2236
+ const [streaming, setStreaming] = useState4(false);
2237
+ const [streamingContent, setStreamingContent] = useState4("");
2238
+ const [error, setError] = useState4(null);
2239
+ const [busy, setBusy] = useState4(false);
2240
+ const [processingLabel, setProcessingLabel] = useState4(void 0);
2241
+ const [agentHistory, setAgentHistory] = useState4([]);
2242
+ const [pendingPermission, setPendingPermission] = useState4(null);
2243
+ const [permissionCursor, setPermissionCursor] = useState4(0);
2244
+ const [activeToolUses, setActiveToolUses] = useState4([]);
2245
+ const [activeToolResults, setActiveToolResults] = useState4([]);
2006
2246
  const busyRef = useRef(false);
2007
2247
  const abortRef = useRef(null);
2008
2248
  const pendingPermissionRef = useRef(null);
@@ -2273,6 +2513,10 @@ function useKeyboard(opts) {
2273
2513
  toggleThinkingVisible();
2274
2514
  return;
2275
2515
  }
2516
+ if (key.ctrl && char === "o") {
2517
+ toggleToolExpanded();
2518
+ return;
2519
+ }
2276
2520
  if (key.escape && busyRef.current && abortRef.current) {
2277
2521
  abortRef.current.abort();
2278
2522
  return;
@@ -2497,31 +2741,31 @@ async function checkForUpdate() {
2497
2741
  import { Fragment as Fragment2, jsx as jsx10, jsxs as jsxs10 } from "react/jsx-runtime";
2498
2742
  function App() {
2499
2743
  const { exit } = useApp();
2500
- const cwd = process.cwd().replace(homedir4(), "~").split(sep2).join("/");
2501
- const [cfg, setCfg] = useState4(loadConfig());
2502
- const [models, setModels] = useState4([]);
2503
- const [contexts, setContexts] = useState4({});
2504
- const [activeCtx, setActiveCtx] = useState4(null);
2505
- const [state, setState] = useState4("loading");
2506
- const [cursor, setCursor] = useState4(0);
2507
- const [updateAvailable, setUpdateAvailable] = useState4(null);
2508
- const [ollamaDown, setOllamaDown] = useState4(false);
2509
- const [sessionId, setSessionId] = useState4(() => newSessionId());
2510
- const [sessions, setSessions] = useState4([]);
2511
- const [notice, setNotice] = useState4(null);
2512
- const [input, setInput] = useState4("");
2513
- const [paletteCursor, setPaletteCursor] = useState4(0);
2514
- const [filePickerCursor, setFilePickerCursor] = useState4(0);
2744
+ const cwd = process.cwd().replace(homedir6(), "~").split(sep2).join("/");
2745
+ const [cfg, setCfg] = useState5(loadConfig());
2746
+ const [models, setModels] = useState5([]);
2747
+ const [contexts, setContexts] = useState5({});
2748
+ const [activeCtx, setActiveCtx] = useState5(null);
2749
+ const [state, setState] = useState5("loading");
2750
+ const [cursor, setCursor] = useState5(0);
2751
+ const [updateAvailable, setUpdateAvailable] = useState5(null);
2752
+ const [ollamaDown, setOllamaDown] = useState5(false);
2753
+ const [sessionId, setSessionId] = useState5(() => newSessionId());
2754
+ const [sessions, setSessions] = useState5([]);
2755
+ const [notice, setNotice] = useState5(null);
2756
+ const [input, setInput] = useState5("");
2757
+ const [paletteCursor, setPaletteCursor] = useState5(0);
2758
+ const [filePickerCursor, setFilePickerCursor] = useState5(0);
2515
2759
  const agent = useAgentRunner(cfg.model, activeCtx);
2516
- useEffect3(() => {
2760
+ useEffect4(() => {
2517
2761
  checkForUpdate().then((v) => {
2518
2762
  if (v) setUpdateAvailable(v);
2519
2763
  });
2520
2764
  }, []);
2521
- useEffect3(() => {
2765
+ useEffect4(() => {
2522
2766
  if (agent.agentHistory.length) persistSession(sessionId, agent.agentHistory);
2523
2767
  }, [agent.agentHistory, sessionId]);
2524
- useEffect3(() => {
2768
+ useEffect4(() => {
2525
2769
  listModels().then((m) => {
2526
2770
  setModels(m);
2527
2771
  setState(cfg.model ? "ready" : "select-model");
@@ -2635,6 +2879,8 @@ function App() {
2635
2879
  }
2636
2880
 
2637
2881
  // src/cli.tsx
2882
+ init_spill();
2883
+ cleanupSpill();
2638
2884
  var [, , cmd, ...rest] = process.argv;
2639
2885
  if (cmd === "update" || cmd === "--update" || cmd === "-u") {
2640
2886
  const { spawnSync } = await import("child_process");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "miii-agent",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "Terminal AI coding agent powered by Ollama",
5
5
  "type": "module",
6
6
  "bin": {