miii-cli 1.0.2 → 1.1.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.
package/README.md CHANGED
@@ -72,20 +72,25 @@ This isn't autocomplete. Miii is a **full autonomous agent loop:**
72
72
 
73
73
  ● Researching: refactor auth module to use JWT
74
74
  ● Reading src/auth/session.ts
75
+ Read 42 lines
75
76
  ● Reading src/middleware/auth.ts
76
- Reading src/routes/login.ts
77
+ Read 28 lines
77
78
 
78
- Planning: 3 file(s) to change
79
+ plan (2 actions)
80
+ ◦ edit_file src/auth/session.ts
81
+ ◦ edit_file src/middleware/auth.ts
79
82
 
80
83
  ⚠ edit_file src/auth/session.ts y approve n deny
81
84
  > y
82
85
 
83
- Editing src/auth/session.ts
84
- Editing src/middleware/auth.ts
85
- Editing src/routes/login.ts
86
- Running tests
86
+ edit_file src/auth/session.ts
87
+ Wrote 12 lines
88
+ edit_file src/middleware/auth.ts
89
+ Wrote 8 lines
90
+ ● run_tests
91
+ ✅ Tests passed
87
92
 
88
- ─ refactor done — 3 file(s) processed
93
+ ─ refactor done — 2 file(s) processed
89
94
  ```
90
95
 
91
96
  ---
@@ -1,52 +1,91 @@
1
- const COMPACT_THRESHOLD = 18; // compact when context exceeds this many messages
2
- const KEEP_RECENT = 6; // always keep last N messages verbatim
1
+ import { chat } from '../llm/stream.js';
2
+ const COMPACT_THRESHOLD = 18;
3
+ const KEEP_RECENT = 6;
3
4
  export function shouldCompact(messages) {
4
5
  return messages.length > COMPACT_THRESHOLD;
5
6
  }
6
- /**
7
- * Compact context to keep local models on track during long refactors.
8
- *
9
- * Strategy:
10
- * 1. Keep system prompt (index 0)
11
- * 2. Keep first user message (original goal)
12
- * 3. Summarise completed tool results in the middle into one message
13
- * 4. Keep last KEEP_RECENT messages verbatim (model's working memory)
14
- */
15
- export function compactContext(messages, goal) {
7
+ const COMPACT_SYSTEM = `You are a context summarizer for an AI coding agent session.
8
+ Your job: produce a dense, structured summary of the conversation so the agent can continue the task without losing context.
9
+
10
+ Output format (use exactly these headers):
11
+
12
+ ## Task
13
+ One sentence: what the user asked for.
14
+
15
+ ## Completed
16
+ Bullet list of actions taken (files edited, commands run, decisions made). Be specific — include file paths and outcomes.
17
+
18
+ ## Current State
19
+ What is true right now: which files were changed, what tests showed, what is working or broken.
20
+
21
+ ## Remaining
22
+ What still needs to be done, if anything.
23
+
24
+ ## Key Context
25
+ Any constraints, errors encountered, important facts the agent must remember to continue correctly.
26
+
27
+ Be factual. No padding. Include file paths, error messages, and command outputs verbatim when relevant.`;
28
+ export async function compactContext(messages, cfg, goal) {
16
29
  if (messages.length <= COMPACT_THRESHOLD)
17
30
  return messages;
18
31
  const system = messages[0]?.role === 'system' ? messages[0] : null;
32
+ const recent = messages.slice(messages.length - KEEP_RECENT);
33
+ const toSummarize = messages.slice(system ? 1 : 0, messages.length - KEEP_RECENT);
34
+ // Build conversation transcript for the summarizer
35
+ const transcript = toSummarize.map(m => {
36
+ const role = m.role === 'assistant' ? 'Assistant' : 'User';
37
+ const body = m.content.length > 2000 ? m.content.slice(0, 2000) + '\n[truncated]' : m.content;
38
+ return `### ${role}\n${body}`;
39
+ }).join('\n\n');
40
+ const userPrompt = [
41
+ goal ? `The user's goal: ${goal}\n` : '',
42
+ `Conversation to summarize:\n\n${transcript}`,
43
+ ].join('');
44
+ let summary = '';
45
+ await chat({
46
+ ...cfg,
47
+ messages: [
48
+ { role: 'system', content: COMPACT_SYSTEM },
49
+ { role: 'user', content: userPrompt },
50
+ ],
51
+ onDone: (text) => { summary = text.trim(); },
52
+ onError: () => { },
53
+ });
54
+ // Fallback to dumb compaction if LLM fails
55
+ if (!summary)
56
+ return dumbCompact(messages, goal);
57
+ const summaryMsg = {
58
+ role: 'user',
59
+ content: `[Context compacted — ${toSummarize.length} messages summarised]\n\n${summary}`,
60
+ };
61
+ return [
62
+ ...(system ? [system] : []),
63
+ summaryMsg,
64
+ ...recent,
65
+ ];
66
+ }
67
+ function dumbCompact(messages, goal) {
68
+ const system = messages[0]?.role === 'system' ? messages[0] : null;
19
69
  const userGoal = messages.find(m => m.role === 'user' && !m.content.startsWith('['));
20
- const anchorCount = (system ? 1 : 0) + (userGoal ? 1 : 0);
21
- const middle = messages.slice(anchorCount, messages.length - KEEP_RECENT);
22
70
  const recent = messages.slice(messages.length - KEEP_RECENT);
71
+ const middle = messages.slice((system ? 1 : 0) + (userGoal ? 1 : 0), messages.length - KEEP_RECENT);
23
72
  const toolResults = middle
24
73
  .filter(m => m.role === 'user' && m.content.startsWith('Tool '))
25
- .map(m => {
26
- const lines = m.content.split('\n');
27
- return `• ${lines[0]}`; // just the "Tool X result:" line
28
- });
29
- const assistantSummaries = middle
30
- .filter(m => m.role === 'assistant' && m.content.trim().length > 0)
31
- .map(m => m.content.slice(0, 120).replace(/\n/g, ' '));
74
+ .map(m => `• ${m.content.split('\n')[0]}`);
32
75
  const parts = [`[context compacted — ${middle.length} messages summarised]`];
33
76
  if (goal)
34
77
  parts.push(`Goal: ${goal}`);
35
78
  if (toolResults.length)
36
79
  parts.push(`Completed:\n${toolResults.join('\n')}`);
37
- if (assistantSummaries.length)
38
- parts.push(`Last reasoning: ${assistantSummaries.at(-1)}`);
39
- const summary = { role: 'user', content: parts.join('\n\n') };
40
80
  return [
41
81
  ...(system ? [system] : []),
42
82
  ...(userGoal ? [userGoal] : []),
43
- summary,
83
+ { role: 'user', content: parts.join('\n\n') },
44
84
  ...recent,
45
85
  ];
46
86
  }
47
87
  /**
48
88
  * Build a fresh isolated context for a single-file edit step.
49
- * Keeps context tiny — avoids cross-file noise polluting the model.
50
89
  */
51
90
  export function fileEditContext(systemPrompt, goal, filePath, fileContent, instruction) {
52
91
  return [
@@ -34,7 +34,7 @@ export const tools = [
34
34
  },
35
35
  {
36
36
  name: 'create_file',
37
- description: 'Create a new file — fails if file already exists',
37
+ description: 'Create a new file with content — fails if file already exists. Prefer edit_file for new files.',
38
38
  params: '{"path": "string", "content": "string"}',
39
39
  execute: async ({ path, content }) => {
40
40
  const safe = guardPath(path);
@@ -46,16 +46,24 @@ export const tools = [
46
46
  },
47
47
  {
48
48
  name: 'edit_file',
49
- description: 'Overwrite entire file — use only for new files or full rewrites',
49
+ description: 'Write a new file — only for files that do not exist yet. Use patch_file to modify existing files.',
50
50
  params: '{"path": "string", "content": "string"}',
51
51
  execute: async ({ path, content }) => {
52
- writeFile(guardPath(path), content);
53
- return `written: ${path}`;
52
+ const safe = guardPath(path);
53
+ if (existsSync(safe)) {
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` +
56
+ `Call read_file first to get the exact current text.`);
57
+ }
58
+ const text = content;
59
+ writeFile(safe, text);
60
+ const lines = text.split('\n').length;
61
+ return `created: ${path} (${lines} line${lines === 1 ? '' : 's'})`;
54
62
  },
55
63
  },
56
64
  {
57
65
  name: 'patch_file',
58
- description: 'Replace an exact string in a file use for targeted edits to existing files',
66
+ description: 'Replace an exact unique string in an existing file. Always call read_file first to get the exact text.',
59
67
  params: '{"path": "string", "old": "string", "new": "string"}',
60
68
  execute: async ({ path, old: oldStr, new: newStr }) => {
61
69
  const safe = guardPath(path);
@@ -64,12 +72,29 @@ export const tools = [
64
72
  throw new Error(`file not found or empty: ${path}`);
65
73
  const old = oldStr;
66
74
  const count = current.split(old).length - 1;
67
- if (count === 0)
68
- throw new Error(`old text not found in ${path}`);
69
- if (count > 1)
70
- throw new Error(`ambiguous: ${count} matches found in ${path} — add more surrounding context to make unique`);
71
- writeFile(safe, current.replace(old, newStr));
72
- return `patched: ${path}`;
75
+ if (count === 0) {
76
+ throw new Error(`old text not found in ${path} — file may have changed since last read.\n` +
77
+ `Call read_file again to get current content, then retry with exact matching text.`);
78
+ }
79
+ if (count > 1) {
80
+ throw new Error(`ambiguous: ${count} matches found in ${path} — extend <old> block with more surrounding lines to make it unique`);
81
+ }
82
+ const updated = current.replace(old, newStr);
83
+ writeFile(safe, updated);
84
+ // Compute affected line range for the snippet
85
+ const startLine = current.slice(0, current.indexOf(old)).split('\n').length;
86
+ const oldLines = old.split('\n').length;
87
+ const newLines = newStr.split('\n').length;
88
+ const updatedArr = updated.split('\n');
89
+ const snippetStart = Math.max(0, startLine - 3);
90
+ const snippetEnd = Math.min(updatedArr.length, startLine + newLines + 2);
91
+ const snippet = updatedArr
92
+ .slice(snippetStart, snippetEnd)
93
+ .map((l, i) => `${String(snippetStart + i + 1).padStart(4)} │ ${l}`)
94
+ .join('\n');
95
+ const delta = newLines - oldLines;
96
+ const deltaStr = delta === 0 ? '' : delta > 0 ? ` (+${delta} line${delta === 1 ? '' : 's'})` : ` (${delta} line${Math.abs(delta) === 1 ? '' : 's'})`;
97
+ return `patched: ${path}${deltaStr}\n\nLines ${snippetStart + 1}–${snippetEnd}:\n${snippet}`;
73
98
  },
74
99
  },
75
100
  {
@@ -284,9 +309,10 @@ ${toolDocs}
284
309
  ${deepThinkDoc}
285
310
 
286
311
  Rules:
287
- - To modify an existing file: use patch_file with the exact old text and new replacement do NOT rewrite the whole file
288
- - To create a new file: use edit_file with full content in the <content> block
289
- - read_file before patch_file so you know the exact text to match
312
+ - edit_file only works on NEW files it throws an error if the file exists. Never call it on existing files
313
+ - To modify any existing file: call read_file first, then patch_file with the exact text from that read as the <old> block
314
+ - Never guess or reuse old text from earlier in the conversation always re-read immediately before patching
315
+ - If patch_file reports "old text not found", call read_file again and retry with the exact current text
290
316
  - Never delete without confirming
291
317
  - Use git_status and git_diff before any refactor to understand what has already changed
292
318
  - Use git_log to understand recent history before suggesting changes
@@ -7,6 +7,7 @@ import { AtPicker } from './AtPicker.js';
7
7
  const BUILTIN_COMMANDS = [
8
8
  // ── Session ──────────────────────────────────────────────────────────────
9
9
  { ns: 'builtin', name: 'new', description: 'start a fresh session with a new auto-named history' },
10
+ { ns: 'builtin', name: 'compact', description: 'summarise conversation history now using the LLM — frees context before miii asks' },
10
11
  { ns: 'builtin', name: 'clear', description: 'wipe chat history for the current session' },
11
12
  { ns: 'builtin', name: 'sessions', description: 'list all saved sessions with message counts' },
12
13
  { ns: 'builtin', name: 'session', description: 'switch to a saved session — /session <name>' },
@@ -197,11 +198,15 @@ export function InputArea({ status, skills, cwd, planningMode, permissionRequest
197
198
  useInput((input, key) => {
198
199
  if (permissionRequest && onPermissionResponse) {
199
200
  if (input === 'y' || input === 'Y') {
200
- onPermissionResponse(true);
201
+ onPermissionResponse('yes');
202
+ return;
203
+ }
204
+ if (input === 'a' || input === 'A') {
205
+ onPermissionResponse('session');
201
206
  return;
202
207
  }
203
208
  if (input === 'n' || input === 'N' || key.escape) {
204
- onPermissionResponse(false);
209
+ onPermissionResponse('no');
205
210
  return;
206
211
  }
207
212
  return;
@@ -445,13 +450,14 @@ export function InputArea({ status, skills, cwd, planningMode, permissionRequest
445
450
  });
446
451
  const { stdout } = useStdout();
447
452
  const cols = stdout.columns ?? 80;
453
+ const availWidth = Math.max(20, cols - 4); // paddingX(2) + "> "(2)
448
454
  const isProcessing = status !== 'idle';
449
455
  const promptColor = (permissionRequest || compactRequest) ? 'yellow' : isProcessing ? 'yellow' : 'green';
450
456
  const inHistory = historyIdx !== -1;
451
457
  const hint = compactRequest
452
458
  ? 'y compact n keep full context'
453
459
  : permissionRequest
454
- ? 'y approve n deny'
460
+ ? 'y approve once a approve for session n deny'
455
461
  : isProcessing
456
462
  ? 'esc interrupt'
457
463
  : pasteLines > 0
@@ -466,10 +472,28 @@ export function InputArea({ status, skills, cwd, planningMode, permissionRequest
466
472
  const pastePreview = pasteRef.current
467
473
  ? pasteRef.current.split('\n')[0].slice(0, cols - 6)
468
474
  : '';
469
- return (_jsxs(Box, { flexDirection: "column", children: [overlay === 'command' && (_jsx(CommandPalette, { skills: allCommands, query: commandQuery, idx: overlayIdx })), overlay === 'at' && (_jsx(AtPicker, { files: filteredFiles, query: atQuery, idx: overlayIdx })), _jsx(Text, { color: "gray", dimColor: true, children: '─'.repeat(Math.max(cols, 10)) }), _jsxs(Box, { paddingX: 1, children: [_jsx(Text, { color: promptColor, bold: true, children: '> ' }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: (permissionRequest || compactRequest) ? (_jsxs(Box, { gap: 2, children: [_jsx(Text, { color: "green", bold: true, children: "y yes" }), _jsx(Text, { color: "red", bold: true, children: "n no" })] })) : pasteLines > 0 ? (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "cyan", children: "\u2398" }), _jsxs(Text, { color: "cyan", children: ["pasted ", pasteLines, " line", pasteLines !== 1 ? 's' : ''] }), (lines.length > 1 || lines[0]) && (_jsx(Text, { color: "gray", dimColor: true, children: "+ typed text" }))] }), pastePreview && (_jsxs(Text, { color: "gray", dimColor: true, children: [" ", pastePreview, pasteRef.current.split('\n')[0].length > cols - 6 ? '…' : ''] }))] })) : lines.length === 1 && !lines[0] ? (_jsx(Text, { children: isActive ? '█' : ' ' })) : (lines.map((line, i) => (_jsx(Text, { wrap: "wrap", children: i === cursor.row
470
- ? renderLineWithCursor(line, cursor.col, isActive)
471
- : line }, i)))) })] }), _jsx(Text, { color: "gray", dimColor: true, children: '─'.repeat(Math.max(cols, 10)) }), _jsxs(Text, { color: "gray", dimColor: true, children: [" ", hint] })] }));
475
+ return (_jsxs(Box, { flexDirection: "column", children: [overlay === 'command' && (_jsx(CommandPalette, { skills: allCommands, query: commandQuery, idx: overlayIdx })), overlay === 'at' && (_jsx(AtPicker, { files: filteredFiles, query: atQuery, idx: overlayIdx })), _jsx(Text, { color: "gray", dimColor: true, children: '─'.repeat(Math.max(cols, 10)) }), _jsxs(Box, { paddingX: 1, children: [_jsx(Text, { color: promptColor, bold: true, children: '> ' }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: permissionRequest ? (_jsxs(Box, { gap: 3, children: [_jsx(Text, { color: "green", bold: true, children: "y once" }), _jsx(Text, { color: "cyan", bold: true, children: "a session" }), _jsx(Text, { color: "red", bold: true, children: "n deny" })] })) : compactRequest ? (_jsxs(Box, { gap: 2, children: [_jsx(Text, { color: "green", bold: true, children: "y yes" }), _jsx(Text, { color: "red", bold: true, children: "n no" })] })) : pasteLines > 0 ? (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "cyan", children: "\u2398" }), _jsxs(Text, { color: "cyan", children: ["pasted ", pasteLines, " line", pasteLines !== 1 ? 's' : ''] }), (lines.length > 1 || lines[0]) && (_jsx(Text, { color: "gray", dimColor: true, children: "+ typed text" }))] }), pastePreview && (_jsxs(Text, { color: "gray", dimColor: true, children: [" ", pastePreview, pasteRef.current.split('\n')[0].length > cols - 6 ? '…' : ''] }))] })) : lines.length === 1 && !lines[0] ? (_jsx(Text, { children: isActive ? '█' : ' ' })) : (lines.map((line, i) => (_jsx(Text, { children: i === cursor.row
476
+ ? viewportLine(line, cursor.col, availWidth, isActive)
477
+ : line.length > availWidth ? '…' + line.slice(line.length - availWidth + 1) : line }, i)))) })] }), _jsx(Text, { color: "gray", dimColor: true, children: '─'.repeat(Math.max(cols, 10)) }), _jsxs(Text, { color: "gray", dimColor: true, children: [" ", hint] })] }));
472
478
  }
473
479
  function renderLineWithCursor(line, col, showCursor) {
474
480
  return line.slice(0, col) + (showCursor ? '█' : '') + line.slice(col);
475
481
  }
482
+ function viewportLine(line, col, width, active) {
483
+ // If line fits, render normally
484
+ if (line.length < width)
485
+ return renderLineWithCursor(line, col, active);
486
+ // Slide window so cursor stays in view, roughly centered
487
+ let start = Math.max(0, col - Math.floor(width / 2));
488
+ if (start + width > line.length + 1) {
489
+ start = Math.max(0, line.length + 1 - width);
490
+ }
491
+ const hasLeft = start > 0;
492
+ const sliceW = width - (hasLeft ? 1 : 0) - 1; // -1 for right indicator space
493
+ const slice = line.slice(start, start + sliceW);
494
+ const hasRight = start + sliceW < line.length;
495
+ const adjCol = col - start;
496
+ return (hasLeft ? '…' : '') +
497
+ renderLineWithCursor(slice, Math.max(0, Math.min(adjCol, slice.length)), active) +
498
+ (hasRight ? '…' : '');
499
+ }
@@ -20,13 +20,14 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
20
20
  const [compactRequest, setCompactRequest] = useState(null);
21
21
  const compactResolveRef = useRef(null);
22
22
  const checkpointRef = useRef(new Map());
23
+ const sessionApprovedRef = useRef(new Set());
23
24
  const thinkingStartRef = useRef(0);
24
25
  const extraToolsRef = useRef(extraTools);
25
26
  extraToolsRef.current = extraTools;
26
27
  const pushHistoryRef = useRef(pushHistory);
27
28
  useEffect(() => { pushHistoryRef.current = pushHistory; }, [pushHistory]);
28
- const resolvePermission = useCallback((approved) => {
29
- permissionResolveRef.current?.(approved);
29
+ const resolvePermission = useCallback((result) => {
30
+ permissionResolveRef.current?.(result);
30
31
  permissionResolveRef.current = null;
31
32
  setPermissionRequest(null);
32
33
  }, []);
@@ -58,9 +59,19 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
58
59
  compactResolveRef.current = resolve;
59
60
  setCompactRequest({ messageCount: contextMsgs.length });
60
61
  });
61
- msgs = approved ? compactContext(contextMsgs, goal) : contextMsgs;
62
- if (!approved)
62
+ if (approved) {
63
+ printer.systemMsg('compacting context…');
64
+ msgs = await compactContext(contextMsgs, {
65
+ provider: config.provider,
66
+ model: currentModelRef.current,
67
+ baseUrl: config.baseUrl,
68
+ apiKey: config.apiKey,
69
+ }, goal);
70
+ printer.systemMsg(`compacted: ${contextMsgs.length} → ${msgs.length} messages`);
71
+ }
72
+ else {
63
73
  printer.systemMsg('keeping full context — responses may be slower');
74
+ }
64
75
  }
65
76
  abortRef.current = new AbortController();
66
77
  await chat({
@@ -88,6 +99,8 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
88
99
  if (displayText)
89
100
  printer.assistantMsg(displayText);
90
101
  pushHistoryRef.current({ role: 'assistant', content: fullText });
102
+ if (pendingTools.length)
103
+ printer.planSummary(pendingTools);
91
104
  if (!pendingTools.length) {
92
105
  const hasFencedCode = /```[\w]*\n[\s\S]{50,}?\n```/.test(fullText);
93
106
  if (hasFencedCode && depth < MAX_TOOL_DEPTH - 1) {
@@ -109,11 +122,20 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
109
122
  const tool = allTools.find(t => t.name === tc.name);
110
123
  setCurrentTool(tc.name);
111
124
  if (PERMISSION_TOOLS.has(tc.name)) {
112
- const approved = await new Promise(resolve => {
113
- permissionResolveRef.current = resolve;
114
- setPermissionRequest({ toolName: tc.name, args: tc.args });
115
- });
116
- if (!approved) {
125
+ const sessionKey = tc.name;
126
+ let decision;
127
+ if (sessionApprovedRef.current.has(sessionKey)) {
128
+ decision = 'yes';
129
+ }
130
+ else {
131
+ decision = await new Promise(resolve => {
132
+ permissionResolveRef.current = resolve;
133
+ setPermissionRequest({ toolName: tc.name, args: tc.args });
134
+ });
135
+ }
136
+ if (decision === 'session')
137
+ sessionApprovedRef.current.add(sessionKey);
138
+ if (decision === 'no') {
117
139
  printer.systemMsg(`denied: ${tc.name}`);
118
140
  next.push({ role: 'user', content: `Tool ${tc.name} was denied by the user` });
119
141
  break;
@@ -133,11 +155,35 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
133
155
  }
134
156
  if (tool) {
135
157
  try {
158
+ // Guard: for patch_file, verify old text still matches before executing.
159
+ // If stale, inject fresh file content and skip — model will retry.
160
+ if (tc.name === 'patch_file') {
161
+ const filePath = tc.args.path;
162
+ const oldText = tc.args.old;
163
+ if (filePath && oldText && existsSync(filePath)) {
164
+ const current = readFileSync(filePath, 'utf-8');
165
+ if (!current.includes(oldText)) {
166
+ printer.errorMsg(`patch stale: old text not found in ${filePath} — injecting fresh content`);
167
+ next.push({ role: 'user', content: `Tool read_file result:\n${current}` });
168
+ 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.` });
169
+ continue;
170
+ }
171
+ }
172
+ }
136
173
  printer.toolCallStart(tc.name, tc.args);
137
174
  const result = await tool.execute(tc.args);
175
+ printer.toolResultSummary(tc.name, tc.args, result);
138
176
  if (SHOW_RESULT_TOOLS.has(tc.name))
139
177
  printer.toolMsg(tc.name, result);
140
178
  next.push({ role: 'user', content: `Tool ${tc.name} result:\n${result}` });
179
+ // After any file edit, inject fresh file state so next tool sees actual content
180
+ if (FILE_EDIT_TOOLS.has(tc.name)) {
181
+ const filePath = tc.args.path;
182
+ if (filePath && existsSync(filePath)) {
183
+ const fresh = readFileSync(filePath, 'utf-8');
184
+ next.push({ role: 'user', content: `[current state of ${filePath} after edit]\n${fresh}` });
185
+ }
186
+ }
141
187
  }
142
188
  catch (e) {
143
189
  const err = `Tool ${tc.name} error: ${e}`;
@@ -164,6 +210,7 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
164
210
  printer.toolCallStart('run_tests', {});
165
211
  const testResult = await testTool.execute({});
166
212
  if (testResult && !testResult.startsWith('(no test script') && !testResult.startsWith('(no package.json')) {
213
+ printer.toolResultSummary('run_tests', {}, testResult);
167
214
  printer.toolMsg('run_tests', testResult);
168
215
  next.push({ role: 'user', content: `Test results after edits:\n${testResult}` });
169
216
  }
@@ -178,7 +225,23 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
178
225
  }
179
226
  }
180
227
  }
181
- await runLoop(next, depth + 1, goal);
228
+ // For file-edit turns: slim context (system + goal + fresh file states + recent results)
229
+ // For non-edit turns: full next (model needs full conversational context)
230
+ if (didEditFiles) {
231
+ const systemMsg = msgs.find(m => m.role === 'system');
232
+ const goalMsg = msgs.find(m => m.role === 'user' && !m.content.startsWith('[') && !m.content.startsWith('Tool '));
233
+ const batchStart = msgs.length + 1; // index in next where this batch's messages start
234
+ const batchMsgs = next.slice(batchStart);
235
+ const slimCtx = [
236
+ ...(systemMsg ? [systemMsg] : []),
237
+ ...(goalMsg ? [goalMsg] : []),
238
+ ...batchMsgs,
239
+ ];
240
+ await runLoop(slimCtx, depth + 1, goal);
241
+ }
242
+ else {
243
+ await runLoop(next, depth + 1, goal);
244
+ }
182
245
  },
183
246
  onError(err) {
184
247
  if (err.name !== 'AbortError')
@@ -189,8 +252,9 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
189
252
  }, [config]);
190
253
  const handleAbort = useCallback(() => {
191
254
  abortRef.current?.abort();
255
+ sessionApprovedRef.current.clear();
192
256
  if (permissionResolveRef.current) {
193
- permissionResolveRef.current(false);
257
+ permissionResolveRef.current('no');
194
258
  permissionResolveRef.current = null;
195
259
  setPermissionRequest(null);
196
260
  }
@@ -3,6 +3,7 @@ import { readFile, guardPath } from '../../files/ops.js';
3
3
  import { getSystemPrompt } from '../../tools/index.js';
4
4
  import { saveConfig } from '../../config.js';
5
5
  import { loadSession, saveSession, listSessions, deleteSession, deleteAllSessions } from '../../sessions.js';
6
+ import { compactContext } from '../../tasks/compactor.js';
6
7
  import { runDeepThink } from '../deepThink.js';
7
8
  import { buildGitContext, looksCodeRelated } from '../git-context.js';
8
9
  import { buildIndex } from '../../index/indexer.js';
@@ -162,6 +163,33 @@ export function useSubmit(deps) {
162
163
  printer.systemMsg(`model → ${name}`);
163
164
  return;
164
165
  }
166
+ if (cmd === '/compact') {
167
+ const full = buildContext();
168
+ if (full.length <= 2) {
169
+ printer.systemMsg('nothing to compact');
170
+ return;
171
+ }
172
+ printer.systemMsg(`compacting ${full.length} messages…`);
173
+ setStatus('thinking');
174
+ try {
175
+ const compacted = await compactContext(full, {
176
+ provider: config.provider,
177
+ model: currentModelRef.current,
178
+ baseUrl: config.baseUrl,
179
+ apiKey: config.apiKey,
180
+ }, undefined);
181
+ historyRef.current = compacted.filter(m => m.role !== 'system');
182
+ saveSession(sessionNameRef.current, historyRef.current);
183
+ printer.systemMsg(`compacted: ${full.length} → ${compacted.length} messages`);
184
+ }
185
+ catch (e) {
186
+ printer.errorMsg(`compact failed: ${e}`);
187
+ }
188
+ finally {
189
+ setStatus('idle');
190
+ }
191
+ return;
192
+ }
165
193
  if (cmd === '/new') {
166
194
  if (saveTimerRef.current) {
167
195
  clearTimeout(saveTimerRef.current);
@@ -192,12 +192,82 @@ function toolLabel(name, args) {
192
192
  }
193
193
  }
194
194
  }
195
+ export function planSummary(tools) {
196
+ if (!tools.length)
197
+ return;
198
+ const header = gray(`─ plan (${tools.length} action${tools.length === 1 ? '' : 's'})`);
199
+ write(header + '\n');
200
+ for (const t of tools) {
201
+ const dot = DELETE_TOOLS.has(t.name) ? red('◦') : EDIT_TOOLS.has(t.name) ? green('◦') : blue('◦');
202
+ const label = toolLabel(t.name, t.args);
203
+ write(` ${dot} ${gray(label)}\n`);
204
+ }
205
+ }
195
206
  export function toolCallStart(name, args) {
196
207
  const dot = DELETE_TOOLS.has(name) ? red('●') : EDIT_TOOLS.has(name) ? green('●') : blue('●');
197
- write(` ${dot} ${toolLabel(name, args)}\n`);
208
+ write(`\n${dot} ${bold(toolLabel(name, args))}\n`);
209
+ }
210
+ export function toolResultSummary(name, args, result) {
211
+ const a = args;
212
+ const lines = result.trim().split('\n').filter(Boolean);
213
+ let summary = '';
214
+ switch (name) {
215
+ case 'edit_file':
216
+ case 'write_file': {
217
+ const n = (a.content ?? '').split('\n').length;
218
+ summary = `Wrote ${n} line${n === 1 ? '' : 's'}`;
219
+ break;
220
+ }
221
+ case 'create_file': {
222
+ const n = (a.content ?? '').split('\n').length;
223
+ summary = `Created file · ${n} line${n === 1 ? '' : 's'}`;
224
+ break;
225
+ }
226
+ case 'patch_file':
227
+ summary = lines[0] ?? 'Applied patch';
228
+ break;
229
+ case 'delete_file':
230
+ summary = 'Deleted';
231
+ break;
232
+ case 'move_file':
233
+ summary = `Moved → ${a.to ?? ''}`;
234
+ break;
235
+ case 'read_file': {
236
+ const n = lines.length;
237
+ summary = `Read ${n} line${n === 1 ? '' : 's'}`;
238
+ break;
239
+ }
240
+ case 'list_files':
241
+ summary = `Found ${lines.length} file${lines.length === 1 ? '' : 's'}`;
242
+ break;
243
+ case 'run_command':
244
+ case 'run_tests':
245
+ case 'git_commit':
246
+ case 'git_status':
247
+ case 'git_diff':
248
+ case 'git_log': {
249
+ const first = lines[0]?.slice(0, 80) ?? '';
250
+ const more = lines.length > 1 ? ` (+${lines.length - 1} more)` : '';
251
+ summary = first + more;
252
+ break;
253
+ }
254
+ case 'web_search':
255
+ summary = `Found ${lines.length} result${lines.length === 1 ? '' : 's'}`;
256
+ break;
257
+ case 'web_extract':
258
+ summary = `Extracted ${lines.length} line${lines.length === 1 ? '' : 's'}`;
259
+ break;
260
+ case 'search_codebase':
261
+ summary = lines[0]?.slice(0, 80) ?? 'Done';
262
+ break;
263
+ default:
264
+ summary = lines[0]?.slice(0, 80) ?? 'Done';
265
+ }
266
+ if (summary)
267
+ write(gray(` ${summary}`) + '\n');
198
268
  }
199
269
  export function toolMsg(name, result) {
200
- const preview = result.length > 250 ? result.slice(0, 250) + '…' : result;
270
+ const preview = result.length > 600 ? result.slice(0, 600) + '…' : result;
201
271
  const body = preview.trim()
202
272
  ? preview.split('\n').map(l => gray(' ' + l)).join('\n')
203
273
  : '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "miii-cli",
3
- "version": "1.0.2",
3
+ "version": "1.1.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",