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 +12 -7
- package/dist/tasks/compactor.js +65 -26
- package/dist/tools/index.js +40 -14
- package/dist/tui/components/InputArea.js +30 -6
- package/dist/tui/hooks/useRunLoop.js +75 -11
- package/dist/tui/hooks/useSubmit.js +28 -0
- package/dist/tui/printer.js +72 -2
- package/package.json +1 -1
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
|
-
|
|
77
|
+
Read 28 lines
|
|
77
78
|
|
|
78
|
-
|
|
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
|
-
●
|
|
84
|
-
|
|
85
|
-
●
|
|
86
|
-
|
|
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 —
|
|
93
|
+
─ refactor done — 2 file(s) processed
|
|
89
94
|
```
|
|
90
95
|
|
|
91
96
|
---
|
package/dist/tasks/compactor.js
CHANGED
|
@@ -1,52 +1,91 @@
|
|
|
1
|
-
|
|
2
|
-
const
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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 [
|
package/dist/tools/index.js
CHANGED
|
@@ -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: '
|
|
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
|
-
|
|
53
|
-
|
|
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
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
-
|
|
288
|
-
- To
|
|
289
|
-
-
|
|
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(
|
|
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(
|
|
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:
|
|
470
|
-
?
|
|
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((
|
|
29
|
-
permissionResolveRef.current?.(
|
|
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
|
-
|
|
62
|
-
|
|
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
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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(
|
|
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);
|
package/dist/tui/printer.js
CHANGED
|
@@ -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(
|
|
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 >
|
|
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
|
: '';
|