miii-cli 1.2.0 → 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.
- package/README.md +6 -6
- package/dist/__tests__/integration.test.js +7 -7
- package/dist/config.js +3 -1
- package/dist/memory/extractor.js +1 -1
- package/dist/parser/stream-parser.js +6 -6
- package/dist/sessions.js +1 -0
- package/dist/skills/loader.js +5 -0
- package/dist/tasks/compactor.js +3 -0
- package/dist/tools/index.js +9 -9
- package/dist/tui/InputBar.js +5 -13
- package/dist/tui/hooks/useRunLoop.js +17 -33
- package/dist/tui/hooks/useSubmit.js +24 -0
- package/dist/tui/image.js +83 -0
- package/dist/tui/printer.js +66 -3
- package/dist/tui/thinking.js +93 -50
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
**Miii is a fully autonomous coding agent that runs entirely on your machine.** It plans, edits files, runs your tests, searches the web, indexes your codebase semantically, and iterates until the job is done — all without a single byte of your code leaving your network.
|
|
15
15
|
|
|
16
|
-
Zero subscription. Zero cloud dependency. Zero Python overhead. **
|
|
16
|
+
Zero subscription. Zero cloud dependency. Zero Python overhead. **Lightning fast startup.**
|
|
17
17
|
|
|
18
18
|
```bash
|
|
19
19
|
npm install -g miii-cli && miii
|
|
@@ -52,9 +52,9 @@ Your compute. Your data. Your rules.
|
|
|
52
52
|
|
|
53
53
|
---
|
|
54
54
|
|
|
55
|
-
##
|
|
55
|
+
## How it Works
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
Miii isn't just autocomplete—it's a **full autonomous agent loop** that reasons through complex tasks:
|
|
58
58
|
|
|
59
59
|
1. You describe a goal
|
|
60
60
|
2. Miii reads your codebase, plans the changes, edits the files
|
|
@@ -95,7 +95,7 @@ This isn't autocomplete. Miii is a **full autonomous agent loop:**
|
|
|
95
95
|
|
|
96
96
|
---
|
|
97
97
|
|
|
98
|
-
##
|
|
98
|
+
## 🚀 Core Capabilities
|
|
99
99
|
|
|
100
100
|
**🔒 Privacy-First, Local by Default**
|
|
101
101
|
Run on Ollama and your code never leaves your machine. No account. No API key. No monthly bill. Switch to Anthropic or OpenAI when you need it — one command, live, mid-session.
|
|
@@ -132,7 +132,7 @@ Connect any MCP-compatible tool server. Miii discovers tools automatically and m
|
|
|
132
132
|
|
|
133
133
|
---
|
|
134
134
|
|
|
135
|
-
##
|
|
135
|
+
## ⚡ Quick Start
|
|
136
136
|
|
|
137
137
|
```bash
|
|
138
138
|
# 1. Start Ollama and pull a model
|
|
@@ -150,7 +150,7 @@ No API keys. No account. No sign-up form. First run walks you through setup inte
|
|
|
150
150
|
|
|
151
151
|
---
|
|
152
152
|
|
|
153
|
-
## Power Commands
|
|
153
|
+
## ⌨️ Power Commands
|
|
154
154
|
|
|
155
155
|
| Command | What it does |
|
|
156
156
|
|---|---|
|
|
@@ -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
|
-
//
|
|
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
|
-
// ───
|
|
27
|
-
describe('
|
|
28
|
-
const
|
|
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
|
|
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(
|
|
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(
|
|
47
|
+
await expect(updateTool.execute({ path: SCRATCH, old: 'hello world', new: 'hi' }))
|
|
48
48
|
.rejects.toThrow('ambiguous');
|
|
49
49
|
});
|
|
50
50
|
});
|
package/dist/config.js
CHANGED
package/dist/memory/extractor.js
CHANGED
|
@@ -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
|
|
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,13 +98,13 @@ function extractFileToolArgs(text, toolName) {
|
|
|
98
98
|
.replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
|
-
// For
|
|
102
|
-
const oldM = text.match(/"old"\s*:\s*"([\s\S]
|
|
101
|
+
// For update_file: extract old/new fields
|
|
102
|
+
const oldM = text.match(/"old"\s*:\s*"((?:[^"\\]|\\[\s\S])*)"/);
|
|
103
103
|
if (oldM)
|
|
104
|
-
args.old = oldM[1].replace(/\\n/g, '\n').replace(/\\"/g, '"');
|
|
105
|
-
const newM = text.match(/"new"\s*:\s*"([\s\S]
|
|
104
|
+
args.old = oldM[1].replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
|
105
|
+
const newM = text.match(/"new"\s*:\s*"((?:[^"\\]|\\[\s\S])*)"/);
|
|
106
106
|
if (newM)
|
|
107
|
-
args.new = newM[1].replace(/\\n/g, '\n').replace(/\\"/g, '"');
|
|
107
|
+
args.new = newM[1].replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
|
108
108
|
return Object.keys(args).length > 0 ? args : null;
|
|
109
109
|
}
|
|
110
110
|
// Extract a bare tool-call JSON from arbitrary text (LLM skipped <tool_call> wrapper)
|
|
@@ -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', '
|
|
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 };
|
package/dist/sessions.js
CHANGED
package/dist/skills/loader.js
CHANGED
package/dist/tasks/compactor.js
CHANGED
|
@@ -66,9 +66,12 @@ export async function compactContext(messages, cfg, goal) {
|
|
|
66
66
|
role: 'user',
|
|
67
67
|
content: `[Context compacted — ${toSummarize.length} messages summarised]\n\n${summary}`,
|
|
68
68
|
};
|
|
69
|
+
const needsBridge = recent.length > 0 && recent[0].role === 'user';
|
|
70
|
+
const bridgeMsg = { role: 'assistant', content: 'Understood. Continuing from the summarized context.' };
|
|
69
71
|
return [
|
|
70
72
|
...(system ? [system] : []),
|
|
71
73
|
summaryMsg,
|
|
74
|
+
...(needsBridge ? [bridgeMsg] : []),
|
|
72
75
|
...recent,
|
|
73
76
|
];
|
|
74
77
|
}
|
package/dist/tools/index.js
CHANGED
|
@@ -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
|
|
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
|
|
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: '
|
|
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 }) => {
|
|
@@ -193,7 +193,7 @@ export const tools = [
|
|
|
193
193
|
if (!message)
|
|
194
194
|
throw new Error('git_commit: message required');
|
|
195
195
|
const fileStr = String(files);
|
|
196
|
-
if (/\.\./.test(fileStr) || !/^(-A|\.|[\w
|
|
196
|
+
if (/\.\./.test(fileStr) || !/^(-A|\.|[\w./\-]+(?: [\w./\-]+)*)$/.test(fileStr))
|
|
197
197
|
throw new Error('git_commit: invalid files argument — use -A, ., or space-separated paths (no .. allowed)');
|
|
198
198
|
try {
|
|
199
199
|
const fileArgs = fileStr === '-A' ? ['-A'] : fileStr === '.' ? ['.'] : fileStr.split(/\s+/).filter(Boolean);
|
|
@@ -295,9 +295,9 @@ full file content here
|
|
|
295
295
|
</content>
|
|
296
296
|
</tool_call>
|
|
297
297
|
|
|
298
|
-
For
|
|
298
|
+
For update_file use <old> and <new> blocks:
|
|
299
299
|
<tool_call>
|
|
300
|
-
{"name": "
|
|
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
|
|
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
|
|
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,
|
|
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
|
package/dist/tui/InputBar.js
CHANGED
|
@@ -6,7 +6,7 @@ import { ModelPicker } from './components/ModelPicker.js';
|
|
|
6
6
|
import { ConfigPicker } from './components/ConfigPicker.js';
|
|
7
7
|
import { Divider } from './components/StatusBar.js';
|
|
8
8
|
import { tools } from '../tools/index.js';
|
|
9
|
-
import { toolArgSummary } from './printer.js';
|
|
9
|
+
import { toolArgSummary, formatElapsed } from './printer.js';
|
|
10
10
|
import { MacroQueue } from '../tasks/queue.js';
|
|
11
11
|
import { TaskExecutor } from '../tasks/executor.js';
|
|
12
12
|
import { THINKING_PHRASES, SPARKLE } from './thinking.js';
|
|
@@ -23,14 +23,6 @@ import { createSearchCodebaseTool } from '../index/tool.js';
|
|
|
23
23
|
import { saveConfig } from '../config.js';
|
|
24
24
|
import { getTavilyKey, saveTavilyKey } from '../tavily/client.js';
|
|
25
25
|
import { warmup } from '../llm/stream.js';
|
|
26
|
-
function formatElapsed(ms) {
|
|
27
|
-
const s = Math.floor(ms / 1000);
|
|
28
|
-
if (s < 60)
|
|
29
|
-
return `${s}s`;
|
|
30
|
-
const m = Math.floor(s / 60);
|
|
31
|
-
const rem = s % 60;
|
|
32
|
-
return rem === 0 ? `${m}m` : `${m}m ${rem}s`;
|
|
33
|
-
}
|
|
34
26
|
const MAX_DIFF_LINES = 40;
|
|
35
27
|
const DIFF_CTX = 2;
|
|
36
28
|
function lineDiff(oldText, newText) {
|
|
@@ -72,7 +64,7 @@ function diffHunks(diff) {
|
|
|
72
64
|
return diff.filter((_, i) => inHunk.has(i));
|
|
73
65
|
}
|
|
74
66
|
function DiffPreview({ toolName, args }) {
|
|
75
|
-
if (toolName === '
|
|
67
|
+
if (toolName === 'update_file' && (args.old != null || args.new != null)) {
|
|
76
68
|
const path = String(args.path ?? '');
|
|
77
69
|
const diff = diffHunks(lineDiff(String(args.old ?? ''), String(args.new ?? '')));
|
|
78
70
|
const visible = diff.slice(0, MAX_DIFF_LINES);
|
|
@@ -143,7 +135,7 @@ export function InputBar({ config: initialConfig, skills, cwd, session, version,
|
|
|
143
135
|
setConfig(c => ({ ...c, ...configPatch }));
|
|
144
136
|
saveConfig(configPatch);
|
|
145
137
|
}
|
|
146
|
-
}, onTavilyKey: (key) => { saveTavilyKey(key); setTavilyKey(key); }, onClose: () => { setConfigOpen(false); } }), _jsx(Divider, { cols: cols })] })) : pickerOpen ? (_jsxs(_Fragment, { children: [_jsx(ModelPicker, { models: pickerModels, current: currentModel, loading: pickerLoading, error: pickerError, pull: pullState, onSelect: handleModelSelect, onPull: handleModelPull, onClose: () => { setPickerOpen(false); } }), _jsx(Divider, { cols: cols })] })) : compactRequest ? (_jsxs(Box, { paddingX: 1, flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "yellow", children: "\u26A0" }), _jsx(Text, { color: "white", bold: true, children: "context is large" }), _jsxs(Text, { color: "gray", children: ["(~", compactRequest.messageCount, "k chars)"] })] }), _jsx(Text, { color: "gray", dimColor: true, children: "compact to keep responses fast, or keep full history" })] })) : permissionRequest ? (_jsxs(Box, { paddingX: 1, flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "yellow", children: "\u26A0" }), _jsx(Text, { color: "white", bold: true, children: permissionRequest.toolName }), _jsx(Text, { color: "gray", children: toolArgSummary(permissionRequest.args) })] }), _jsx(DiffPreview, { toolName: permissionRequest.toolName, args: permissionRequest.args })] })) : (status === 'thinking' || status === 'tool') ? (
|
|
147
|
-
|
|
148
|
-
|
|
138
|
+
}, onTavilyKey: (key) => { saveTavilyKey(key); setTavilyKey(key); }, onClose: () => { setConfigOpen(false); } }), _jsx(Divider, { cols: cols })] })) : pickerOpen ? (_jsxs(_Fragment, { children: [_jsx(ModelPicker, { models: pickerModels, current: currentModel, loading: pickerLoading, error: pickerError, pull: pullState, onSelect: handleModelSelect, onPull: handleModelPull, onClose: () => { setPickerOpen(false); } }), _jsx(Divider, { cols: cols })] })) : compactRequest ? (_jsxs(Box, { paddingX: 1, flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "yellow", children: "\u26A0" }), _jsx(Text, { color: "white", bold: true, children: "context is large" }), _jsxs(Text, { color: "gray", children: ["(~", compactRequest.messageCount, "k chars)"] })] }), _jsx(Text, { color: "gray", dimColor: true, children: "compact to keep responses fast, or keep full history" })] })) : permissionRequest ? (_jsxs(Box, { paddingX: 1, flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "yellow", children: "\u26A0" }), _jsx(Text, { color: "white", bold: true, children: permissionRequest.toolName }), _jsx(Text, { color: "gray", children: toolArgSummary(permissionRequest.args) })] }), _jsx(DiffPreview, { toolName: permissionRequest.toolName, args: permissionRequest.args })] })) : (status === 'thinking' || status === 'tool') ? (_jsx(Box, { paddingX: 1, gap: 1, children: status === 'thinking'
|
|
139
|
+
? _jsxs(_Fragment, { children: [_jsx(Text, { color: "yellow", children: SPARKLE[tick % SPARKLE.length] }), _jsx(Text, { color: Math.floor(tick / 4) % 6 >= 2 && Math.floor(tick / 4) % 6 <= 4 ? 'white' : 'gray', italic: true, children: THINKING_PHRASES[phraseSeq[Math.floor(tick / 62) % phraseSeq.length]] }), _jsx(Text, { color: "gray", dimColor: true, children: formatElapsed(Date.now() - thinkingStartRef.current) }), taskLabel && _jsx(Text, { color: "cyan", dimColor: true, children: taskLabel })] })
|
|
140
|
+
: _jsxs(_Fragment, { children: [_jsxs(Text, { color: "yellow", dimColor: true, children: ["\u2699 running ", currentTool ?? 'tool', "\u2026"] }), _jsx(Text, { color: "gray", dimColor: true, children: formatElapsed(Date.now() - thinkingStartRef.current) }), taskLabel && _jsx(Text, { color: "cyan", dimColor: true, children: taskLabel })] }) })) : null, _jsx(InputArea, { status: status, skills: skillList, cwd: cwd, planningMode: planningMode, permissionRequest: permissionRequest, onPermissionResponse: resolvePermission, compactRequest: compactRequest, onCompactResponse: resolveCompact, onSubmit: handleSubmit, onAbort: handleAbort, history: historyRef.current.filter(m => m.role === 'user').map(m => m.content), watchActive: watchActive })] }));
|
|
149
141
|
}
|
|
@@ -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', '
|
|
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', '
|
|
12
|
-
const CHECKPOINT_TOOLS = new Set(['edit_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,11 +115,12 @@ 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
|
|
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;
|
|
122
122
|
}
|
|
123
|
+
printer.systemMsg(`done in ${printer.formatElapsed(Date.now() - thinkingStartRef.current)}`);
|
|
123
124
|
setStatus('idle');
|
|
124
125
|
return;
|
|
125
126
|
}
|
|
@@ -164,17 +165,24 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
|
|
|
164
165
|
}
|
|
165
166
|
if (tool) {
|
|
166
167
|
try {
|
|
167
|
-
// Guard: for
|
|
168
|
+
// Guard: for update_file, verify old text still matches before executing.
|
|
168
169
|
// If stale, inject fresh file content and skip — model will retry.
|
|
169
|
-
if (tc.name === '
|
|
170
|
+
if (tc.name === 'update_file') {
|
|
170
171
|
const filePath = tc.args.path;
|
|
171
172
|
const oldText = tc.args.old;
|
|
172
173
|
if (filePath && oldText && existsSync(filePath)) {
|
|
173
174
|
const current = readFileSync(filePath, 'utf-8');
|
|
174
|
-
|
|
175
|
+
const occurrences = current.split(oldText).length - 1;
|
|
176
|
+
if (occurrences === 0) {
|
|
175
177
|
printer.errorMsg(`patch stale: old text not found in ${filePath} — injecting fresh content`);
|
|
176
178
|
next.push({ role: 'user', content: `Tool read_file result:\n${current}` });
|
|
177
|
-
next.push({ role: 'user', content: `
|
|
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
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (occurrences > 1) {
|
|
183
|
+
printer.errorMsg(`patch ambiguous: old text matches ${occurrences} locations in ${filePath} — injecting fresh content`);
|
|
184
|
+
next.push({ role: 'user', content: `Tool read_file result:\n${current}` });
|
|
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.` });
|
|
178
186
|
continue;
|
|
179
187
|
}
|
|
180
188
|
}
|
|
@@ -209,33 +217,9 @@ export function useRunLoop(config, currentModelRef, pushHistory, extraTools = []
|
|
|
209
217
|
finally {
|
|
210
218
|
setCurrentTool(undefined);
|
|
211
219
|
}
|
|
212
|
-
// Auto-run tests after file edits
|
|
213
|
-
const didEditFiles = pendingTools.some(tc => FILE_EDIT_TOOLS.has(tc.name));
|
|
214
|
-
if (didEditFiles) {
|
|
215
|
-
const testTool = staticTools.find(t => t.name === 'run_tests');
|
|
216
|
-
if (testTool) {
|
|
217
|
-
setCurrentTool('run_tests');
|
|
218
|
-
try {
|
|
219
|
-
printer.toolCallStart('run_tests', {});
|
|
220
|
-
const testResult = await testTool.execute({});
|
|
221
|
-
if (testResult && !testResult.startsWith('(no test script') && !testResult.startsWith('(no package.json')) {
|
|
222
|
-
printer.toolResultSummary('run_tests', {}, testResult);
|
|
223
|
-
printer.toolMsg('run_tests', testResult);
|
|
224
|
-
next.push({ role: 'user', content: `Test results after edits:\n${testResult}` });
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
catch (e) {
|
|
228
|
-
const err = `run_tests error: ${e}`;
|
|
229
|
-
printer.errorMsg(err);
|
|
230
|
-
next.push({ role: 'user', content: err });
|
|
231
|
-
}
|
|
232
|
-
finally {
|
|
233
|
-
setCurrentTool(undefined);
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
220
|
// For file-edit turns: slim context (system + goal + fresh file states + recent results)
|
|
238
221
|
// For non-edit turns: full next (model needs full conversational context)
|
|
222
|
+
const didEditFiles = pendingTools.some(tc => FILE_EDIT_TOOLS.has(tc.name));
|
|
239
223
|
if (didEditFiles) {
|
|
240
224
|
const systemMsg = msgs.find(m => m.role === 'system');
|
|
241
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') {
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { execFileSync, execSync } from 'child_process';
|
|
2
|
+
import { writeFileSync, readFileSync, existsSync, unlinkSync } from 'fs';
|
|
3
|
+
import { tmpdir } from 'os';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
export function hasClipboardImage() {
|
|
6
|
+
try {
|
|
7
|
+
if (process.platform === 'darwin') {
|
|
8
|
+
const info = execFileSync('osascript', ['-e', 'clipboard info'], { encoding: 'utf8' });
|
|
9
|
+
return info.includes('PNGf') || info.includes('JPEG') || info.includes('TIFF');
|
|
10
|
+
}
|
|
11
|
+
if (process.platform === 'linux') {
|
|
12
|
+
const targets = execSync('xclip -selection clipboard -t TARGETS -o 2>/dev/null', { encoding: 'utf8' });
|
|
13
|
+
return targets.includes('image/');
|
|
14
|
+
}
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export async function readClipboardText() {
|
|
22
|
+
try {
|
|
23
|
+
if (process.platform === 'darwin') {
|
|
24
|
+
return execFileSync('pbpaste', { encoding: 'utf8' }) || null;
|
|
25
|
+
}
|
|
26
|
+
if (process.platform === 'linux') {
|
|
27
|
+
return execSync('xclip -selection clipboard -o 2>/dev/null', { encoding: 'utf8' }) || null;
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export async function readClipboardImage() {
|
|
36
|
+
const stamp = Date.now();
|
|
37
|
+
const tmpImg = join(tmpdir(), `miii_clip_${stamp}.png`);
|
|
38
|
+
const tmpScript = join(tmpdir(), `miii_clip_${stamp}.scpt`);
|
|
39
|
+
try {
|
|
40
|
+
if (process.platform === 'darwin') {
|
|
41
|
+
writeFileSync(tmpScript, [
|
|
42
|
+
'try',
|
|
43
|
+
` set theData to (the clipboard as «class PNGf»)`,
|
|
44
|
+
` set theFile to (open for access POSIX file "${tmpImg}" with write permission)`,
|
|
45
|
+
' write theData to theFile',
|
|
46
|
+
' close access theFile',
|
|
47
|
+
' return "ok"',
|
|
48
|
+
'on error',
|
|
49
|
+
' return "no"',
|
|
50
|
+
'end try',
|
|
51
|
+
].join('\n'), 'utf8');
|
|
52
|
+
const result = execFileSync('osascript', [tmpScript], { encoding: 'utf8' }).trim();
|
|
53
|
+
if (result !== 'ok' || !existsSync(tmpImg))
|
|
54
|
+
return null;
|
|
55
|
+
const buf = readFileSync(tmpImg);
|
|
56
|
+
if (!buf.length)
|
|
57
|
+
return null;
|
|
58
|
+
return { data: buf.toString('base64'), mediaType: 'image/png' };
|
|
59
|
+
}
|
|
60
|
+
if (process.platform === 'linux') {
|
|
61
|
+
execSync(`xclip -selection clipboard -t image/png -o > "${tmpImg}" 2>/dev/null`);
|
|
62
|
+
if (!existsSync(tmpImg))
|
|
63
|
+
return null;
|
|
64
|
+
const buf = readFileSync(tmpImg);
|
|
65
|
+
if (!buf.length)
|
|
66
|
+
return null;
|
|
67
|
+
return { data: buf.toString('base64'), mediaType: 'image/png' };
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
for (const f of [tmpImg, tmpScript]) {
|
|
76
|
+
try {
|
|
77
|
+
if (existsSync(f))
|
|
78
|
+
unlinkSync(f);
|
|
79
|
+
}
|
|
80
|
+
catch { }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
package/dist/tui/printer.js
CHANGED
|
@@ -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', '
|
|
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 '
|
|
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 '
|
|
277
|
+
case 'update_file':
|
|
223
278
|
summary = lines[0] ?? 'Applied patch';
|
|
224
279
|
break;
|
|
225
280
|
case 'delete_file':
|
|
@@ -280,3 +335,11 @@ export function divider() {
|
|
|
280
335
|
const cols = process.stdout.columns ?? 80;
|
|
281
336
|
write(`${gray('─'.repeat(cols))}\n`);
|
|
282
337
|
}
|
|
338
|
+
export function formatElapsed(ms) {
|
|
339
|
+
const s = Math.floor(ms / 1000);
|
|
340
|
+
if (s < 60)
|
|
341
|
+
return `${s}s`;
|
|
342
|
+
const m = Math.floor(s / 60);
|
|
343
|
+
const rem = s % 60;
|
|
344
|
+
return rem === 0 ? `${m}m` : `${m}m ${rem}s`;
|
|
345
|
+
}
|
package/dist/tui/thinking.js
CHANGED
|
@@ -1,53 +1,96 @@
|
|
|
1
1
|
export const THINKING_PHRASES = [
|
|
2
|
-
'
|
|
3
|
-
'
|
|
4
|
-
'
|
|
5
|
-
'
|
|
6
|
-
'
|
|
7
|
-
'
|
|
8
|
-
'
|
|
9
|
-
'
|
|
10
|
-
'
|
|
11
|
-
'
|
|
12
|
-
'
|
|
13
|
-
'
|
|
14
|
-
'
|
|
15
|
-
'
|
|
16
|
-
'
|
|
17
|
-
'
|
|
18
|
-
'
|
|
19
|
-
'
|
|
20
|
-
'
|
|
21
|
-
'
|
|
22
|
-
'
|
|
23
|
-
'
|
|
24
|
-
'
|
|
25
|
-
'
|
|
26
|
-
'
|
|
27
|
-
'
|
|
28
|
-
'
|
|
29
|
-
'
|
|
30
|
-
'
|
|
31
|
-
'
|
|
32
|
-
'
|
|
33
|
-
'
|
|
34
|
-
'
|
|
35
|
-
'
|
|
36
|
-
'
|
|
37
|
-
'
|
|
38
|
-
'
|
|
39
|
-
'
|
|
40
|
-
'
|
|
41
|
-
'
|
|
42
|
-
'
|
|
43
|
-
'
|
|
44
|
-
'
|
|
45
|
-
'
|
|
46
|
-
'
|
|
47
|
-
'
|
|
48
|
-
'
|
|
49
|
-
'
|
|
50
|
-
'
|
|
51
|
-
'
|
|
2
|
+
'thinking…',
|
|
3
|
+
'reasoning…',
|
|
4
|
+
'processing…',
|
|
5
|
+
'analyzing…',
|
|
6
|
+
'working…',
|
|
7
|
+
'reading…',
|
|
8
|
+
'planning…',
|
|
9
|
+
'figuring out…',
|
|
10
|
+
'computing…',
|
|
11
|
+
'calculating…',
|
|
12
|
+
'evaluating…',
|
|
13
|
+
'examining…',
|
|
14
|
+
'reviewing…',
|
|
15
|
+
'considering…',
|
|
16
|
+
'reflecting…',
|
|
17
|
+
'determining…',
|
|
18
|
+
'exploring…',
|
|
19
|
+
'searching…',
|
|
20
|
+
'checking…',
|
|
21
|
+
'verifying…',
|
|
22
|
+
'scanning…',
|
|
23
|
+
'parsing…',
|
|
24
|
+
'loading…',
|
|
25
|
+
'mapping…',
|
|
26
|
+
'tracing…',
|
|
27
|
+
'resolving…',
|
|
28
|
+
'generating…',
|
|
29
|
+
'building…',
|
|
30
|
+
'compiling…',
|
|
31
|
+
'drafting…',
|
|
32
|
+
'forming…',
|
|
33
|
+
'shaping…',
|
|
34
|
+
'crafting…',
|
|
35
|
+
'assembling…',
|
|
36
|
+
'organizing…',
|
|
37
|
+
'sorting…',
|
|
38
|
+
'filtering…',
|
|
39
|
+
'matching…',
|
|
40
|
+
'comparing…',
|
|
41
|
+
'correlating…',
|
|
42
|
+
'inferring…',
|
|
43
|
+
'deducing…',
|
|
44
|
+
'estimating…',
|
|
45
|
+
'predicting…',
|
|
46
|
+
'modeling…',
|
|
47
|
+
'simulating…',
|
|
48
|
+
'optimizing…',
|
|
49
|
+
'refining…',
|
|
50
|
+
'debugging…',
|
|
51
|
+
'testing…',
|
|
52
|
+
'validating…',
|
|
53
|
+
'calibrating…',
|
|
54
|
+
'measuring…',
|
|
55
|
+
'indexing…',
|
|
56
|
+
'formatting…',
|
|
57
|
+
'encoding…',
|
|
58
|
+
'extracting…',
|
|
59
|
+
'transforming…',
|
|
60
|
+
'converting…',
|
|
61
|
+
'merging…',
|
|
62
|
+
'clustering…',
|
|
63
|
+
'ranking…',
|
|
64
|
+
'scoring…',
|
|
65
|
+
'sampling…',
|
|
66
|
+
'iterating…',
|
|
67
|
+
'traversing…',
|
|
68
|
+
'navigating…',
|
|
69
|
+
'querying…',
|
|
70
|
+
'updating…',
|
|
71
|
+
'patching…',
|
|
72
|
+
'adapting…',
|
|
73
|
+
'learning…',
|
|
74
|
+
'recalling…',
|
|
75
|
+
'synthesizing…',
|
|
76
|
+
'concluding…',
|
|
77
|
+
'deciding…',
|
|
78
|
+
'selecting…',
|
|
79
|
+
'choosing…',
|
|
80
|
+
'prioritizing…',
|
|
81
|
+
'sequencing…',
|
|
82
|
+
'aligning…',
|
|
83
|
+
'balancing…',
|
|
84
|
+
'adjusting…',
|
|
85
|
+
'reconciling…',
|
|
86
|
+
'confirming…',
|
|
87
|
+
'finalizing…',
|
|
88
|
+
'structuring…',
|
|
89
|
+
'decomposing…',
|
|
90
|
+
'abstracting…',
|
|
91
|
+
'simplifying…',
|
|
92
|
+
'expanding…',
|
|
93
|
+
'summarizing…',
|
|
94
|
+
'distilling…',
|
|
52
95
|
];
|
|
53
96
|
export const SPARKLE = ['✦', '✧', '✶', '✷', '✸', '✹'];
|