miii-cli 1.2.4 → 1.3.1

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.
@@ -20,14 +20,15 @@ export function useRefactor(deps) {
20
20
  content: `Refactor goal: ${goal}\n\nList every file that needs to change. For each file output:\nFILE: <path>\nCHANGE: <one sentence describing the edit>\n\nUse list_files and read_file to discover relevant files first. Only list files that genuinely need changes.`,
21
21
  },
22
22
  ];
23
- abortRef.current = new AbortController();
23
+ const controller = new AbortController();
24
+ abortRef.current = controller;
24
25
  let planText = '';
25
26
  await chat({
26
27
  provider: config.provider,
27
28
  model: currentModelRef.current,
28
29
  baseUrl: config.baseUrl,
29
30
  messages: planCtx,
30
- signal: abortRef.current.signal,
31
+ signal: controller.signal,
31
32
  async onDone(text) { planText = text; },
32
33
  onError(err) { printer.errorMsg(err.message); },
33
34
  });
@@ -95,7 +96,7 @@ export function useRefactor(deps) {
95
96
  model: currentModelRef.current,
96
97
  baseUrl: config.baseUrl,
97
98
  messages: editCtx,
98
- signal: abortRef.current?.signal,
99
+ signal: controller.signal,
99
100
  async onDone(text) { editText = text; },
100
101
  onError(err) { printer.errorMsg(`edit LLM error: ${err.message}`); },
101
102
  });
@@ -1,5 +1,8 @@
1
1
  import { useState, useRef, useCallback, useEffect } from 'react';
2
2
  import { readFileSync, writeFileSync, unlinkSync, existsSync } from 'fs';
3
+ import { exec } from 'child_process';
4
+ import { promisify } from 'util';
5
+ const runCmd = promisify(exec);
3
6
  import { chat } from '../../llm/stream.js';
4
7
  import { tools as staticTools } from '../../tools/index.js';
5
8
  import { StreamParser, extractBareToolCall } from '../../parser/stream-parser.js';
@@ -10,6 +13,7 @@ const FILE_EDIT_TOOLS = new Set(['edit_file', 'create_file', 'update_file', 'del
10
13
  const SHOW_RESULT_TOOLS = new Set(['run_tests', 'git_commit']);
11
14
  const PERMISSION_TOOLS = new Set(['edit_file', 'update_file', 'delete_file', 'create_file', 'move_file', 'run_command', 'git_commit']);
12
15
  const CHECKPOINT_TOOLS = new Set(['edit_file', 'update_file', 'create_file', 'delete_file']);
16
+ const PARALLEL_SAFE = new Set(['read_file', 'list_files', 'git_status', 'git_log', 'git_diff', 'web_search', 'web_extract']);
13
17
  // Tool result messages that are ephemeral — never worth storing in memory or compact summaries
14
18
  const EPHEMERAL_PATTERN = /^Tool (read_file|list_files|run_tests) result:|^\[current state of|^\[Context compacted|^\[file updated:/;
15
19
  export function stripEphemeral(messages) {
@@ -23,6 +27,7 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
23
27
  const [permissionRequest, setPermissionRequest] = useState(null);
24
28
  const permissionResolveRef = useRef(null);
25
29
  const checkpointRef = useRef(new Map());
30
+ const autoBranchedRef = useRef(null);
26
31
  const sessionApprovedRef = useRef(new Set());
27
32
  const thinkingStartRef = useRef(0);
28
33
  const extraToolsRef = useRef(extraTools);
@@ -42,7 +47,7 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
42
47
  const t = setInterval(() => setTick(n => n + 1), 80);
43
48
  return () => clearInterval(t);
44
49
  }, [status]);
45
- const runLoop = useCallback(async (contextMsgs, depth = 0, goal) => {
50
+ const runLoop = useCallback(async (contextMsgs, depth = 0, goal, options) => {
46
51
  if (depth >= MAX_TOOL_DEPTH) {
47
52
  abortRef.current = null;
48
53
  setStatus('idle');
@@ -52,7 +57,9 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
52
57
  if (depth === 0) {
53
58
  thinkingStartRef.current = Date.now();
54
59
  checkpointRef.current.clear();
60
+ autoBranchedRef.current = null;
55
61
  }
62
+ abortRef.current = new AbortController();
56
63
  let msgs = contextMsgs;
57
64
  if (shouldCompact(contextMsgs)) {
58
65
  printer.systemMsg('compacting context…');
@@ -62,17 +69,31 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
62
69
  model: currentModelRef.current,
63
70
  baseUrl: config.baseUrl,
64
71
  apiKey: config.apiKey,
65
- }, goal);
72
+ }, goal, abortRef.current.signal);
73
+ if (abortRef.current.signal.aborted) {
74
+ setStatus('idle');
75
+ return;
76
+ }
66
77
  printer.systemMsg(`compacted: ${contextMsgs.length} → ${msgs.length} messages`);
67
78
  replaceHistoryRef.current?.(msgs.filter(m => m.role !== 'system'));
68
79
  }
69
- abortRef.current = new AbortController();
80
+ let didStream = false;
70
81
  await chat({
71
82
  provider: config.provider,
72
83
  model: currentModelRef.current,
73
84
  baseUrl: config.baseUrl,
85
+ apiKey: config.apiKey,
74
86
  messages: msgs,
87
+ tools: config.provider !== 'ollama' && !(options?.noTools && depth === 0) ? [...staticTools, ...extraToolsRef.current] : undefined,
88
+ toolChoice: (options?.noTools && depth === 0) ? 'none' : undefined,
75
89
  signal: abortRef.current.signal,
90
+ onChunk: config.streaming ? (chunk) => {
91
+ if (!didStream) {
92
+ printer.streamStart();
93
+ didStream = true;
94
+ }
95
+ printer.streamChunk(chunk);
96
+ } : undefined,
76
97
  onRetry(attempt, max, delayMs) {
77
98
  printer.systemMsg(`retry ${attempt}/${max} — waiting ${Math.round(delayMs / 1000)}s`);
78
99
  },
@@ -91,8 +112,10 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
91
112
  if (bare)
92
113
  pendingTools.push({ name: bare.name, args: bare.args });
93
114
  }
115
+ if (didStream)
116
+ printer.streamEnd();
94
117
  const displayText = textParts.join('').trim();
95
- if (displayText)
118
+ if (displayText && !didStream)
96
119
  printer.assistantMsg(displayText);
97
120
  pushHistoryRef.current({ role: 'assistant', content: fullText });
98
121
  if (pendingTools.length)
@@ -107,108 +130,161 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
107
130
  await runLoop([...msgs, { role: 'assistant', content: fullText }, nudge], depth + 1, goal);
108
131
  return;
109
132
  }
133
+ if (autoBranchedRef.current)
134
+ printer.systemMsg(`branch: ${autoBranchedRef.current} (git checkout main when done)`);
110
135
  printer.systemMsg(`done in ${printer.formatElapsed(Date.now() - thinkingStartRef.current)}`);
111
136
  setStatus('idle');
112
137
  return;
113
138
  }
114
139
  setStatus('tool');
115
140
  const next = [...msgs, { role: 'assistant', content: fullText }];
116
- try {
117
- for (const tc of pendingTools) {
141
+ const allParallelSafe = pendingTools.every(tc => PARALLEL_SAFE.has(tc.name));
142
+ if (allParallelSafe && pendingTools.length > 1) {
143
+ try {
144
+ setCurrentTool(pendingTools[0].name);
118
145
  const allTools = [...staticTools, ...extraToolsRef.current];
119
- const tool = allTools.find(t => t.name === tc.name);
120
- setCurrentTool(tc.name);
121
- if (PERMISSION_TOOLS.has(tc.name)) {
122
- const sessionKey = tc.name;
123
- let decision;
124
- if (sessionApprovedRef.current.has(sessionKey)) {
125
- decision = 'yes';
146
+ const settled = await Promise.allSettled(pendingTools.map(async (tc) => {
147
+ const tool = allTools.find(t => t.name === tc.name);
148
+ printer.toolCallStart(tc.name, tc.args);
149
+ if (!tool)
150
+ throw new Error(`unknown tool: ${tc.name}`);
151
+ const result = await tool.execute(tc.args);
152
+ printer.toolResultSummary(tc.name, tc.args, result);
153
+ if (SHOW_RESULT_TOOLS.has(tc.name))
154
+ printer.toolMsg(tc.name, result);
155
+ return { tc, result };
156
+ }));
157
+ for (const r of settled) {
158
+ if (r.status === 'fulfilled') {
159
+ next.push({ role: 'user', content: `Tool ${r.value.tc.name} result:\n${r.value.result}` });
126
160
  }
127
161
  else {
128
- decision = await new Promise(resolve => {
129
- permissionResolveRef.current = resolve;
130
- setPermissionRequest({ toolName: tc.name, args: tc.args });
131
- });
132
- }
133
- if (decision === 'session')
134
- sessionApprovedRef.current.add(sessionKey);
135
- if (decision === 'no') {
136
- printer.systemMsg(`denied: ${tc.name}`);
137
- next.push({ role: 'user', content: `Tool ${tc.name} was denied by the user` });
138
- break;
162
+ const err = `Tool error: ${r.reason}`;
163
+ printer.errorMsg(err);
164
+ next.push({ role: 'user', content: err });
139
165
  }
140
- // Checkpoint: store pre-execution file state
141
- if (CHECKPOINT_TOOLS.has(tc.name)) {
142
- const path = tc.args.path;
143
- if (path && !checkpointRef.current.has(path)) {
144
- try {
145
- checkpointRef.current.set(path, readFileSync(path, 'utf-8'));
166
+ }
167
+ }
168
+ finally {
169
+ setCurrentTool(undefined);
170
+ }
171
+ }
172
+ else {
173
+ try {
174
+ for (const tc of pendingTools) {
175
+ const allTools = [...staticTools, ...extraToolsRef.current];
176
+ const tool = allTools.find(t => t.name === tc.name);
177
+ setCurrentTool(tc.name);
178
+ if (PERMISSION_TOOLS.has(tc.name)) {
179
+ const sessionKey = tc.name;
180
+ let decision;
181
+ if (sessionApprovedRef.current.has(sessionKey)) {
182
+ decision = 'yes';
183
+ }
184
+ else {
185
+ decision = await new Promise(resolve => {
186
+ permissionResolveRef.current = resolve;
187
+ setPermissionRequest({ toolName: tc.name, args: tc.args });
188
+ });
189
+ }
190
+ if (decision === 'session')
191
+ sessionApprovedRef.current.add(sessionKey);
192
+ if (decision === 'no') {
193
+ printer.systemMsg(`denied: ${tc.name}`);
194
+ const remaining = pendingTools.slice(pendingTools.indexOf(tc) + 1).map(t => t.name);
195
+ const skippedNote = remaining.length ? ` The following tools were also skipped: ${remaining.join(', ')}.` : '';
196
+ next.push({ role: 'user', content: `Tool ${tc.name} was denied by the user.${skippedNote} Do not retry these tools unless the user explicitly asks.` });
197
+ break;
198
+ }
199
+ // Checkpoint: store pre-execution file state + auto-branch on first edit
200
+ if (CHECKPOINT_TOOLS.has(tc.name)) {
201
+ const path = tc.args.path;
202
+ if (path && !checkpointRef.current.has(path)) {
203
+ try {
204
+ checkpointRef.current.set(path, readFileSync(path, 'utf-8'));
205
+ }
206
+ catch {
207
+ checkpointRef.current.set(path, null);
208
+ }
146
209
  }
147
- catch {
148
- checkpointRef.current.set(path, null);
210
+ if (!autoBranchedRef.current) {
211
+ try {
212
+ const { stdout } = await runCmd('git rev-parse --abbrev-ref HEAD', { timeout: 3000 });
213
+ const branch = stdout.trim();
214
+ if (branch === 'main' || branch === 'master') {
215
+ const ts = new Date().toISOString().slice(0, 16).replace(/[T:]/g, '-');
216
+ const newBranch = `miii/task-${ts}`;
217
+ await runCmd(`git checkout -b ${newBranch}`, { timeout: 5000 });
218
+ autoBranchedRef.current = newBranch;
219
+ printer.systemMsg(`auto-branched: ${newBranch}`);
220
+ }
221
+ }
222
+ catch { }
149
223
  }
150
224
  }
151
225
  }
152
- }
153
- if (tool) {
154
- try {
155
- // Guard: for update_file, verify old text still matches before executing.
156
- // If stale, inject fresh file content and skip — model will retry.
157
- if (tc.name === 'update_file') {
158
- const filePath = tc.args.path;
159
- const oldText = tc.args.old;
160
- if (filePath && oldText && existsSync(filePath)) {
161
- const current = readFileSync(filePath, 'utf-8');
162
- const occurrences = current.split(oldText).length - 1;
163
- if (occurrences === 0) {
164
- printer.errorMsg(`patch stale: old text not found in ${filePath} — injecting fresh content`);
165
- next.push({ role: 'user', content: `Tool read_file result:\n${current}` });
166
- next.push({ role: 'user', content: `update_file failed: the <old> text you used does not exist in ${filePath}. The CURRENT file content is shown above. Re-read it carefully, find the exact text you want to replace, and retry update_file using text that exactly matches what is in the file now.` });
167
- continue;
226
+ if (tool) {
227
+ try {
228
+ // Guard: for update_file, verify old text still matches before executing.
229
+ // If stale, inject fresh file content and skip — model will retry.
230
+ if (tc.name === 'update_file') {
231
+ const filePath = tc.args.path;
232
+ const oldText = tc.args.old;
233
+ if (filePath && oldText && existsSync(filePath)) {
234
+ const norm = (s) => s.replace(/\r\n/g, '\n');
235
+ const current = readFileSync(filePath, 'utf-8');
236
+ const occurrences = norm(current).split(norm(oldText)).length - 1;
237
+ if (occurrences === 0) {
238
+ printer.errorMsg(`patch stale: old text not found in ${filePath} — injecting fresh content`);
239
+ next.push({ role: 'user', content: `Tool read_file result:\n${current}` });
240
+ next.push({ role: 'user', content: `update_file failed: the <old> text you used does not exist in ${filePath}. The CURRENT file content is shown above. Re-read it carefully, find the exact text you want to replace, and retry update_file using text that exactly matches what is in the file now.` });
241
+ continue;
242
+ }
243
+ if (occurrences > 1) {
244
+ printer.errorMsg(`patch ambiguous: old text matches ${occurrences} locations in ${filePath} — injecting fresh content`);
245
+ next.push({ role: 'user', content: `Tool read_file result:\n${current}` });
246
+ next.push({ role: 'user', content: `update_file failed: the <old> text matches ${occurrences} locations in ${filePath}. Add more surrounding lines to the <old> block to make it unique, then retry.` });
247
+ continue;
248
+ }
168
249
  }
169
- if (occurrences > 1) {
170
- printer.errorMsg(`patch ambiguous: old text matches ${occurrences} locations in ${filePath} — injecting fresh content`);
171
- next.push({ role: 'user', content: `Tool read_file result:\n${current}` });
172
- next.push({ role: 'user', content: `update_file failed: the <old> text matches ${occurrences} locations in ${filePath}. Add more surrounding lines to the <old> block to make it unique, then retry.` });
173
- continue;
250
+ }
251
+ printer.toolCallStart(tc.name, tc.args);
252
+ const result = await tool.execute(tc.args);
253
+ printer.toolResultSummary(tc.name, tc.args, result);
254
+ if (SHOW_RESULT_TOOLS.has(tc.name))
255
+ printer.toolMsg(tc.name, result);
256
+ next.push({ role: 'user', content: `Tool ${tc.name} result:\n${result}` });
257
+ if (FILE_EDIT_TOOLS.has(tc.name)) {
258
+ const filePath = tc.args.path;
259
+ if (filePath && existsSync(filePath)) {
260
+ const lineCount = readFileSync(filePath, 'utf-8').split('\n').length;
261
+ next.push({ role: 'user', content: `[file updated: ${filePath} — ${lineCount} lines]` });
174
262
  }
175
263
  }
176
264
  }
177
- printer.toolCallStart(tc.name, tc.args);
178
- const result = await tool.execute(tc.args);
179
- printer.toolResultSummary(tc.name, tc.args, result);
180
- if (SHOW_RESULT_TOOLS.has(tc.name))
181
- printer.toolMsg(tc.name, result);
182
- next.push({ role: 'user', content: `Tool ${tc.name} result:\n${result}` });
183
- if (FILE_EDIT_TOOLS.has(tc.name)) {
184
- const filePath = tc.args.path;
185
- if (filePath && existsSync(filePath)) {
186
- const lineCount = readFileSync(filePath, 'utf-8').split('\n').length;
187
- next.push({ role: 'user', content: `[file updated: ${filePath} — ${lineCount} lines]` });
188
- }
265
+ catch (e) {
266
+ const err = `Tool ${tc.name} error: ${e}`;
267
+ printer.errorMsg(err);
268
+ next.push({ role: 'user', content: err });
189
269
  }
190
270
  }
191
- catch (e) {
192
- const err = `Tool ${tc.name} error: ${e}`;
193
- printer.errorMsg(err);
194
- next.push({ role: 'user', content: err });
271
+ else {
272
+ printer.errorMsg(`unknown tool: ${tc.name}`);
273
+ next.push({ role: 'user', content: `unknown tool: ${tc.name}` });
195
274
  }
196
275
  }
197
- else {
198
- printer.errorMsg(`unknown tool: ${tc.name}`);
199
- next.push({ role: 'user', content: `unknown tool: ${tc.name}` });
200
- }
201
276
  }
202
- }
203
- finally {
204
- setCurrentTool(undefined);
205
- }
277
+ finally {
278
+ setCurrentTool(undefined);
279
+ }
280
+ } // end sequential else
206
281
  // For file-edit turns: slim context (system + goal + fresh file states + recent results)
207
282
  // For non-edit turns: full next (model needs full conversational context)
208
283
  const didEditFiles = pendingTools.some(tc => FILE_EDIT_TOOLS.has(tc.name));
209
284
  if (didEditFiles) {
210
285
  const systemMsg = msgs.find(m => m.role === 'system');
211
- const goalMsg = msgs.find(m => m.role === 'user' && !m.content.startsWith('[') && !m.content.startsWith('Tool '));
286
+ const goalMsg = msgs.find(m => m.role === 'user' && !m.content.startsWith('[') && !m.content.startsWith('Tool '))
287
+ ?? (goal ? { role: 'user', content: goal } : undefined);
212
288
  const batchStart = msgs.length; // include assistant message so model sees its own tool call on retry
213
289
  const batchMsgs = next.slice(batchStart);
214
290
  const slimCtx = [
@@ -257,6 +333,10 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
257
333
  if (restored > 0)
258
334
  printer.systemMsg(`restored ${restored} file(s) to pre-session state`);
259
335
  }
336
+ if (autoBranchedRef.current) {
337
+ printer.systemMsg(`task branch preserved: ${autoBranchedRef.current}`);
338
+ autoBranchedRef.current = null;
339
+ }
260
340
  setStatus('idle');
261
341
  }, []);
262
342
  return {
@@ -9,7 +9,7 @@ const SHORT_MEMORY_SIZE = 50;
9
9
  function buildSystemPrompt(cwd, facts, extraTools = []) {
10
10
  return getSystemPrompt(`\n- CWD: ${cwd}`, extraTools) + formatMemoryBlock(facts);
11
11
  }
12
- export function useSession(initialSession, cwd, config, extraTools = []) {
12
+ export function useSession(initialSession, cwd, config, extraTools = [], currentModelRef) {
13
13
  const projectDir = getProjectDir(cwd);
14
14
  const [sessionName, setSessionName] = useState(initialSession);
15
15
  const sessionNameRef = useRef(initialSession);
@@ -49,7 +49,7 @@ export function useSession(initialSession, cwd, config, extraTools = []) {
49
49
  if (historyRef.current.length > SHORT_MEMORY_SIZE && !extractingRef.current) {
50
50
  const dropped = historyRef.current.splice(0, historyRef.current.length - SHORT_MEMORY_SIZE);
51
51
  extractingRef.current = true;
52
- extractFacts(dropped, config, config.model).then(newFacts => {
52
+ extractFacts(dropped, config, currentModelRef?.current ?? config.model).then(newFacts => {
53
53
  if (newFacts.length) {
54
54
  const updated = mergeFacts(longMemoryRef.current, newFacts);
55
55
  longMemoryRef.current = updated;
@@ -326,6 +326,18 @@ Analyze what exists, then implement the design. Use the design system above if a
326
326
  }
327
327
  return;
328
328
  }
329
+ if (cmd.startsWith('/plan exec ')) {
330
+ const task = cmd.slice(11).trim();
331
+ if (!task) {
332
+ printer.systemMsg('usage: /plan exec <task>');
333
+ return;
334
+ }
335
+ const planPrompt = `PLANNING TURN — output a numbered plan of exactly what you will do to accomplish this task. List which files to read, which to edit, and what changes to make. Do NOT call any tools in this response. After I review the plan and respond "go", you will execute.\n\nTask: ${task}`;
336
+ printer.userMsg(`/plan exec ${task}`);
337
+ pushHistory({ role: 'user', content: planPrompt });
338
+ await runLoop(buildContext(), 0, task, { noTools: true });
339
+ return;
340
+ }
329
341
  if (cmd === '/plan' || cmd.startsWith('/plan ')) {
330
342
  const topic = cmd.slice(5).trim();
331
343
  setPlanningMode(true);
@@ -81,7 +81,7 @@ export function toolArgSummary(args) {
81
81
  const first = Object.values(args)[0];
82
82
  return first ? truncate(String(first), 60) : '';
83
83
  }
84
- export function welcome(provider, model, cwd, version, updateAvailable, linked) {
84
+ export function welcome(cwd, version, updateAvailable, linked) {
85
85
  const cols = Math.min(process.stdout.columns ?? 80, 100);
86
86
  const innerW = cols - 2;
87
87
  const leftW = Math.floor(innerW * 0.44);
@@ -114,7 +114,6 @@ export function welcome(provider, model, cwd, version, updateAvailable, linked)
114
114
  '',
115
115
  ...miniArt,
116
116
  '',
117
- ` ${gray(model + ' · ' + provider)}`,
118
117
  ` ${gray(shortCwd)}`,
119
118
  '',
120
119
  ];
@@ -162,8 +161,11 @@ export function assistantMsg(text) {
162
161
  const tail = lines.slice(idx + 1).join('\n');
163
162
  write(`\n${blue('●')} ${head}${tail ? '\n' + tail : ''}\n`);
164
163
  }
165
- export const EDIT_TOOLS = new Set(['edit_file', 'update_file', 'create_file', 'write_file']);
166
- export const DELETE_TOOLS = new Set(['delete_file', 'remove_file']);
164
+ export function streamStart() { write(`\n${blue('')} `); }
165
+ export function streamChunk(s) { write(s); }
166
+ export function streamEnd() { write('\n'); }
167
+ export const EDIT_TOOLS = new Set(['edit_file', 'update_file', 'create_file']);
168
+ export const DELETE_TOOLS = new Set(['delete_file']);
167
169
  const PERM_DESC = {
168
170
  delete_file: 'delete this file',
169
171
  update_file: 'edit this file',
@@ -292,8 +294,7 @@ export function toolResultSummary(name, args, result) {
292
294
  const lines = result.trim().split('\n').filter(Boolean);
293
295
  let summary = '';
294
296
  switch (name) {
295
- case 'edit_file':
296
- case 'write_file': {
297
+ case 'edit_file': {
297
298
  const n = (a.content ?? '').split('\n').length;
298
299
  summary = `Wrote ${n} line${n === 1 ? '' : 's'}`;
299
300
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "miii-cli",
3
- "version": "1.2.4",
3
+ "version": "1.3.1",
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",