miii-cli 0.2.9 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -39
- package/dist/__tests__/integration.test.js +50 -0
- package/dist/init.js +4 -3
- package/dist/llm/stream.js +78 -10
- package/dist/memory/extractor.js +44 -0
- package/dist/memory/store.js +41 -0
- package/dist/tavily/client.js +64 -0
- package/dist/tools/index.js +5 -1
- package/dist/tui/InputBar.js +73 -5
- package/dist/tui/components/InputArea.js +51 -13
- package/dist/tui/deepThink.js +94 -0
- package/dist/tui/git-context.js +59 -0
- package/dist/tui/hooks/useModelPicker.js +63 -0
- package/dist/tui/hooks/useRunLoop.js +173 -0
- package/dist/tui/hooks/useSession.js +93 -0
- package/dist/tui/printer.js +16 -8
- package/dist/tui/thinking.js +53 -0
- package/package.json +1 -1
|
@@ -32,11 +32,15 @@ const PLANNING_COMMANDS = [
|
|
|
32
32
|
{ ns: 'plan', name: 'review', description: 'review and critique the plan so far' },
|
|
33
33
|
{ ns: 'plan', name: 'done', description: 'exit planning mode' },
|
|
34
34
|
];
|
|
35
|
-
|
|
35
|
+
const PASTE_MIN_LINES = 3;
|
|
36
|
+
const PASTE_MIN_CHARS = 200;
|
|
37
|
+
export function InputArea({ status, skills, cwd, planningMode, permissionRequest, onPermissionResponse, onSubmit, onAbort }) {
|
|
36
38
|
const [lines, setLines] = useState(['']);
|
|
37
39
|
const [cursor, setCursor] = useState({ row: 0, col: 0 });
|
|
38
40
|
const [overlay, setOverlay] = useState('none');
|
|
39
41
|
const [overlayIdx, setOverlayIdx] = useState(0);
|
|
42
|
+
const [pasteLines, setPasteLines] = useState(0);
|
|
43
|
+
const pasteRef = useRef(null);
|
|
40
44
|
const [files, setFiles] = useState([]);
|
|
41
45
|
const filesLoadedRef = useRef(false);
|
|
42
46
|
// built-ins first, then loaded skills (deduplicated by name)
|
|
@@ -87,6 +91,8 @@ export function InputArea({ status, skills, cwd, planningMode, onSubmit, onAbort
|
|
|
87
91
|
setCursor({ row: 0, col: 0 });
|
|
88
92
|
setOverlay('none');
|
|
89
93
|
setOverlayIdx(0);
|
|
94
|
+
pasteRef.current = null;
|
|
95
|
+
setPasteLines(0);
|
|
90
96
|
}
|
|
91
97
|
function appendChar(ch) {
|
|
92
98
|
setLines(prev => {
|
|
@@ -142,6 +148,18 @@ export function InputArea({ status, skills, cwd, planningMode, onSubmit, onAbort
|
|
|
142
148
|
setOverlayIdx(0);
|
|
143
149
|
}
|
|
144
150
|
useInput((input, key) => {
|
|
151
|
+
// Permission prompt intercepts all input
|
|
152
|
+
if (permissionRequest && onPermissionResponse) {
|
|
153
|
+
if (input === 'y' || input === 'Y') {
|
|
154
|
+
onPermissionResponse(true);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (input === 'n' || input === 'N' || key.escape) {
|
|
158
|
+
onPermissionResponse(false);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
145
163
|
// ESC: close overlay, abort stream, or clear input
|
|
146
164
|
if (key.escape) {
|
|
147
165
|
if (overlay !== 'none') {
|
|
@@ -200,7 +218,11 @@ export function InputArea({ status, skills, cwd, planningMode, onSubmit, onAbort
|
|
|
200
218
|
// backspace/typing falls through to normal handling below
|
|
201
219
|
}
|
|
202
220
|
if (key.return) {
|
|
203
|
-
const
|
|
221
|
+
const typed = fullInput.trim();
|
|
222
|
+
const pasted = pasteRef.current;
|
|
223
|
+
const text = pasted
|
|
224
|
+
? typed ? `${typed}\n${pasted}` : pasted
|
|
225
|
+
: typed;
|
|
204
226
|
if (text) {
|
|
205
227
|
clearInput();
|
|
206
228
|
onSubmit(text);
|
|
@@ -208,6 +230,11 @@ export function InputArea({ status, skills, cwd, planningMode, onSubmit, onAbort
|
|
|
208
230
|
return;
|
|
209
231
|
}
|
|
210
232
|
if (key.backspace || key.delete) {
|
|
233
|
+
if (pasteRef.current) {
|
|
234
|
+
pasteRef.current = null;
|
|
235
|
+
setPasteLines(0);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
211
238
|
deleteChar();
|
|
212
239
|
// Recompute overlay trigger for updated input
|
|
213
240
|
const r = cursor.row;
|
|
@@ -245,6 +272,13 @@ export function InputArea({ status, skills, cwd, planningMode, onSubmit, onAbort
|
|
|
245
272
|
return;
|
|
246
273
|
}
|
|
247
274
|
if (input && !key.ctrl && !key.meta) {
|
|
275
|
+
// Detect paste: Ink delivers entire pasted chunk as one input string
|
|
276
|
+
const lineCount = input.split('\n').length;
|
|
277
|
+
if (input.length > 1 && (lineCount >= PASTE_MIN_LINES || input.length >= PASTE_MIN_CHARS)) {
|
|
278
|
+
pasteRef.current = input;
|
|
279
|
+
setPasteLines(lineCount);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
248
282
|
// Compute prospective new input to decide overlay
|
|
249
283
|
const r = cursor.row;
|
|
250
284
|
const col = cursor.col;
|
|
@@ -274,17 +308,21 @@ export function InputArea({ status, skills, cwd, planningMode, onSubmit, onAbort
|
|
|
274
308
|
}
|
|
275
309
|
});
|
|
276
310
|
const isProcessing = status !== 'idle';
|
|
277
|
-
const borderColor = isProcessing ? 'yellow' : 'cyan';
|
|
278
|
-
const hint =
|
|
279
|
-
? '
|
|
280
|
-
:
|
|
281
|
-
? '
|
|
282
|
-
:
|
|
283
|
-
? '
|
|
284
|
-
:
|
|
285
|
-
? '
|
|
286
|
-
:
|
|
287
|
-
|
|
311
|
+
const borderColor = permissionRequest ? 'yellow' : isProcessing ? 'yellow' : 'cyan';
|
|
312
|
+
const hint = permissionRequest
|
|
313
|
+
? 'y approve n deny'
|
|
314
|
+
: isProcessing
|
|
315
|
+
? 'esc to abort'
|
|
316
|
+
: pasteLines > 0
|
|
317
|
+
? 'backspace removes paste enter to send'
|
|
318
|
+
: overlay === 'command' && !commandQuery.includes(' ')
|
|
319
|
+
? '↑↓ navigate enter select esc close'
|
|
320
|
+
: overlay === 'at'
|
|
321
|
+
? '↑↓ navigate enter select esc close'
|
|
322
|
+
: planningMode
|
|
323
|
+
? '📋 planning mode / suggestions enter send /plan:done to exit'
|
|
324
|
+
: '@ file / command enter send ctrl+c exit';
|
|
325
|
+
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 })), _jsxs(Box, { borderStyle: "round", borderColor: borderColor, paddingX: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: borderColor, bold: true, children: '❯ ' }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: permissionRequest ? (_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, { gap: 1, children: [_jsx(Text, { color: "cyan", children: "\u2398" }), _jsxs(Text, { color: "cyan", children: ["pasted ", pasteLines, " lines"] }), (lines.length > 1 || lines[0]) && (_jsx(Text, { color: "gray", dimColor: true, children: "+ typed text" }))] })) : lines.length === 1 && !lines[0] ? (_jsx(Text, { color: isActive ? 'white' : 'gray', dimColor: isProcessing, children: isActive ? '█' : 'processing...' })) : (lines.map((line, i) => (_jsx(Text, { wrap: "wrap", children: i === cursor.row
|
|
288
326
|
? renderLineWithCursor(line, cursor.col, isActive)
|
|
289
327
|
: line }, i)))) })] }), _jsx(Text, { color: "gray", dimColor: true, children: hint })] })] }));
|
|
290
328
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { chat } from '../llm/stream.js';
|
|
2
|
+
import { tools as staticTools } from '../tools/index.js';
|
|
3
|
+
import { StreamParser } from '../parser/stream-parser.js';
|
|
4
|
+
const ALLOWED_TOOLS = new Set([
|
|
5
|
+
'read_file', 'list_files', 'web_search', 'web_extract',
|
|
6
|
+
'git_status', 'git_log', 'git_diff',
|
|
7
|
+
]);
|
|
8
|
+
const MAX_DEPTH = 6;
|
|
9
|
+
const MAX_WEB = 4;
|
|
10
|
+
export async function runDeepThink(query, config, model, signal, onStep) {
|
|
11
|
+
const gatherTools = staticTools.filter(t => ALLOWED_TOOLS.has(t.name));
|
|
12
|
+
const toolDocs = gatherTools.map(t => `- ${t.name}(${t.params}): ${t.description}`).join('\n');
|
|
13
|
+
const sysPrompt = `You are a research agent. Gather information to answer: "${query}"
|
|
14
|
+
|
|
15
|
+
Available tools (read-only — no file writes, no mutations):
|
|
16
|
+
${toolDocs}
|
|
17
|
+
|
|
18
|
+
Guardrails:
|
|
19
|
+
- Max ${MAX_DEPTH} tool calls total
|
|
20
|
+
- Max ${MAX_WEB} web calls (web_search + web_extract combined)
|
|
21
|
+
- No file edits, no shell commands that modify state
|
|
22
|
+
- When you have enough info, output a detailed plain-text research summary
|
|
23
|
+
- No markdown formatting in output`;
|
|
24
|
+
const messages = [
|
|
25
|
+
{ role: 'system', content: sysPrompt },
|
|
26
|
+
{ role: 'user', content: `Research and gather all relevant information for: ${query}` },
|
|
27
|
+
];
|
|
28
|
+
let depth = 0;
|
|
29
|
+
let webCalls = 0;
|
|
30
|
+
let totalCalls = 0;
|
|
31
|
+
let findings = '';
|
|
32
|
+
async function gather(msgs) {
|
|
33
|
+
if (depth >= MAX_DEPTH)
|
|
34
|
+
return;
|
|
35
|
+
depth++;
|
|
36
|
+
let fullText = '';
|
|
37
|
+
await chat({
|
|
38
|
+
provider: config.provider,
|
|
39
|
+
model,
|
|
40
|
+
baseUrl: config.baseUrl,
|
|
41
|
+
apiKey: config.apiKey,
|
|
42
|
+
messages: msgs,
|
|
43
|
+
signal,
|
|
44
|
+
async onDone(text) { fullText = text; },
|
|
45
|
+
onError(err) { if (err.name !== 'AbortError')
|
|
46
|
+
throw err; },
|
|
47
|
+
});
|
|
48
|
+
if (!fullText)
|
|
49
|
+
return;
|
|
50
|
+
const pending = [];
|
|
51
|
+
const parser = new StreamParser();
|
|
52
|
+
for (const item of [...parser.feed(fullText), ...parser.flush()]) {
|
|
53
|
+
if (item.type === 'tool_call')
|
|
54
|
+
pending.push({ name: item.toolName, args: item.toolArgs });
|
|
55
|
+
}
|
|
56
|
+
if (!pending.length) {
|
|
57
|
+
findings = fullText;
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const next = [...msgs, { role: 'assistant', content: fullText }];
|
|
61
|
+
for (const tc of pending) {
|
|
62
|
+
if (!ALLOWED_TOOLS.has(tc.name)) {
|
|
63
|
+
next.push({ role: 'user', content: `Tool "${tc.name}" not permitted in research phase.` });
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const isWeb = tc.name === 'web_search' || tc.name === 'web_extract';
|
|
67
|
+
if (isWeb && webCalls >= MAX_WEB) {
|
|
68
|
+
next.push({ role: 'user', content: `Web call limit (${MAX_WEB}) reached. Summarize findings now.` });
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (totalCalls >= MAX_DEPTH) {
|
|
72
|
+
next.push({ role: 'user', content: `Tool call limit (${MAX_DEPTH}) reached. Summarize findings now.` });
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const tool = gatherTools.find(t => t.name === tc.name);
|
|
76
|
+
if (!tool)
|
|
77
|
+
continue;
|
|
78
|
+
onStep?.(tc.name);
|
|
79
|
+
totalCalls++;
|
|
80
|
+
if (isWeb)
|
|
81
|
+
webCalls++;
|
|
82
|
+
try {
|
|
83
|
+
const result = await tool.execute(tc.args);
|
|
84
|
+
next.push({ role: 'user', content: `Tool ${tc.name} result:\n${result}` });
|
|
85
|
+
}
|
|
86
|
+
catch (e) {
|
|
87
|
+
next.push({ role: 'user', content: `Tool ${tc.name} error: ${e}` });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
await gather(next);
|
|
91
|
+
}
|
|
92
|
+
await gather(messages);
|
|
93
|
+
return { findings, toolCalls: totalCalls, webCalls };
|
|
94
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { readFile } from '../files/ops.js';
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
import { exec } from 'child_process';
|
|
4
|
+
import { promisify } from 'util';
|
|
5
|
+
const gitRun = promisify(exec);
|
|
6
|
+
const CODE_PATTERN = /\.(ts|js|tsx|jsx|py|go|rs|java|rb|sh|css|html|json|yaml|yml)\b|function|class|import|export|const|let|var|def |async|await|error|bug|fix|refactor|implement|`[^`]+`/i;
|
|
7
|
+
export function looksCodeRelated(text) {
|
|
8
|
+
return text.length >= 10 && CODE_PATTERN.test(text);
|
|
9
|
+
}
|
|
10
|
+
export async function buildGitContext(cwd, lastStatusRef) {
|
|
11
|
+
try {
|
|
12
|
+
const { stdout } = await gitRun('git status --short', { cwd, timeout: 5000 });
|
|
13
|
+
const status = stdout.trim();
|
|
14
|
+
if (!status || status === lastStatusRef.current)
|
|
15
|
+
return { prefix: '', label: '' };
|
|
16
|
+
lastStatusRef.current = status;
|
|
17
|
+
const MAX_TOTAL = 40_000;
|
|
18
|
+
const MAX_FILE = 15_000;
|
|
19
|
+
let total = 0;
|
|
20
|
+
const parts = [];
|
|
21
|
+
const skipped = [];
|
|
22
|
+
for (const line of status.split('\n')) {
|
|
23
|
+
const code = line.slice(0, 2);
|
|
24
|
+
if (code.includes('D'))
|
|
25
|
+
continue;
|
|
26
|
+
const raw = line.slice(3).trim().replace(/^"|"$/g, '');
|
|
27
|
+
const rel = raw.includes(' -> ') ? raw.split(' -> ')[1] : raw;
|
|
28
|
+
if (!rel)
|
|
29
|
+
continue;
|
|
30
|
+
try {
|
|
31
|
+
const content = readFile(resolve(cwd, rel));
|
|
32
|
+
if (!content || content.length > MAX_FILE) {
|
|
33
|
+
skipped.push(rel);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
total += content.length;
|
|
37
|
+
if (total > MAX_TOTAL) {
|
|
38
|
+
skipped.push(rel);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
parts.push(`<file path="${rel}">\n${content}\n</file>`);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
skipped.push(rel);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (!parts.length && !skipped.length)
|
|
48
|
+
return { prefix: '', label: '' };
|
|
49
|
+
let prefix = '[Auto-context: git-changed files]\n' + parts.join('\n') + '\n';
|
|
50
|
+
if (skipped.length)
|
|
51
|
+
prefix += `Files changed but too large to auto-load: ${skipped.join(', ')}\n`;
|
|
52
|
+
prefix += '\n';
|
|
53
|
+
const label = `auto-loaded ${parts.length} changed file(s)${skipped.length ? `, skipped ${skipped.length} (too large)` : ''}`;
|
|
54
|
+
return { prefix, label };
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return { prefix: '', label: '' };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { useState, useRef, useCallback, useEffect } from 'react';
|
|
2
|
+
import { listModels, pullModel } from '../../llm/ollama.js';
|
|
3
|
+
import * as printer from '../printer.js';
|
|
4
|
+
export function useModelPicker(config) {
|
|
5
|
+
const [currentModel, setCurrentModel] = useState(config.model);
|
|
6
|
+
const currentModelRef = useRef(config.model);
|
|
7
|
+
const [pickerOpen, setPickerOpen] = useState(true);
|
|
8
|
+
const [pickerModels, setPickerModels] = useState([]);
|
|
9
|
+
const [pickerLoading, setPickerLoading] = useState(false);
|
|
10
|
+
const [pickerError, setPickerError] = useState();
|
|
11
|
+
const [pullState, setPullState] = useState();
|
|
12
|
+
const pullAbortRef = useRef(null);
|
|
13
|
+
useEffect(() => { currentModelRef.current = currentModel; }, [currentModel]);
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
setPickerLoading(true);
|
|
16
|
+
listModels(config.baseUrl)
|
|
17
|
+
.then(m => { setPickerModels(m); setPickerLoading(false); })
|
|
18
|
+
.catch(e => { setPickerError(String(e)); setPickerLoading(false); });
|
|
19
|
+
}, []);
|
|
20
|
+
const openPicker = useCallback(async () => {
|
|
21
|
+
setPickerOpen(true);
|
|
22
|
+
setPickerLoading(true);
|
|
23
|
+
setPickerError(undefined);
|
|
24
|
+
try {
|
|
25
|
+
setPickerModels(await listModels(config.baseUrl));
|
|
26
|
+
}
|
|
27
|
+
catch (e) {
|
|
28
|
+
setPickerError(String(e));
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
setPickerLoading(false);
|
|
32
|
+
}
|
|
33
|
+
}, [config.baseUrl]);
|
|
34
|
+
const handleModelSelect = useCallback((name) => {
|
|
35
|
+
setCurrentModel(name);
|
|
36
|
+
currentModelRef.current = name;
|
|
37
|
+
setPickerOpen(false);
|
|
38
|
+
printer.systemMsg(`model → ${name}`);
|
|
39
|
+
}, []);
|
|
40
|
+
const handleModelPull = useCallback(async (name) => {
|
|
41
|
+
setPullState({ name, status: 'starting...', pct: undefined });
|
|
42
|
+
pullAbortRef.current = new AbortController();
|
|
43
|
+
try {
|
|
44
|
+
await pullModel(config.baseUrl, name, (s, p) => setPullState({ name, status: s, pct: p }), pullAbortRef.current.signal);
|
|
45
|
+
setPickerModels(await listModels(config.baseUrl));
|
|
46
|
+
setPullState(undefined);
|
|
47
|
+
setCurrentModel(name);
|
|
48
|
+
currentModelRef.current = name;
|
|
49
|
+
setPickerOpen(false);
|
|
50
|
+
printer.systemMsg(`pulled ${name} → active`);
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
setPullState(undefined);
|
|
54
|
+
setPickerError(`pull failed: ${e}`);
|
|
55
|
+
}
|
|
56
|
+
}, [config.baseUrl]);
|
|
57
|
+
return {
|
|
58
|
+
currentModel, setCurrentModel, currentModelRef,
|
|
59
|
+
pickerOpen, setPickerOpen,
|
|
60
|
+
pickerModels, pickerLoading, pickerError, pullState,
|
|
61
|
+
openPicker, handleModelSelect, handleModelPull,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { useState, useRef, useCallback, useEffect } from 'react';
|
|
2
|
+
import { chat } from '../../llm/stream.js';
|
|
3
|
+
import { tools as staticTools } from '../../tools/index.js';
|
|
4
|
+
import { StreamParser, extractBareToolCall } from '../../parser/stream-parser.js';
|
|
5
|
+
import { shouldCompact, compactContext } from '../../tasks/compactor.js';
|
|
6
|
+
import * as printer from '../printer.js';
|
|
7
|
+
const MAX_TOOL_DEPTH = 6;
|
|
8
|
+
const FILE_EDIT_TOOLS = new Set(['edit_file', 'create_file', 'patch_file', 'delete_file']);
|
|
9
|
+
const SHOW_RESULT_TOOLS = new Set(['run_tests', 'git_commit']);
|
|
10
|
+
const PERMISSION_TOOLS = new Set(['edit_file', 'patch_file', 'delete_file', 'create_file', 'move_file', 'run_command', 'git_commit']);
|
|
11
|
+
export function useRunLoop(config, currentModelRef, pushHistory, extraTools = [], abortRef) {
|
|
12
|
+
const [status, setStatus] = useState('idle');
|
|
13
|
+
const [tick, setTick] = useState(0);
|
|
14
|
+
const [currentTool, setCurrentTool] = useState();
|
|
15
|
+
const [taskLabel, setTaskLabel] = useState();
|
|
16
|
+
const [permissionRequest, setPermissionRequest] = useState(null);
|
|
17
|
+
const permissionResolveRef = useRef(null);
|
|
18
|
+
const thinkingStartRef = useRef(0);
|
|
19
|
+
const extraToolsRef = useRef(extraTools);
|
|
20
|
+
extraToolsRef.current = extraTools;
|
|
21
|
+
const pushHistoryRef = useRef(pushHistory);
|
|
22
|
+
useEffect(() => { pushHistoryRef.current = pushHistory; }, [pushHistory]);
|
|
23
|
+
const resolvePermission = useCallback((approved) => {
|
|
24
|
+
permissionResolveRef.current?.(approved);
|
|
25
|
+
permissionResolveRef.current = null;
|
|
26
|
+
setPermissionRequest(null);
|
|
27
|
+
}, []);
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (status === 'idle')
|
|
30
|
+
return;
|
|
31
|
+
const t = setInterval(() => setTick(n => n + 1), 80);
|
|
32
|
+
return () => clearInterval(t);
|
|
33
|
+
}, [status]);
|
|
34
|
+
const runLoop = useCallback(async (contextMsgs, depth = 0, goal) => {
|
|
35
|
+
if (depth >= MAX_TOOL_DEPTH) {
|
|
36
|
+
setStatus('idle');
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
setStatus('thinking');
|
|
40
|
+
if (depth === 0)
|
|
41
|
+
thinkingStartRef.current = Date.now();
|
|
42
|
+
const msgs = shouldCompact(contextMsgs) ? compactContext(contextMsgs, goal) : contextMsgs;
|
|
43
|
+
abortRef.current = new AbortController();
|
|
44
|
+
await chat({
|
|
45
|
+
provider: config.provider,
|
|
46
|
+
model: currentModelRef.current,
|
|
47
|
+
baseUrl: config.baseUrl,
|
|
48
|
+
messages: msgs,
|
|
49
|
+
signal: abortRef.current.signal,
|
|
50
|
+
async onDone(fullText) {
|
|
51
|
+
const pendingTools = [];
|
|
52
|
+
const textParts = [];
|
|
53
|
+
const parser = new StreamParser();
|
|
54
|
+
for (const item of [...parser.feed(fullText), ...parser.flush()]) {
|
|
55
|
+
if (item.type === 'tool_call')
|
|
56
|
+
pendingTools.push({ name: item.toolName, args: item.toolArgs });
|
|
57
|
+
else
|
|
58
|
+
textParts.push(item.content);
|
|
59
|
+
}
|
|
60
|
+
if (!pendingTools.length) {
|
|
61
|
+
const bare = extractBareToolCall(fullText);
|
|
62
|
+
if (bare)
|
|
63
|
+
pendingTools.push({ name: bare.name, args: bare.args });
|
|
64
|
+
}
|
|
65
|
+
const displayText = textParts.join('').trim();
|
|
66
|
+
if (displayText)
|
|
67
|
+
printer.assistantMsg(displayText);
|
|
68
|
+
pushHistoryRef.current({ role: 'assistant', content: fullText });
|
|
69
|
+
if (!pendingTools.length) {
|
|
70
|
+
const hasFencedCode = /```[\w]*\n[\s\S]{50,}?\n```/.test(fullText);
|
|
71
|
+
if (hasFencedCode && depth < MAX_TOOL_DEPTH - 1) {
|
|
72
|
+
const nudge = {
|
|
73
|
+
role: 'user',
|
|
74
|
+
content: 'You showed code in your response but did not use any file tools. Use edit_file or patch_file to actually write the changes to disk.',
|
|
75
|
+
};
|
|
76
|
+
await runLoop([...msgs, { role: 'assistant', content: fullText }, nudge], depth + 1, goal);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
setStatus('idle');
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
setStatus('tool');
|
|
83
|
+
const next = [...msgs, { role: 'assistant', content: fullText }];
|
|
84
|
+
try {
|
|
85
|
+
for (const tc of pendingTools) {
|
|
86
|
+
const allTools = [...staticTools, ...extraToolsRef.current];
|
|
87
|
+
const tool = allTools.find(t => t.name === tc.name);
|
|
88
|
+
setCurrentTool(tc.name);
|
|
89
|
+
if (PERMISSION_TOOLS.has(tc.name)) {
|
|
90
|
+
const approved = await new Promise(resolve => {
|
|
91
|
+
permissionResolveRef.current = resolve;
|
|
92
|
+
setPermissionRequest({ toolName: tc.name, args: tc.args });
|
|
93
|
+
});
|
|
94
|
+
if (!approved) {
|
|
95
|
+
printer.systemMsg(`denied: ${tc.name}`);
|
|
96
|
+
next.push({ role: 'user', content: `Tool ${tc.name} was denied by the user` });
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (tool) {
|
|
101
|
+
try {
|
|
102
|
+
printer.toolCallStart(tc.name, tc.args);
|
|
103
|
+
const result = await tool.execute(tc.args);
|
|
104
|
+
if (SHOW_RESULT_TOOLS.has(tc.name))
|
|
105
|
+
printer.toolMsg(tc.name, result);
|
|
106
|
+
next.push({ role: 'user', content: `Tool ${tc.name} result:\n${result}` });
|
|
107
|
+
}
|
|
108
|
+
catch (e) {
|
|
109
|
+
const err = `Tool ${tc.name} error: ${e}`;
|
|
110
|
+
printer.errorMsg(err);
|
|
111
|
+
next.push({ role: 'user', content: err });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
printer.errorMsg(`unknown tool: ${tc.name}`);
|
|
116
|
+
next.push({ role: 'user', content: `unknown tool: ${tc.name}` });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
setCurrentTool(undefined);
|
|
122
|
+
}
|
|
123
|
+
// Auto-run tests after file edits
|
|
124
|
+
const didEditFiles = pendingTools.some(tc => FILE_EDIT_TOOLS.has(tc.name));
|
|
125
|
+
if (didEditFiles) {
|
|
126
|
+
const testTool = staticTools.find(t => t.name === 'run_tests');
|
|
127
|
+
if (testTool) {
|
|
128
|
+
setCurrentTool('run_tests');
|
|
129
|
+
try {
|
|
130
|
+
printer.toolCallStart('run_tests', {});
|
|
131
|
+
const testResult = await testTool.execute({});
|
|
132
|
+
if (testResult && !testResult.startsWith('(no test script') && !testResult.startsWith('(no package.json')) {
|
|
133
|
+
printer.toolMsg('run_tests', testResult);
|
|
134
|
+
next.push({ role: 'user', content: `Test results after edits:\n${testResult}` });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
catch (e) {
|
|
138
|
+
const err = `run_tests error: ${e}`;
|
|
139
|
+
printer.errorMsg(err);
|
|
140
|
+
next.push({ role: 'user', content: err });
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
setCurrentTool(undefined);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
await runLoop(next, depth + 1, goal);
|
|
148
|
+
},
|
|
149
|
+
onError(err) {
|
|
150
|
+
if (err.name !== 'AbortError')
|
|
151
|
+
printer.errorMsg(err.message);
|
|
152
|
+
setStatus('idle');
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
}, [config]);
|
|
156
|
+
const handleAbort = useCallback(() => {
|
|
157
|
+
abortRef.current?.abort();
|
|
158
|
+
if (permissionResolveRef.current) {
|
|
159
|
+
permissionResolveRef.current(false);
|
|
160
|
+
permissionResolveRef.current = null;
|
|
161
|
+
setPermissionRequest(null);
|
|
162
|
+
}
|
|
163
|
+
setStatus('idle');
|
|
164
|
+
}, []);
|
|
165
|
+
return {
|
|
166
|
+
status, setStatus, tick,
|
|
167
|
+
currentTool, setCurrentTool,
|
|
168
|
+
taskLabel, setTaskLabel,
|
|
169
|
+
thinkingStartRef,
|
|
170
|
+
runLoop, handleAbort,
|
|
171
|
+
permissionRequest, resolvePermission,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { useState, useRef, useEffect } from 'react';
|
|
2
|
+
import { loadSession, saveSession, deleteSession } from '../../sessions.js';
|
|
3
|
+
import { getSystemPrompt } from '../../tools/index.js';
|
|
4
|
+
import { getTavilyKey, saveTavilyKey } from '../../tavily/client.js';
|
|
5
|
+
import * as printer from '../printer.js';
|
|
6
|
+
import { loadLongMemory, saveLongMemory, mergeFacts, formatMemoryBlock } from '../../memory/store.js';
|
|
7
|
+
import { extractFacts } from '../../memory/extractor.js';
|
|
8
|
+
const SHORT_MEMORY_SIZE = 40;
|
|
9
|
+
function buildSystemPrompt(cwd, facts) {
|
|
10
|
+
return getSystemPrompt(`\n- CWD: ${cwd}`) + formatMemoryBlock(facts);
|
|
11
|
+
}
|
|
12
|
+
export function useSession(initialSession, cwd, config) {
|
|
13
|
+
const [sessionName, setSessionName] = useState(initialSession);
|
|
14
|
+
const sessionNameRef = useRef(initialSession);
|
|
15
|
+
const historyRef = useRef([]);
|
|
16
|
+
const saveTimerRef = useRef(null);
|
|
17
|
+
const firstMessageSentRef = useRef(false);
|
|
18
|
+
const longMemoryRef = useRef([]);
|
|
19
|
+
const systemPromptRef = useRef(buildSystemPrompt(cwd, []));
|
|
20
|
+
useEffect(() => { sessionNameRef.current = sessionName; }, [sessionName]);
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
const facts = loadLongMemory(initialSession);
|
|
23
|
+
longMemoryRef.current = facts;
|
|
24
|
+
systemPromptRef.current = buildSystemPrompt(cwd, facts);
|
|
25
|
+
if (facts.length)
|
|
26
|
+
printer.systemMsg(`long memory: ${facts.length} facts loaded`);
|
|
27
|
+
const history = loadSession(initialSession);
|
|
28
|
+
historyRef.current = history;
|
|
29
|
+
if (history.length)
|
|
30
|
+
printer.systemMsg(`resumed "${initialSession}" — ${history.length} messages`);
|
|
31
|
+
if (config.tavilyApiKey && !getTavilyKey())
|
|
32
|
+
saveTavilyKey(config.tavilyApiKey);
|
|
33
|
+
if (!getTavilyKey()) {
|
|
34
|
+
printer.systemMsg('Tavily API key not set — web search disabled. Run /tavily-key <key> to enable. Get a free key at https://tavily.com');
|
|
35
|
+
}
|
|
36
|
+
}, []);
|
|
37
|
+
function scheduleSave() {
|
|
38
|
+
if (saveTimerRef.current)
|
|
39
|
+
clearTimeout(saveTimerRef.current);
|
|
40
|
+
saveTimerRef.current = setTimeout(() => {
|
|
41
|
+
saveSession(sessionNameRef.current, historyRef.current);
|
|
42
|
+
saveTimerRef.current = null;
|
|
43
|
+
}, 2000);
|
|
44
|
+
}
|
|
45
|
+
function pushHistory(msg) {
|
|
46
|
+
historyRef.current.push(msg);
|
|
47
|
+
if (historyRef.current.length > SHORT_MEMORY_SIZE) {
|
|
48
|
+
const dropped = historyRef.current.splice(0, historyRef.current.length - SHORT_MEMORY_SIZE);
|
|
49
|
+
extractFacts(dropped, config, config.model).then(newFacts => {
|
|
50
|
+
if (!newFacts.length)
|
|
51
|
+
return;
|
|
52
|
+
const updated = mergeFacts(longMemoryRef.current, newFacts);
|
|
53
|
+
longMemoryRef.current = updated;
|
|
54
|
+
systemPromptRef.current = buildSystemPrompt(cwd, updated);
|
|
55
|
+
saveLongMemory(sessionNameRef.current, updated);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
scheduleSave();
|
|
59
|
+
}
|
|
60
|
+
function renameFromMessage(text) {
|
|
61
|
+
if (firstMessageSentRef.current)
|
|
62
|
+
return;
|
|
63
|
+
firstMessageSentRef.current = true;
|
|
64
|
+
const slug = text
|
|
65
|
+
.toLowerCase()
|
|
66
|
+
.split(/\s+/)
|
|
67
|
+
.slice(0, 5)
|
|
68
|
+
.join('-')
|
|
69
|
+
.replace(/[^\w-]/g, '')
|
|
70
|
+
.replace(/-+/g, '-')
|
|
71
|
+
.replace(/^-|-$/g, '')
|
|
72
|
+
.slice(0, 40) || 'chat';
|
|
73
|
+
const oldName = sessionNameRef.current;
|
|
74
|
+
sessionNameRef.current = slug;
|
|
75
|
+
setSessionName(slug);
|
|
76
|
+
try {
|
|
77
|
+
deleteSession(oldName);
|
|
78
|
+
}
|
|
79
|
+
catch { }
|
|
80
|
+
}
|
|
81
|
+
function buildContext(extra) {
|
|
82
|
+
const ctx = [{ role: 'system', content: systemPromptRef.current }];
|
|
83
|
+
ctx.push(...historyRef.current);
|
|
84
|
+
if (extra)
|
|
85
|
+
ctx.push(extra);
|
|
86
|
+
return ctx;
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
sessionName, setSessionName, sessionNameRef,
|
|
90
|
+
historyRef, saveTimerRef, systemPromptRef,
|
|
91
|
+
pushHistory, buildContext, renameFromMessage,
|
|
92
|
+
};
|
|
93
|
+
}
|
package/dist/tui/printer.js
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
// ANSI-formatted stdout output — goes into terminal scrollback
|
|
2
|
+
let _inkClear = null;
|
|
3
|
+
export function setInkInstance(clear) {
|
|
4
|
+
_inkClear = clear;
|
|
5
|
+
}
|
|
6
|
+
function write(s) {
|
|
7
|
+
_inkClear?.();
|
|
8
|
+
process.stdout.write(s);
|
|
9
|
+
}
|
|
2
10
|
const R = '\x1b[0m';
|
|
3
11
|
const BOLD = '\x1b[1m';
|
|
4
12
|
const DIM = '\x1b[2m';
|
|
@@ -55,7 +63,7 @@ function formatContent(text) {
|
|
|
55
63
|
function truncate(s, n) {
|
|
56
64
|
return s.length > n ? s.slice(0, n) + '…' : s;
|
|
57
65
|
}
|
|
58
|
-
function toolArgSummary(args) {
|
|
66
|
+
export function toolArgSummary(args) {
|
|
59
67
|
if (args.message)
|
|
60
68
|
return `"${truncate(String(args.message), 60)}"`;
|
|
61
69
|
if (args.path)
|
|
@@ -134,7 +142,7 @@ export function welcome(provider, model, cwd, version, updateAvailable, linked)
|
|
|
134
142
|
}
|
|
135
143
|
export function userMsg(text) {
|
|
136
144
|
const atHighlighted = text.replace(/(@[\w./\-]+)/g, (m) => cyan(m));
|
|
137
|
-
|
|
145
|
+
write(`\n${gray('>>')} ${atHighlighted}\n`);
|
|
138
146
|
}
|
|
139
147
|
export function assistantMsg(text) {
|
|
140
148
|
const content = formatContent(text);
|
|
@@ -146,14 +154,14 @@ export function assistantMsg(text) {
|
|
|
146
154
|
return;
|
|
147
155
|
const head = lines[idx].replace(/^ {2}/, '');
|
|
148
156
|
const tail = lines.slice(idx + 1).join('\n');
|
|
149
|
-
|
|
157
|
+
write(`\n${blue('●')} ${head}${tail ? '\n' + tail : ''}\n`);
|
|
150
158
|
}
|
|
151
159
|
const EDIT_TOOLS = new Set(['edit_file', 'patch_file', 'create_file', 'write_file']);
|
|
152
160
|
const DELETE_TOOLS = new Set(['delete_file', 'remove_file']);
|
|
153
161
|
export function toolCallStart(name, args) {
|
|
154
162
|
const summary = toolArgSummary(args);
|
|
155
163
|
const dot = DELETE_TOOLS.has(name) ? red('●') : EDIT_TOOLS.has(name) ? green('●') : blue('●');
|
|
156
|
-
|
|
164
|
+
write(` ${dot} ${cyan(name)}${summary ? gray('(' + summary + ')') : ''}\n`);
|
|
157
165
|
}
|
|
158
166
|
export function toolMsg(name, result) {
|
|
159
167
|
const preview = result.length > 250 ? result.slice(0, 250) + '…' : result;
|
|
@@ -161,15 +169,15 @@ export function toolMsg(name, result) {
|
|
|
161
169
|
? preview.split('\n').map(l => gray(' ' + l)).join('\n')
|
|
162
170
|
: '';
|
|
163
171
|
if (body)
|
|
164
|
-
|
|
172
|
+
write(body + '\n');
|
|
165
173
|
}
|
|
166
174
|
export function systemMsg(text) {
|
|
167
|
-
|
|
175
|
+
write(gray(`─ ${text}`) + '\n');
|
|
168
176
|
}
|
|
169
177
|
export function errorMsg(text) {
|
|
170
|
-
|
|
178
|
+
write(gray(`error: ${text}`) + '\n');
|
|
171
179
|
}
|
|
172
180
|
export function divider() {
|
|
173
181
|
const cols = process.stdout.columns ?? 80;
|
|
174
|
-
|
|
182
|
+
write(`${gray('─'.repeat(cols))}\n`);
|
|
175
183
|
}
|