miii-cli 1.2.1 → 1.2.2

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.
@@ -3,7 +3,7 @@ import { writeFileSync, unlinkSync, readFileSync } from 'fs';
3
3
  import { join } from 'path';
4
4
  import { looksCodeRelated } from '../tui/git-context.js';
5
5
  import { tools } from '../tools/index.js';
6
- // patch_file uses guardPath which restricts to CWD — use a local scratch file
6
+ // update_file uses guardPath which restricts to CWD — use a local scratch file
7
7
  const SCRATCH = join(process.cwd(), '.miii-test-scratch.txt');
8
8
  // ─── looksCodeRelated ─────────────────────────────────────────────────────────
9
9
  describe('looksCodeRelated', () => {
@@ -23,9 +23,9 @@ describe('looksCodeRelated', () => {
23
23
  expect(looksCodeRelated('what is the weather like in london today')).toBe(false);
24
24
  });
25
25
  });
26
- // ─── patch_file ───────────────────────────────────────────────────────────────
27
- describe('patch_file', () => {
28
- const patchTool = tools.find(t => t.name === 'patch_file');
26
+ // ─── update_file ───────────────────────────────────────────────────────────────
27
+ describe('update_file', () => {
28
+ const updateTool = tools.find(t => t.name === 'update_file');
29
29
  afterEach(() => {
30
30
  try {
31
31
  unlinkSync(SCRATCH);
@@ -34,17 +34,17 @@ describe('patch_file', () => {
34
34
  });
35
35
  it('applies a unique patch correctly', async () => {
36
36
  writeFileSync(SCRATCH, 'hello world\ngoodbye world\n');
37
- await patchTool.execute({ path: SCRATCH, old: 'hello world', new: 'hello earth' });
37
+ await updateTool.execute({ path: SCRATCH, old: 'hello world', new: 'hello earth' });
38
38
  expect(readFileSync(SCRATCH, 'utf-8')).toBe('hello earth\ngoodbye world\n');
39
39
  });
40
40
  it('throws when old text not found', async () => {
41
41
  writeFileSync(SCRATCH, 'hello world\n');
42
- await expect(patchTool.execute({ path: SCRATCH, old: 'no such text', new: 'x' }))
42
+ await expect(updateTool.execute({ path: SCRATCH, old: 'no such text', new: 'x' }))
43
43
  .rejects.toThrow('old text not found');
44
44
  });
45
45
  it('throws on ambiguous match (2+ occurrences)', async () => {
46
46
  writeFileSync(SCRATCH, 'hello world\nhello world\n');
47
- await expect(patchTool.execute({ path: SCRATCH, old: 'hello world', new: 'hi' }))
47
+ await expect(updateTool.execute({ path: SCRATCH, old: 'hello world', new: 'hi' }))
48
48
  .rejects.toThrow('ambiguous');
49
49
  });
50
50
  });
@@ -6,7 +6,7 @@ Skip: trivial exchanges, transient state, tool output noise.
6
6
  Max 8 facts. Be specific and concrete.
7
7
 
8
8
  Example output:
9
- ["User prefers patch_file over full rewrites","entry point is src/index.ts","decided to use Zod for validation"]`;
9
+ ["User prefers update_file over full rewrites","entry point is src/index.ts","decided to use Zod for validation"]`;
10
10
  export function extractFacts(messages, config, model) {
11
11
  const lines = messages
12
12
  .filter(m => m.role !== 'system')
@@ -98,7 +98,7 @@ function extractFileToolArgs(text, toolName) {
98
98
  .replace(/\\"/g, '"').replace(/\\\\/g, '\\');
99
99
  }
100
100
  }
101
- // For patch_file: extract old/new fields
101
+ // For update_file: extract old/new fields
102
102
  const oldM = text.match(/"old"\s*:\s*"((?:[^"\\]|\\[\s\S])*)"/);
103
103
  if (oldM)
104
104
  args.old = oldM[1].replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
@@ -127,7 +127,7 @@ export function extractBareToolCall(text) {
127
127
  pos = start + 1;
128
128
  }
129
129
  // Fallback: content-aware extraction for file-writing tools (immune to unescaped chars)
130
- for (const name of ['edit_file', 'create_file', 'patch_file']) {
130
+ for (const name of ['edit_file', 'create_file', 'update_file']) {
131
131
  const args = extractFileToolArgs(text, name);
132
132
  if (args)
133
133
  return { name, args };
@@ -79,6 +79,11 @@ const builtin = [
79
79
  return `moved: ${from} → ${to}`;
80
80
  },
81
81
  },
82
+ {
83
+ name: 'test',
84
+ ns: 'builtin',
85
+ description: 'Run test suite — usage: /test [path]',
86
+ },
82
87
  {
83
88
  name: 'touch',
84
89
  ns: 'default',
@@ -46,13 +46,13 @@ export const tools = [
46
46
  },
47
47
  {
48
48
  name: 'edit_file',
49
- description: 'Write a new file — only for files that do not exist yet. Use patch_file to modify existing files.',
49
+ description: 'Write a new file — only for files that do not exist yet. Use update_file to modify existing files.',
50
50
  params: '{"path": "string", "content": "string"}',
51
51
  execute: async ({ path, content }) => {
52
52
  const safe = guardPath(path);
53
53
  if (existsSync(safe)) {
54
54
  throw new Error(`edit_file cannot overwrite existing file: ${path}\n` +
55
- `Use patch_file with <old> and <new> blocks to make targeted edits.\n` +
55
+ `Use update_file with <old> and <new> blocks to make targeted edits.\n` +
56
56
  `Call read_file first to get the exact current text.`);
57
57
  }
58
58
  const text = content;
@@ -62,7 +62,7 @@ export const tools = [
62
62
  },
63
63
  },
64
64
  {
65
- name: 'patch_file',
65
+ name: 'update_file',
66
66
  description: 'Replace an exact unique string in an existing file. Always call read_file first to get the exact text.',
67
67
  params: '{"path": "string", "old": "string", "new": "string"}',
68
68
  execute: async ({ path, old: oldStr, new: newStr }) => {
@@ -295,9 +295,9 @@ full file content here
295
295
  </content>
296
296
  </tool_call>
297
297
 
298
- For patch_file use <old> and <new> blocks:
298
+ For update_file use <old> and <new> blocks:
299
299
  <tool_call>
300
- {"name": "patch_file", "args": {"path": "src/foo.ts"}}
300
+ {"name": "update_file", "args": {"path": "src/foo.ts"}}
301
301
  <old>
302
302
  exact text to replace
303
303
  </old>
@@ -312,9 +312,9 @@ ${deepThinkDoc}
312
312
 
313
313
  Rules:
314
314
  - edit_file only works on NEW files — it throws an error if the file exists. Never call it on existing files
315
- - To modify any existing file: call read_file first, then patch_file with the exact text from that read as the <old> block
315
+ - To modify any existing file: call read_file first, then update_file with the exact text from that read as the <old> block
316
316
  - Never guess or reuse old text from earlier in the conversation — always re-read immediately before patching
317
- - If patch_file reports "old text not found", call read_file again and retry with the exact current text
317
+ - If update_file reports "old text not found", call read_file again and retry with the exact current text
318
318
  - Never delete without confirming
319
319
  - Use git_status and git_diff before any refactor to understand what has already changed
320
320
  - Use git_log to understand recent history before suggesting changes
@@ -322,7 +322,7 @@ Rules:
322
322
  - Be concise
323
323
  - Output plain text only — never use markdown formatting in your responses
324
324
  - No headers (no #, ##), no bold (**text**), no italic (*text*), no bullet points with *, no horizontal rules (---)
325
- - NEVER show file content or code in your text response — always use edit_file, patch_file, or create_file tools to write code to files
325
+ - NEVER show file content or code in your text response — always use edit_file, update_file, or create_file tools to write code to files
326
326
  - If you want to show the user code, write it to the file with a tool call instead
327
327
  - No fenced code blocks (no \`\`\`). If you find yourself about to write a code block, use a tool call instead
328
328
  - Use plain indentation and labels for structure. This is a terminal, not a chat UI
@@ -64,7 +64,7 @@ function diffHunks(diff) {
64
64
  return diff.filter((_, i) => inHunk.has(i));
65
65
  }
66
66
  function DiffPreview({ toolName, args }) {
67
- if (toolName === 'patch_file' && (args.old != null || args.new != null)) {
67
+ if (toolName === 'update_file' && (args.old != null || args.new != null)) {
68
68
  const path = String(args.path ?? '');
69
69
  const diff = diffHunks(lineDiff(String(args.old ?? ''), String(args.new ?? '')));
70
70
  const visible = diff.slice(0, MAX_DIFF_LINES);
@@ -6,10 +6,10 @@ import { StreamParser, extractBareToolCall } from '../../parser/stream-parser.js
6
6
  import { shouldCompact, compactContext, contextSize } from '../../tasks/compactor.js';
7
7
  import * as printer from '../printer.js';
8
8
  const MAX_TOOL_DEPTH = 10;
9
- const FILE_EDIT_TOOLS = new Set(['edit_file', 'create_file', 'patch_file', 'delete_file']);
9
+ const FILE_EDIT_TOOLS = new Set(['edit_file', 'create_file', 'update_file', 'delete_file']);
10
10
  const SHOW_RESULT_TOOLS = new Set(['run_tests', 'git_commit']);
11
- const PERMISSION_TOOLS = new Set(['edit_file', 'patch_file', 'delete_file', 'create_file', 'move_file', 'run_command', 'git_commit']);
12
- const CHECKPOINT_TOOLS = new Set(['edit_file', 'patch_file', 'create_file', 'delete_file']);
11
+ const PERMISSION_TOOLS = new Set(['edit_file', 'update_file', 'delete_file', 'create_file', 'move_file', 'run_command', 'git_commit']);
12
+ const CHECKPOINT_TOOLS = new Set(['edit_file', 'update_file', 'create_file', 'delete_file']);
13
13
  // Tool result messages that are ephemeral — never worth storing in memory or compact summaries
14
14
  const EPHEMERAL_PATTERN = /^Tool (read_file|list_files|run_tests) result:|^\[current state of|^\[Context compacted/;
15
15
  export function stripEphemeral(messages) {
@@ -115,7 +115,7 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
115
115
  if (hasFencedCode && depth < MAX_TOOL_DEPTH - 1) {
116
116
  const nudge = {
117
117
  role: 'user',
118
- content: 'You showed code in your response but did not use any file tools. Use edit_file or patch_file to actually write the changes to disk.',
118
+ content: 'You showed code in your response but did not use any file tools. Use edit_file or update_file to actually write the changes to disk.',
119
119
  };
120
120
  await runLoop([...msgs, { role: 'assistant', content: fullText }, nudge], depth + 1, goal);
121
121
  return;
@@ -165,9 +165,9 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
165
165
  }
166
166
  if (tool) {
167
167
  try {
168
- // Guard: for patch_file, verify old text still matches before executing.
168
+ // Guard: for update_file, verify old text still matches before executing.
169
169
  // If stale, inject fresh file content and skip — model will retry.
170
- if (tc.name === 'patch_file') {
170
+ if (tc.name === 'update_file') {
171
171
  const filePath = tc.args.path;
172
172
  const oldText = tc.args.old;
173
173
  if (filePath && oldText && existsSync(filePath)) {
@@ -176,13 +176,13 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
176
176
  if (occurrences === 0) {
177
177
  printer.errorMsg(`patch stale: old text not found in ${filePath} — injecting fresh content`);
178
178
  next.push({ role: 'user', content: `Tool read_file result:\n${current}` });
179
- next.push({ role: 'user', content: `patch_file failed: old text not found in ${filePath}. The file content above is the current state. Retry patch_file with the correct exact text.` });
179
+ next.push({ role: 'user', content: `update_file failed: old text not found in ${filePath}. The file content above is the current state. Retry update_file with the correct exact text.` });
180
180
  continue;
181
181
  }
182
182
  if (occurrences > 1) {
183
183
  printer.errorMsg(`patch ambiguous: old text matches ${occurrences} locations in ${filePath} — injecting fresh content`);
184
184
  next.push({ role: 'user', content: `Tool read_file result:\n${current}` });
185
- next.push({ role: 'user', content: `patch_file failed: old text matches ${occurrences} locations in ${filePath}. Use more surrounding context to make old text unique, then retry.` });
185
+ next.push({ role: 'user', content: `update_file failed: old text matches ${occurrences} locations in ${filePath}. Use more surrounding context to make old text unique, then retry.` });
186
186
  continue;
187
187
  }
188
188
  }
@@ -217,33 +217,9 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
217
217
  finally {
218
218
  setCurrentTool(undefined);
219
219
  }
220
- // Auto-run tests after file edits
221
- const didEditFiles = pendingTools.some(tc => FILE_EDIT_TOOLS.has(tc.name));
222
- if (didEditFiles) {
223
- const testTool = staticTools.find(t => t.name === 'run_tests');
224
- if (testTool) {
225
- setCurrentTool('run_tests');
226
- try {
227
- printer.toolCallStart('run_tests', {});
228
- const testResult = await testTool.execute({});
229
- if (testResult && !testResult.startsWith('(no test script') && !testResult.startsWith('(no package.json')) {
230
- printer.toolResultSummary('run_tests', {}, testResult);
231
- printer.toolMsg('run_tests', testResult);
232
- next.push({ role: 'user', content: `Test results after edits:\n${testResult}` });
233
- }
234
- }
235
- catch (e) {
236
- const err = `run_tests error: ${e}`;
237
- printer.errorMsg(err);
238
- next.push({ role: 'user', content: err });
239
- }
240
- finally {
241
- setCurrentTool(undefined);
242
- }
243
- }
244
- }
245
220
  // For file-edit turns: slim context (system + goal + fresh file states + recent results)
246
221
  // For non-edit turns: full next (model needs full conversational context)
222
+ const didEditFiles = pendingTools.some(tc => FILE_EDIT_TOOLS.has(tc.name));
247
223
  if (didEditFiles) {
248
224
  const systemMsg = msgs.find(m => m.role === 'system');
249
225
  const goalMsg = msgs.find(m => m.role === 'user' && !m.content.startsWith('[') && !m.content.startsWith('Tool '));
@@ -431,6 +431,30 @@ export function useSubmit(deps) {
431
431
  printer.systemMsg('usage: /index build | /index status | /index search <query> | /index clear');
432
432
  return;
433
433
  }
434
+ if (cmd === '/test' || cmd.startsWith('/test ')) {
435
+ const testPath = cmd.slice(5).trim();
436
+ const testTool = (await import('../../tools/index.js')).tools.find(t => t.name === 'run_tests');
437
+ if (!testTool) {
438
+ printer.errorMsg('run_tests tool not found');
439
+ return;
440
+ }
441
+ setStatus('tool');
442
+ setCurrentTool('run_tests');
443
+ try {
444
+ printer.toolCallStart('run_tests', testPath ? { path: testPath } : {});
445
+ const result = await testTool.execute(testPath ? { path: testPath } : {});
446
+ printer.toolResultSummary('run_tests', {}, result);
447
+ printer.toolMsg('run_tests', result);
448
+ }
449
+ catch (e) {
450
+ printer.errorMsg(`run_tests: ${e}`);
451
+ }
452
+ finally {
453
+ setCurrentTool(undefined);
454
+ setStatus('idle');
455
+ }
456
+ return;
457
+ }
434
458
  if (cmd === '/watch' || cmd.startsWith('/watch ')) {
435
459
  const sub = cmd.slice(6).trim();
436
460
  if (sub === 'stop') {
@@ -1,4 +1,5 @@
1
1
  // ANSI-formatted stdout output — goes into terminal scrollback
2
+ import { readFileSync, existsSync } from 'fs';
2
3
  let _inkWrite = null;
3
4
  export function setInkInstance(inkWrite) {
4
5
  _inkWrite = inkWrite;
@@ -158,7 +159,7 @@ export function assistantMsg(text) {
158
159
  const tail = lines.slice(idx + 1).join('\n');
159
160
  write(`\n${blue('●')} ${head}${tail ? '\n' + tail : ''}\n`);
160
161
  }
161
- const EDIT_TOOLS = new Set(['edit_file', 'patch_file', 'create_file', 'write_file']);
162
+ const EDIT_TOOLS = new Set(['edit_file', 'update_file', 'create_file', 'write_file']);
162
163
  const DELETE_TOOLS = new Set(['delete_file', 'remove_file']);
163
164
  function toolLabel(name, args) {
164
165
  const a = args;
@@ -168,7 +169,7 @@ function toolLabel(name, args) {
168
169
  case 'list_files': return `Listing ${a.path || '.'}`;
169
170
  case 'create_file': return `Creating ${a.path ?? ''}`;
170
171
  case 'edit_file': return `Writing ${a.path ?? ''}`;
171
- case 'patch_file': return `Editing ${a.path ?? ''}`;
172
+ case 'update_file': return `Updating ${a.path ?? ''}`;
172
173
  case 'delete_file': return `Deleting ${a.path ?? ''}`;
173
174
  case 'move_file': return `Moving ${a.from} → ${a.to}`;
174
175
  case 'create_folder': return `Creating folder ${a.path ?? ''}`;
@@ -199,9 +200,63 @@ export function planSummary(tools) {
199
200
  write(` ${dot} ${gray(label)}\n`);
200
201
  }
201
202
  }
203
+ const DIFF_CTX = 2;
204
+ const DIFF_MAX = 40;
205
+ function printUpdateDiff(filePath, oldText, newText) {
206
+ const oldLines = oldText.split('\n');
207
+ const newLines = newText.split('\n');
208
+ write(gray(` └ Added ${newLines.length} line${newLines.length !== 1 ? 's' : ''}, removed ${oldLines.length} line${oldLines.length !== 1 ? 's' : ''}\n`));
209
+ let fileLines = [];
210
+ let lineOffset = 0;
211
+ try {
212
+ if (existsSync(filePath)) {
213
+ const content = readFileSync(filePath, 'utf-8');
214
+ fileLines = content.split('\n');
215
+ const idx = content.indexOf(oldText);
216
+ if (idx >= 0)
217
+ lineOffset = content.slice(0, idx).split('\n').length - 1;
218
+ else
219
+ fileLines = []; // old text not in file — skip context lines
220
+ }
221
+ }
222
+ catch { }
223
+ let shown = 0;
224
+ const ctxStart = Math.max(0, lineOffset - DIFF_CTX);
225
+ for (let i = ctxStart; i < lineOffset && shown < DIFF_MAX; i++, shown++) {
226
+ write(gray(` ${String(i + 1).padStart(4)} ${fileLines[i] ?? ''}\n`));
227
+ }
228
+ for (let i = 0; i < oldLines.length && shown < DIFF_MAX; i++, shown++) {
229
+ write(` ${gray(String(lineOffset + i + 1).padStart(4))} ${red('- ')}${red(oldLines[i])}\n`);
230
+ }
231
+ for (let i = 0; i < newLines.length && shown < DIFF_MAX; i++, shown++) {
232
+ write(` ${gray(String(lineOffset + i + 1).padStart(4))} ${green('+ ')}${green(newLines[i])}\n`);
233
+ }
234
+ const ctxEnd = Math.min(fileLines.length, lineOffset + oldLines.length + DIFF_CTX);
235
+ for (let i = lineOffset + oldLines.length; i < ctxEnd && shown < DIFF_MAX; i++, shown++) {
236
+ write(gray(` ${String(i + 1).padStart(4)} ${fileLines[i] ?? ''}\n`));
237
+ }
238
+ }
239
+ function printEditPreview(content) {
240
+ const lines = content.split('\n');
241
+ const visible = lines.slice(0, DIFF_MAX);
242
+ const hidden = lines.length - visible.length;
243
+ write(gray(` └ ${lines.length} line${lines.length !== 1 ? 's' : ''}\n`));
244
+ visible.forEach((line, i) => {
245
+ write(` ${gray(String(i + 1).padStart(4))} ${green('+ ')}${green(line)}\n`);
246
+ });
247
+ if (hidden > 0)
248
+ write(gray(` …${hidden} more line${hidden !== 1 ? 's' : ''}\n`));
249
+ }
202
250
  export function toolCallStart(name, args) {
203
251
  const dot = DELETE_TOOLS.has(name) ? red('●') : EDIT_TOOLS.has(name) ? green('●') : blue('●');
204
252
  write(`\n${dot} ${bold(toolLabel(name, args))}\n`);
253
+ const a = args;
254
+ if (name === 'update_file' && a.old && a.new && a.path) {
255
+ printUpdateDiff(a.path, a.old, a.new);
256
+ }
257
+ else if (name === 'edit_file' && a.content && a.path) {
258
+ printEditPreview(a.content);
259
+ }
205
260
  }
206
261
  export function toolResultSummary(name, args, result) {
207
262
  const a = args;
@@ -219,7 +274,7 @@ export function toolResultSummary(name, args, result) {
219
274
  summary = `Created file · ${n} line${n === 1 ? '' : 's'}`;
220
275
  break;
221
276
  }
222
- case 'patch_file':
277
+ case 'update_file':
223
278
  summary = lines[0] ?? 'Applied patch';
224
279
  break;
225
280
  case 'delete_file':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "miii-cli",
3
- "version": "1.2.1",
3
+ "version": "1.2.2",
4
4
  "type": "module",
5
5
  "description": "The high-performance local AI coding agent for your terminal. Automate complex workflows with local LLMs.",
6
6
  "license": "MIT",