@vierratale/ai 0.1.0-beta.1 → 0.1.0-beta.11
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 +157 -15
- package/bin/vierrataleai.js +74 -1
- package/package.json +10 -2
- package/scripts/postinstall.js +35 -0
- package/src/catalog.js +94 -45
- package/src/cli.js +1098 -51
- package/src/cmd/agent.js +1711 -0
- package/src/cmd/executor.js +257 -0
- package/src/cmd/tools.js +153 -0
- package/src/config.js +54 -15
- package/src/index.js +2 -1
- package/src/installer.js +159 -15
- package/src/prompts/system.json +2 -2
- package/src/prompts/system.md +42 -0
- package/src/providers/anthropic.js +130 -0
- package/src/providers/base.js +18 -0
- package/src/providers/cortex.js +298 -0
- package/src/providers/gemini.js +128 -0
- package/src/providers/index.js +7 -3
- package/src/providers/openai.js +79 -34
- package/src/session.js +221 -0
- package/src/ui/banner.js +23 -12
- package/src/ui/branding.js +8 -9
- package/src/ui/chatbox.js +280 -0
- package/src/ui/input.js +501 -0
- package/src/ui/terminal.js +84 -8
- package/src/utils/downloader.js +364 -0
- package/src/utils/filewriter.js +97 -0
- package/src/utils/intents.js +102 -0
- package/src/utils/logger.js +67 -0
- package/src/utils/math.js +142 -0
- package/src/utils/webfetch.js +667 -0
- package/src/utils/websearch.js +262 -0
- package/build.sh +0 -17
- package/src/providers/ollama.js +0 -89
package/src/cli.js
CHANGED
|
@@ -1,14 +1,26 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFile } from 'child_process';
|
|
2
|
+
import { promisify } from 'util';
|
|
2
3
|
import { Config } from './config.js';
|
|
3
4
|
import { Catalog } from './catalog.js';
|
|
4
5
|
import { Installer } from './installer.js';
|
|
5
6
|
import { ProviderFactory } from './providers/index.js';
|
|
6
7
|
import { Branding } from './ui/branding.js';
|
|
7
8
|
import { Terminal } from './ui/terminal.js';
|
|
8
|
-
import { loadSystemPrompt
|
|
9
|
+
import { loadSystemPrompt } from './ui/banner.js';
|
|
10
|
+
import { LineEditor } from './ui/input.js';
|
|
11
|
+
import { WebSearch } from './utils/websearch.js';
|
|
12
|
+
import { WebFetch } from './utils/webfetch.js';
|
|
13
|
+
import { Downloader } from './utils/downloader.js';
|
|
14
|
+
import { FileWriter } from './utils/filewriter.js';
|
|
15
|
+
import { detectIntent, extractSearchTopic, looksLikeStoryRequest } from './utils/intents.js';
|
|
16
|
+
import { trySolveMath } from './utils/math.js';
|
|
17
|
+
import { Session } from './session.js';
|
|
18
|
+
import { ChatUI, FrameThrottle } from './ui/chatbox.js';
|
|
19
|
+
import { logger } from './utils/logger.js';
|
|
20
|
+
import { runWithTools, TOOL_RESULTS_PROMPT, looksLikeOperationRequest } from './cmd/agent.js';
|
|
9
21
|
|
|
10
22
|
function parseArgs(args) {
|
|
11
|
-
const parsed = { provider: null, model: null, clear: false, version: false, help: false };
|
|
23
|
+
const parsed = { provider: null, model: null, clear: false, version: false, help: false, install: false };
|
|
12
24
|
for (let i = 2; i < args.length; i++) {
|
|
13
25
|
const arg = args[i];
|
|
14
26
|
if (arg === '--provider' || arg === '-p') parsed.provider = args[++i];
|
|
@@ -16,6 +28,7 @@ function parseArgs(args) {
|
|
|
16
28
|
else if (arg === '--clear') parsed.clear = true;
|
|
17
29
|
else if (arg === '--version' || arg === '-v') parsed.version = true;
|
|
18
30
|
else if (arg === '--help' || arg === '-h') parsed.help = true;
|
|
31
|
+
else if (arg === '--install' || arg === '--setup' || arg === 'install') parsed.install = true;
|
|
19
32
|
}
|
|
20
33
|
return parsed;
|
|
21
34
|
}
|
|
@@ -26,9 +39,10 @@ ${Branding.APP_NAME} ${Branding.VERSION}
|
|
|
26
39
|
Usage: vierrataleai [options]
|
|
27
40
|
|
|
28
41
|
Options:
|
|
29
|
-
--provider, -p <name> Provider:
|
|
30
|
-
--model, -m <name> Model:
|
|
31
|
-
--clear
|
|
42
|
+
--provider, -p <name> Provider: cortex, openai (default: auto-detect)
|
|
43
|
+
--model, -m <name> Model: VRTL-1.lite/2.fast/4.balanced/6.pro/8.cloud
|
|
44
|
+
--clear (deprecated: screen is always cleared on start)
|
|
45
|
+
--install, --setup Install engine + model + a "vierrataleai" command, then run
|
|
32
46
|
--version, -v Show version
|
|
33
47
|
--help, -h Show this help
|
|
34
48
|
|
|
@@ -37,39 +51,564 @@ Commands (in chat):
|
|
|
37
51
|
/model [name] Switch model
|
|
38
52
|
/provider [name] Switch provider
|
|
39
53
|
/models List available models
|
|
54
|
+
/search <query> Search the web and summarize with AI
|
|
55
|
+
/fetch <url> Open a link and summarize its content
|
|
56
|
+
/download <url> Download a file, folder listing or archive
|
|
57
|
+
/log [n|path] Show the last n log entries (default 30), or the log file path
|
|
40
58
|
/clear Clear screen
|
|
59
|
+
/new Start a new empty session (keeps old ones)
|
|
60
|
+
/sessions List saved sessions
|
|
61
|
+
/session new [name] Create and switch to a session
|
|
62
|
+
/session open <name|#> Resume a session (e.g. /session open #2)
|
|
63
|
+
/session delete <name|#> Delete a session
|
|
64
|
+
/session backup [name|#] Back up a session to a .bak.json (auto on fresh start)
|
|
65
|
+
/session backups List session backups
|
|
66
|
+
/session restore <n|#> Restore a backup into a session
|
|
41
67
|
/quit Exit
|
|
42
68
|
`);
|
|
43
69
|
}
|
|
44
70
|
|
|
71
|
+
export const SLASH_COMMANDS = [
|
|
72
|
+
{ name: 'help', desc: 'Show all commands' },
|
|
73
|
+
{ name: 'clear', desc: 'Clear the screen' },
|
|
74
|
+
{ name: 'new', desc: 'Start a new empty conversation' },
|
|
75
|
+
{ name: 'model', args: '[name]', desc: 'Switch model' },
|
|
76
|
+
{ name: 'provider', args: '[name]', desc: 'Switch provider' },
|
|
77
|
+
{ name: 'models', desc: 'List available models' },
|
|
78
|
+
{ name: 'search', args: '<query>', desc: 'Search the web and summarize' },
|
|
79
|
+
{ name: 'fetch', args: '<url>', desc: 'Open a link and summarize' },
|
|
80
|
+
{ name: 'download', args: '<url>', desc: 'Download a file / folder / archive' },
|
|
81
|
+
{ name: 'system', desc: 'Show or set the system prompt' },
|
|
82
|
+
{ name: 'sessions', desc: 'List saved sessions' },
|
|
83
|
+
{ name: 'session', args: 'new | open | delete | backup | restore', desc: 'Manage sessions' },
|
|
84
|
+
{ name: 'log', args: '[n | path]', desc: 'Show recent log entries' },
|
|
85
|
+
{ name: 'quit', desc: 'Exit' },
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
const FILE_VERBS =
|
|
89
|
+
'write|save|make|create|generate|build|code|design|implement|produce|add|store|put|give me|show me|make me|help me|need|want|have';
|
|
90
|
+
const FILE_NOUNS =
|
|
91
|
+
'file|folder|directory|txt|json|md|markdown|text file|document|html|css|js|javascript|script|page|webpage|website|code|project|config|app|application|api|bot|game|tool|crawler|scraper';
|
|
92
|
+
const KNOWLEDGE_MARKERS =
|
|
93
|
+
/\b(how|why|what is|what are|what's|whats|explain|learn|teach me|meaning|means|used for|tutorial|why is|what does|is it|does it)\b/i;
|
|
94
|
+
const CONCRETE_FILE_ASK = new RegExp(
|
|
95
|
+
`\\b(?:create|make|write|save|generate|build|produce)\\b[^.!?\\n]{0,60}\\b(?:${FILE_NOUNS})\\b`,
|
|
96
|
+
'i'
|
|
97
|
+
);
|
|
98
|
+
const FILE_ASK_STRONG = new RegExp(
|
|
99
|
+
`\\b(?:${FILE_VERBS})\\b[^.!?]{0,80}\\b(?:${FILE_NOUNS})\\b`,
|
|
100
|
+
'i'
|
|
101
|
+
);
|
|
102
|
+
const FILE_ASK_SENTENCE = new RegExp(
|
|
103
|
+
`\\b(?:${FILE_VERBS})\\b[^.!?\\n]{0,160}\\b(?:${FILE_NOUNS})\\b`,
|
|
104
|
+
'i'
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
// Attached as a hidden user message to file requests so small local models
|
|
108
|
+
// reliably emit complete FILE: blocks with closed fences instead of free-style
|
|
109
|
+
// prose that gets truncated or never closes its code fences.
|
|
110
|
+
const FILE_BLOCK_PROMPT = `If the user asked you to create a file or project with code, output your whole answer as one or more file blocks — nothing else:
|
|
111
|
+
|
|
112
|
+
FILE: path/to/file.ext
|
|
113
|
+
${'```'}language
|
|
114
|
+
<complete, working code>
|
|
115
|
+
${'```'}
|
|
116
|
+
|
|
117
|
+
Rules:
|
|
118
|
+
- One FILE: header at the start of each file, immediately followed by its fenced code block.
|
|
119
|
+
- Always close every fence with ${'```'}.
|
|
120
|
+
- Write real, complete code on the first try — no placeholders, no "[0]", no Lorem ipsum, no "This is a basic example" filler, no fabricated external script URLs.
|
|
121
|
+
- If the request mentions serving or routing the files (e.g. a python web server, flask, routes), also write the server file (like app.py) that serves them.`;
|
|
122
|
+
|
|
123
|
+
function looksLikeFileRequest(text) {
|
|
124
|
+
const s = String(text || '');
|
|
125
|
+
if (KNOWLEDGE_MARKERS.test(s) && !CONCRETE_FILE_ASK.test(s)) return false;
|
|
126
|
+
if (FILE_ASK_STRONG.test(s)) return true;
|
|
127
|
+
return FILE_ASK_SENTENCE.test(s);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const CODE_FILE_RE = /([\w.-]+\.(?:html?|css|json|jsx|tsx|mjs|cjs|ts|js|md|markdown|txt|py|php|rb|go|java|c|cpp|h|sh|bat|yml|yaml|xml|sql|svg|csv|env))/i;
|
|
131
|
+
|
|
132
|
+
function requestedFileName(text) {
|
|
133
|
+
const m = String(text || '').match(CODE_FILE_RE);
|
|
134
|
+
if (m) return m[1];
|
|
135
|
+
const lower = String(text || '').toLowerCase();
|
|
136
|
+
if (/\b(python|py)\b/.test(lower) && /\b(html?|css|javascript|js|website|webpage|web|route)\b/.test(lower)) return 'app.py';
|
|
137
|
+
if (/\b(python|py)\b/.test(lower)) return 'script.py';
|
|
138
|
+
if (/\b(flask|django|fastapi|streamlit)\b/.test(lower)) return 'app.py';
|
|
139
|
+
if (/\b(bash|zsh|shell)\b/.test(lower)) return 'script.sh';
|
|
140
|
+
if (/\b(javascript|js|script)\b/.test(lower)) return 'script.js';
|
|
141
|
+
if (/\b(typescript|ts)\b/.test(lower)) return 'script.ts';
|
|
142
|
+
if (/\bjson\b/.test(lower)) return 'output.json';
|
|
143
|
+
if (/\b(markdown|md)\b/.test(lower)) return 'output.md';
|
|
144
|
+
if (/\b(html?|webpage|website|page)\b/.test(lower)) return 'index.html';
|
|
145
|
+
if (/\bcss\b/.test(lower)) return 'style.css';
|
|
146
|
+
if (/\bapp\b/.test(lower)) return 'app.py';
|
|
147
|
+
if (/\b(yaml|yml)\b/.test(lower)) return 'output.yml';
|
|
148
|
+
if (/\bcsv\b/.test(lower)) return 'output.csv';
|
|
149
|
+
if (/\bsql\b/.test(lower)) return 'output.sql';
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function extractFallbackFile(response, requestText) {
|
|
154
|
+
const lines = String(response || '').split('\n');
|
|
155
|
+
const headerIdx = lines.findIndex((l) => /^FILE:\s*.+$/i.test(l));
|
|
156
|
+
if (headerIdx !== -1) {
|
|
157
|
+
const path = lines[headerIdx].replace(/^FILE:\s*/i, '').trim();
|
|
158
|
+
const content = lines
|
|
159
|
+
.slice(headerIdx + 1)
|
|
160
|
+
.map((l) => (/^```/.test(l.trim()) ? '' : l))
|
|
161
|
+
.join('\n')
|
|
162
|
+
.trim();
|
|
163
|
+
return { path, content };
|
|
164
|
+
}
|
|
165
|
+
const fenced = String(response || '').match(/```([\w./+-]*)[^\n]*\n?([\s\S]*?)(?:```|$)/);
|
|
166
|
+
const content = fenced ? fenced[2].trim() : String(response || '').replace(/^FILE:\s*\S+\s*$/gim, '').trim();
|
|
167
|
+
const path = requestedFileName(requestText) || languageFileName(fenced ? fenced[1] : null) || storyFileName(requestText);
|
|
168
|
+
return { path, content };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function storyFileName(text) {
|
|
172
|
+
const topic = extractSearchTopic(text);
|
|
173
|
+
let raw = (topic || 'story')
|
|
174
|
+
.toLowerCase()
|
|
175
|
+
.replace(/\s+and\s+(?:write|save|make|create|generate|build|give|put)\b.*$/i, '')
|
|
176
|
+
.replace(/\s+(?:in|into|to)\s+.*?(?:file|txt|json|md|csv)\b.*$/i, '')
|
|
177
|
+
.replace(/\s+for\s+me\s*$/i, '')
|
|
178
|
+
.trim();
|
|
179
|
+
const slug = raw.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
180
|
+
return `${slug || 'story'}.txt`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function responseWantsFile(response) {
|
|
184
|
+
return /^(?:FILE|FOLDER):\s*\S/im.test(String(response || ''));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function languageFileName(lang) {
|
|
188
|
+
const map = {
|
|
189
|
+
html: 'index.html', htm: 'index.html', xml: 'index.html',
|
|
190
|
+
css: 'style.css', js: 'script.js', javascript: 'script.js',
|
|
191
|
+
ts: 'script.ts', json: 'output.json', md: 'output.md',
|
|
192
|
+
markdown: 'output.md', py: 'script.py', python: 'script.py',
|
|
193
|
+
txt: 'output.txt', sh: 'script.sh', bash: 'script.sh',
|
|
194
|
+
yaml: 'output.yml', yml: 'output.yml', csv: 'output.csv',
|
|
195
|
+
svg: 'output.svg', sql: 'output.sql', go: 'main.go',
|
|
196
|
+
java: 'Main.java', rb: 'script.rb', php: 'script.php',
|
|
197
|
+
c: 'main.c', cpp: 'main.cpp', h: 'main.h',
|
|
198
|
+
};
|
|
199
|
+
return map[String(lang || '').toLowerCase()] || null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// True when the reply is basically "just code": one or more substantial
|
|
203
|
+
// fenced code blocks and very little surrounding prose.
|
|
204
|
+
function responseIsSoloCode(response) {
|
|
205
|
+
const text = String(response || '');
|
|
206
|
+
const blocks = [...text.matchAll(/```[\w./+-]*[^\n]*\n?([\s\S]*?)(?:```|$)/g)];
|
|
207
|
+
if (!blocks.length) return false;
|
|
208
|
+
const totalCode = blocks.reduce((n, b) => n + b[1].length, 0);
|
|
209
|
+
const prose = text.replace(/```[\s\S]*?(?:```|$)/g, ' ').replace(/\s+/g, ' ').trim();
|
|
210
|
+
return totalCode >= 50 && prose.length <= 60;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Prose-only replies are fine for story requests (.txt), but a file/code
|
|
214
|
+
// request with no fenced block and no FILE: header must not be saved as code.
|
|
215
|
+
function fallbackHasUsefulCode(response, requestText) {
|
|
216
|
+
const hasCode = /```[\w./+-]*/m.test(String(response || '')) || responseWantsFile(response);
|
|
217
|
+
return looksLikeStoryRequest(requestText) || hasCode;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// After a .py file is saved, compile-check it so truncated or broken replies
|
|
221
|
+
// never sit on disk silently. Returns a promise so callers can await the check
|
|
222
|
+
// before surfacing results (the test previously raced a fire-and-forget exec).
|
|
223
|
+
async function validateWrittenCode(fileName, notify) {
|
|
224
|
+
if (!/\.py$/i.test(fileName)) return;
|
|
225
|
+
try {
|
|
226
|
+
await promisify(execFile)(
|
|
227
|
+
'python3',
|
|
228
|
+
['-c', 'import sys; compile(open(sys.argv[1]).read(), sys.argv[1], "exec")', fileName]
|
|
229
|
+
);
|
|
230
|
+
} catch (err) {
|
|
231
|
+
const msg = `Saved ${fileName}, but Python couldn't compile it (the reply may have been cut short).`;
|
|
232
|
+
notify(msg);
|
|
233
|
+
logger.log('ERROR', `invalid python saved to ${fileName}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Small local models often emit recognizable placeholder/sample scaffolding
|
|
238
|
+
// (token artifacts like "[0]", fabricated external script URLs, "this is a
|
|
239
|
+
// basic example" text) that compiles but is not real, useful code. Detect
|
|
240
|
+
// the telltale signs so the user is warned instead of silently saving junk.
|
|
241
|
+
function looksLikeJunkCode(content, fileName) {
|
|
242
|
+
const text = String(content || '');
|
|
243
|
+
return /(code\.google\.com|chrome-extension|Lorem ipsum|placeholder text|\b[0-9]+\b\s*#\s*(This is|Python)|\bnot real code\b|\bcopy[-\s]?paste\b)/i.test(text);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Runs the junk check after a successful write and warns + logs if flagged.
|
|
247
|
+
function warnIfJunkCode(fileName, content, notify) {
|
|
248
|
+
if (!looksLikeJunkCode(content, fileName)) return;
|
|
249
|
+
const msg = `Heads up: ${fileName} looks like placeholder/sample content (the local model may be too weak for real code). Check it, or try importing from a stronger engine or model.`;
|
|
250
|
+
notify(msg);
|
|
251
|
+
logger.log('WARN', `possible junk content saved to ${fileName}`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Like FileWriter.parse, but tolerant of an unclosed trailing fence (the
|
|
255
|
+
// model often truncates) so truncated FILE: blocks can still be rescued.
|
|
256
|
+
function extractFileBlocks(response) {
|
|
257
|
+
const blocks = [];
|
|
258
|
+
const lines = String(response || '').split('\n');
|
|
259
|
+
const n = lines.length;
|
|
260
|
+
let i = 0;
|
|
261
|
+
while (i < n) {
|
|
262
|
+
const m = /^FILE:\s*(.+)\s*$/i.exec(lines[i]);
|
|
263
|
+
if (m) {
|
|
264
|
+
let j = i + 1;
|
|
265
|
+
while (j < n && lines[j].trim() === '') j++;
|
|
266
|
+
const fence = /^```([\w./+-]*)/.exec(lines[j] || '');
|
|
267
|
+
if (fence) {
|
|
268
|
+
const content = [];
|
|
269
|
+
let k = j + 1;
|
|
270
|
+
while (k < n && !/^```/.test(lines[k])) {
|
|
271
|
+
content.push(lines[k]);
|
|
272
|
+
k++;
|
|
273
|
+
}
|
|
274
|
+
blocks.push({ path: m[1].trim(), language: fence[1], content: content.join('\n').trim() });
|
|
275
|
+
i = Math.max(k, j + 1);
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
i++;
|
|
280
|
+
}
|
|
281
|
+
return blocks;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function writeFilesFromResponse(response, confirm, requestText, notify) {
|
|
285
|
+
const { files, folders } = FileWriter.parse(response);
|
|
286
|
+
const contentByPath = new Map(files.map((f) => [f.path, f.content]));
|
|
287
|
+
const results = await FileWriter.write({ files, folders, overwrite: false, confirm });
|
|
288
|
+
let wroteAny = false;
|
|
289
|
+
for (const r of results) {
|
|
290
|
+
const label = r.kind === 'folder' ? 'folder' : 'file';
|
|
291
|
+
if (r.status === 'written' || r.status === 'created') {
|
|
292
|
+
wroteAny = true;
|
|
293
|
+
notify(`Wrote ${label}: ${r.path}`);
|
|
294
|
+
logger.log('FILE', `wrote ${label} ${r.path}${r.target ? ` (${r.target})` : ''}`);
|
|
295
|
+
await validateWrittenCode(r.path, notify);
|
|
296
|
+
warnIfJunkCode(r.path, contentByPath.get(r.path) || '', notify);
|
|
297
|
+
} else if (r.status === 'skipped') {
|
|
298
|
+
notify(`Skipped ${label}: ${r.path} (${r.error})`);
|
|
299
|
+
logger.log('FILE', `skipped ${label} ${r.path}${r.target ? ` (${r.target})` : ''}: ${r.error}`);
|
|
300
|
+
} else {
|
|
301
|
+
notify(`Failed to write ${label}: ${r.path} (${r.error})`);
|
|
302
|
+
logger.log('ERROR', `failed writing ${label} ${r.path}: ${r.error}`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Rescue FILE: blocks the parser dropped because their trailing fence was
|
|
307
|
+
// truncated (the model truncates often); otherwise data is lost silently.
|
|
308
|
+
const handledPaths = new Set(results.filter((x) => x.kind === 'file').map((x) => x.path));
|
|
309
|
+
for (const b of extractFileBlocks(response)) {
|
|
310
|
+
if (handledPaths.has(b.path) || !b.content.trim()) continue;
|
|
311
|
+
const rescue = await FileWriter.write({
|
|
312
|
+
files: [{ path: b.path, content: b.content }],
|
|
313
|
+
overwrite: false,
|
|
314
|
+
confirm,
|
|
315
|
+
});
|
|
316
|
+
const r0 = rescue[0];
|
|
317
|
+
if (r0.status === 'written') {
|
|
318
|
+
wroteAny = true;
|
|
319
|
+
notify(`Wrote file: ${b.path} (recovered from truncated reply)`);
|
|
320
|
+
logger.log('FILE', `wrote file ${b.path} (rescued truncated block)${r0.target ? ` at ${r0.target}` : ''}`);
|
|
321
|
+
await validateWrittenCode(b.path, notify);
|
|
322
|
+
warnIfJunkCode(b.path, b.content, notify);
|
|
323
|
+
} else if (r0.status === 'skipped') {
|
|
324
|
+
notify(`Skipped file: ${b.path} (${r0.error})`);
|
|
325
|
+
} else {
|
|
326
|
+
notify(`Failed to write file: ${b.path} (${r0.error})`);
|
|
327
|
+
logger.log('ERROR', `failed writing file ${b.path}: ${r0.error}`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (!wroteAny && response && (
|
|
332
|
+
responseWantsFile(response) ||
|
|
333
|
+
responseIsSoloCode(response) ||
|
|
334
|
+
looksLikeFileRequest(requestText) ||
|
|
335
|
+
looksLikeStoryRequest(requestText)
|
|
336
|
+
)) {
|
|
337
|
+
const { path: name, content } = extractFallbackFile(response, requestText);
|
|
338
|
+
if (!fallbackHasUsefulCode(response, requestText)) {
|
|
339
|
+
notify('The model replied without any code blocks — nothing was saved. Try again.');
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (!content.trim()) {
|
|
343
|
+
notify('The model returned no code — nothing was saved. Try again.');
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
let fallback;
|
|
347
|
+
try {
|
|
348
|
+
fallback = await FileWriter.write({
|
|
349
|
+
files: [{ path: name, content }],
|
|
350
|
+
overwrite: false,
|
|
351
|
+
confirm,
|
|
352
|
+
});
|
|
353
|
+
} catch (err) {
|
|
354
|
+
notify(`Failed to write file: ${name} (${err.message})`);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
const res = fallback[0];
|
|
358
|
+
if (res.status === 'written') {
|
|
359
|
+
notify(`Wrote file: ${name}`);
|
|
360
|
+
logger.log('FILE', `wrote file ${name}${res.target ? ` (${res.target})` : ''}`);
|
|
361
|
+
await validateWrittenCode(name, notify);
|
|
362
|
+
warnIfJunkCode(name, content, notify);
|
|
363
|
+
const fenceCount = [...String(response).matchAll(/```[\w./+-]*[^\n]*\n?([\s\S]*?)(?:```|$)/g)].length;
|
|
364
|
+
if (fenceCount > 1) {
|
|
365
|
+
notify(`Note: the reply contained ${fenceCount} code blocks but only ${name} was saved. Ask me to save the others (one FILE: request per file).`);
|
|
366
|
+
logger.log('WARN', `multi-block reply: saved only ${name} of ${fenceCount} code blocks`);
|
|
367
|
+
}
|
|
368
|
+
} else if (res.status === 'skipped') {
|
|
369
|
+
notify(`Skipped file: ${name} (${res.error})`);
|
|
370
|
+
logger.log('FILE', `skipped file ${name}${res.target ? ` (${res.target})` : ''}: ${res.error}`);
|
|
371
|
+
} else {
|
|
372
|
+
notify(`Failed to write file: ${name} (${res.error})`);
|
|
373
|
+
logger.log('ERROR', `failed writing file ${name}: ${res.error}`);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function answerWithSearch(messages, provider, query, systemPrompt, confirm, requestText, chatUI) {
|
|
379
|
+
chatUI.clearNotices();
|
|
380
|
+
chatUI.setStatus(`Searching the web for "${query}"…`);
|
|
381
|
+
chatUI.render(messages);
|
|
382
|
+
const results = await WebSearch.search(query);
|
|
383
|
+
|
|
384
|
+
let userContent;
|
|
385
|
+
if (results.length > 0) {
|
|
386
|
+
chatUI.setStatus('');
|
|
387
|
+
chatUI.notify(`Found ${results.length} web result${results.length === 1 ? '' : 's'} for "${query}"`);
|
|
388
|
+
const searchContext = results.map((r, i) =>
|
|
389
|
+
`${i + 1}. ${r.title}\n URL: ${r.url}\n ${r.snippet}`
|
|
390
|
+
).join('\n\n');
|
|
391
|
+
userContent = `Web search results for "${query}":\n\n${searchContext}\n\nAnswer based on these results.${looksLikeStoryRequest(requestText) ? ' If this is a famous story, folk tale, legend, or myth, tell the real, well-known version fully and save it to a .txt file too.' : ''}`;
|
|
392
|
+
} else {
|
|
393
|
+
chatUI.setStatus('');
|
|
394
|
+
chatUI.notify('No web results — answering from my own knowledge.');
|
|
395
|
+
userContent = query;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
messages.push({ role: 'user', content: userContent, hidden: true });
|
|
399
|
+
Session.save(messages);
|
|
400
|
+
|
|
401
|
+
chatUI.setThinking(true);
|
|
402
|
+
chatUI.render(messages);
|
|
403
|
+
let response = '';
|
|
404
|
+
const frame = new FrameThrottle(() => chatUI.render(messages));
|
|
405
|
+
try {
|
|
406
|
+
for await (const chunk of provider.stream(messages, {
|
|
407
|
+
model: Config.getEffectiveModel(provider.name),
|
|
408
|
+
systemPrompt,
|
|
409
|
+
maxTokens: 600,
|
|
410
|
+
})) {
|
|
411
|
+
response += chunk;
|
|
412
|
+
chatUI.setStreaming(response);
|
|
413
|
+
frame.schedule();
|
|
414
|
+
}
|
|
415
|
+
} catch (err) {
|
|
416
|
+
chatUI.notify(err.message || 'Stream error');
|
|
417
|
+
logger.log('ERROR', err.message || 'Stream error');
|
|
418
|
+
}
|
|
419
|
+
frame.flush();
|
|
420
|
+
if (response) {
|
|
421
|
+
messages.push({ role: 'assistant', content: response });
|
|
422
|
+
logger.log('AI', response);
|
|
423
|
+
chatUI.setStreaming(null);
|
|
424
|
+
await writeFilesFromResponse(response, confirm, requestText, (msg) => chatUI.notify(msg));
|
|
425
|
+
}
|
|
426
|
+
chatUI.render(messages);
|
|
427
|
+
Session.save(messages);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function extractUrlFromText(text) {
|
|
431
|
+
const m = text.match(/https?:\/\/[^\s<>"']+/i);
|
|
432
|
+
return m ? m[0] : null;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async function answerWithFetch(messages, provider, url, systemPrompt, confirm, chatUI, requestText = '') {
|
|
436
|
+
chatUI.clearNotices();
|
|
437
|
+
chatUI.setStatus(`Opening ${url}…`);
|
|
438
|
+
chatUI.render(messages);
|
|
439
|
+
let page;
|
|
440
|
+
try {
|
|
441
|
+
page = await WebFetch.fetch(url);
|
|
442
|
+
} catch (err) {
|
|
443
|
+
chatUI.setStatus('');
|
|
444
|
+
chatUI.notify(err.message || 'Could not fetch the link.');
|
|
445
|
+
logger.log('ERROR', err.message || 'Could not fetch the link.');
|
|
446
|
+
chatUI.render(messages);
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
chatUI.setStatus('');
|
|
451
|
+
const assets = page.assets || [];
|
|
452
|
+
chatUI.notify(`Opened: ${page.title || page.url}${assets.length ? ` · ${assets.length} download ready` : ''}`);
|
|
453
|
+
const content = page.text.trim();
|
|
454
|
+
if (!content) {
|
|
455
|
+
chatUI.notify('Nothing readable found on that page.');
|
|
456
|
+
chatUI.render(messages);
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
let assetBlock = '';
|
|
461
|
+
if (assets.length > 0) {
|
|
462
|
+
assetBlock =
|
|
463
|
+
`\n\nDownloadable files found on the page:\n${assets
|
|
464
|
+
.map((a, i) => `${i + 1}. [${a.type}] ${a.name} — ${a.url}`)
|
|
465
|
+
.join('\n')}` +
|
|
466
|
+
'\n\nIf the user asks to download any of these, tell them it is saved at ~/Downloads and actually download each one with the download_url tool.';
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
messages.push({
|
|
470
|
+
role: 'user',
|
|
471
|
+
content: `Here is the content fetched from the URL "${page.url}" (${page.title || 'no title'}):\n\n${content}${assetBlock}\n\nPlease summarize and answer based on this content. Be concise.`,
|
|
472
|
+
hidden: true,
|
|
473
|
+
});
|
|
474
|
+
Session.save(messages);
|
|
475
|
+
|
|
476
|
+
chatUI.setThinking(true);
|
|
477
|
+
chatUI.render(messages);
|
|
478
|
+
let response = '';
|
|
479
|
+
const frame = new FrameThrottle(() => chatUI.render(messages));
|
|
480
|
+
try {
|
|
481
|
+
for await (const chunk of provider.stream(messages, {
|
|
482
|
+
model: Config.getEffectiveModel(provider.name),
|
|
483
|
+
systemPrompt,
|
|
484
|
+
maxTokens: 600,
|
|
485
|
+
})) {
|
|
486
|
+
response += chunk;
|
|
487
|
+
chatUI.setStreaming(response);
|
|
488
|
+
frame.schedule();
|
|
489
|
+
}
|
|
490
|
+
} catch (err) {
|
|
491
|
+
chatUI.notify(err.message || 'Stream error');
|
|
492
|
+
logger.log('ERROR', err.message || 'Stream error');
|
|
493
|
+
}
|
|
494
|
+
frame.flush();
|
|
495
|
+
if (response) {
|
|
496
|
+
messages.push({ role: 'assistant', content: response });
|
|
497
|
+
logger.log('AI', response);
|
|
498
|
+
chatUI.setStreaming(null);
|
|
499
|
+
await writeFilesFromResponse(response, confirm, requestText, (msg) => chatUI.notify(msg));
|
|
500
|
+
}
|
|
501
|
+
chatUI.render(messages);
|
|
502
|
+
Session.save(messages);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
async function answerWithDownload(messages, provider, url, systemPrompt, confirm, chatUI, requestText = '') {
|
|
506
|
+
chatUI.clearNotices();
|
|
507
|
+
chatUI.setStatus(`Downloading ${url}…`);
|
|
508
|
+
chatUI.render(messages);
|
|
509
|
+
let result;
|
|
510
|
+
try {
|
|
511
|
+
result = await Downloader.download(url, { dir: Config.downloadDir, onProgress: (received, total) => {
|
|
512
|
+
chatUI.setStatus(`Downloading ${url}… ${received}${total ? `/${total}` : ''} items`);
|
|
513
|
+
} });
|
|
514
|
+
} catch (err) {
|
|
515
|
+
chatUI.setStatus('');
|
|
516
|
+
chatUI.notify(err.message || 'Could not download the link.');
|
|
517
|
+
logger.log('ERROR', err.message || 'Could not download the link.');
|
|
518
|
+
chatUI.render(messages);
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
chatUI.setStatus('');
|
|
522
|
+
const kind = result.kind || 'file';
|
|
523
|
+
const location = result.path;
|
|
524
|
+
const note = result.extracted ? '\n(contents extracted into that folder)' : '';
|
|
525
|
+
chatUI.notify(`${kind[0].toUpperCase() + kind.slice(1)} downloaded → ${location}${note}`);
|
|
526
|
+
logger.log('FILE', `downloaded ${kind} ${url} -> ${location}`);
|
|
527
|
+
|
|
528
|
+
const summaryContent = `The user downloaded ${kind} from "${url}". Saved to: ${location}${result.extracted ? `, extracted to ${result.extracted}` : ''} (${result.size ?? 0} bytes).`;
|
|
529
|
+
messages.push({ role: 'user', content: summaryContent, hidden: true });
|
|
530
|
+
Session.save(messages);
|
|
531
|
+
|
|
532
|
+
chatUI.setThinking(true);
|
|
533
|
+
chatUI.render(messages);
|
|
534
|
+
let response = '';
|
|
535
|
+
const frame = new FrameThrottle(() => chatUI.render(messages));
|
|
536
|
+
try {
|
|
537
|
+
for await (const chunk of provider.stream(messages, {
|
|
538
|
+
model: Config.getEffectiveModel(provider.name),
|
|
539
|
+
systemPrompt,
|
|
540
|
+
maxTokens: 300,
|
|
541
|
+
})) {
|
|
542
|
+
response += chunk;
|
|
543
|
+
chatUI.setStreaming(response);
|
|
544
|
+
frame.schedule();
|
|
545
|
+
}
|
|
546
|
+
} catch (err) {
|
|
547
|
+
chatUI.notify(err.message || 'Stream error');
|
|
548
|
+
logger.log('ERROR', err.message || 'Stream error');
|
|
549
|
+
}
|
|
550
|
+
frame.flush();
|
|
551
|
+
if (response) {
|
|
552
|
+
messages.push({ role: 'assistant', content: response });
|
|
553
|
+
logger.log('AI', response);
|
|
554
|
+
chatUI.setStreaming(null);
|
|
555
|
+
await writeFilesFromResponse(response, confirm, requestText, (msg) => chatUI.notify(msg));
|
|
556
|
+
}
|
|
557
|
+
chatUI.render(messages);
|
|
558
|
+
Session.save(messages);
|
|
559
|
+
}
|
|
560
|
+
|
|
45
561
|
async function chat(provider, systemPrompt) {
|
|
46
|
-
|
|
562
|
+
// Fresh start every launch: we never resume silently. The old active session
|
|
563
|
+
// is auto-backup'd (if it has messages) and stays on disk, reachable later
|
|
564
|
+
// via /sessions and /session open.
|
|
565
|
+
Session.autoBackup();
|
|
566
|
+
Session.create(null);
|
|
567
|
+
let messages = Session.loadActive();
|
|
568
|
+
const sessionName = Session.activeName();
|
|
47
569
|
let exited = false;
|
|
570
|
+
const chatUI = new ChatUI({
|
|
571
|
+
model: Config.getEffectiveModel(provider.name),
|
|
572
|
+
engine: provider.displayName,
|
|
573
|
+
});
|
|
574
|
+
logger.log('SESSION', `started provider=${provider.name} model=${Config.getEffectiveModel(provider.name)} session=${sessionName}`);
|
|
575
|
+
|
|
576
|
+
if (messages.length > 0) {
|
|
577
|
+
chatUI.notify(`Fresh session "${sessionName}" (${messages.filter((m) => m.role === 'user').length} previous message(s) carried). Use /sessions to open an old one.`);
|
|
578
|
+
} else {
|
|
579
|
+
chatUI.notify(`Hello! I am VierrataleAI (session "${sessionName}"). Type /help for commands, or just start chatting.`);
|
|
580
|
+
}
|
|
48
581
|
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
582
|
+
const editor = new LineEditor({
|
|
583
|
+
prompt: `${Branding.colors.bold}${Branding.colors.primary}${Branding.USER_PROMPT}${Branding.colors.reset} ${Branding.colors.dim}›${Branding.colors.reset}`,
|
|
584
|
+
commands: SLASH_COMMANDS,
|
|
585
|
+
onExit: () => hadExit(),
|
|
53
586
|
});
|
|
587
|
+
chatUI.setCompositor(editor);
|
|
588
|
+
editor.start();
|
|
54
589
|
|
|
55
|
-
|
|
590
|
+
const hadExit = () => {
|
|
56
591
|
if (!exited) {
|
|
57
592
|
exited = true;
|
|
58
|
-
|
|
593
|
+
editor.exit();
|
|
59
594
|
}
|
|
60
|
-
}
|
|
595
|
+
};
|
|
61
596
|
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
);
|
|
67
|
-
|
|
597
|
+
const readLine = () => editor.readLine();
|
|
598
|
+
|
|
599
|
+
const askConfirm = async (msg) => {
|
|
600
|
+
const ans = await editor.question(msg);
|
|
601
|
+
if (ans === null || ans === undefined) return false;
|
|
602
|
+
const a = String(ans).trim().toLowerCase();
|
|
603
|
+
return a === 'y' || a === 'yes';
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
chatUI.render(messages);
|
|
68
607
|
|
|
69
608
|
while (!exited) {
|
|
70
609
|
let input;
|
|
71
610
|
try {
|
|
72
|
-
input = await
|
|
611
|
+
input = await readLine();
|
|
73
612
|
} catch {
|
|
74
613
|
break;
|
|
75
614
|
}
|
|
@@ -81,94 +620,578 @@ async function chat(provider, systemPrompt) {
|
|
|
81
620
|
|
|
82
621
|
const cmd = trimmed.toLowerCase();
|
|
83
622
|
|
|
623
|
+
if (!cmd.startsWith('/')) logger.log('USER', trimmed);
|
|
624
|
+
|
|
84
625
|
if (cmd === '/quit' || cmd === '/exit' || cmd === '/q') {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
626
|
+
chatUI.setStatus('');
|
|
627
|
+
console.log(' Goodbye!');
|
|
628
|
+
logger.log('SESSION', 'ended');
|
|
629
|
+
hadExit();
|
|
88
630
|
break;
|
|
89
631
|
}
|
|
90
632
|
|
|
91
633
|
if (cmd === '/clear') {
|
|
92
|
-
|
|
93
|
-
|
|
634
|
+
chatUI.clearNotices();
|
|
635
|
+
chatUI.setStatus('');
|
|
636
|
+
chatUI.render(messages);
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
if (cmd === '/log' || cmd.startsWith('/log ')) {
|
|
641
|
+
const rest = cmd === '/log' ? '' : cmd.slice(5).trim();
|
|
642
|
+
chatUI.clearNotices();
|
|
643
|
+
if (rest === 'path' || rest === 'file') {
|
|
644
|
+
chatUI.notify(`Log file: ${logger.getPath()}`);
|
|
645
|
+
} else if (/^\d+$/.test(rest)) {
|
|
646
|
+
const lines = logger.tail(Math.max(1, Math.min(200, parseInt(rest, 10))));
|
|
647
|
+
chatUI.notify(lines.length ? lines.join('\n') : 'No log entries yet.');
|
|
648
|
+
} else {
|
|
649
|
+
const lines = logger.tail(30);
|
|
650
|
+
chatUI.notify(lines.length ? lines.join('\n') : 'No log entries yet.');
|
|
651
|
+
}
|
|
652
|
+
chatUI.render(messages);
|
|
94
653
|
continue;
|
|
95
654
|
}
|
|
96
655
|
|
|
97
656
|
if (cmd === '/help') {
|
|
98
|
-
|
|
657
|
+
chatUI.clearNotices();
|
|
658
|
+
chatUI.notify(
|
|
659
|
+
`Commands:\n /search <query> · /fetch <url> · /model [name] · /provider [name]\n /models · /log [n|path] · /new · /sessions · /session new|open|delete|backup|restore\n /clear · /help · /quit`
|
|
660
|
+
);
|
|
661
|
+
chatUI.render(messages);
|
|
662
|
+
continue;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
if (cmd === '/new') {
|
|
666
|
+
Session.autoBackup();
|
|
667
|
+
const name = Session.create(null);
|
|
668
|
+
messages = Session.loadActive();
|
|
669
|
+
chatUI.clearNotices();
|
|
670
|
+
chatUI.notify(`Started a new conversation (session: "${name}"). All messages cleared — old ones are preserved, use /sessions to return.`);
|
|
671
|
+
chatUI.render(messages);
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
if (cmd === '/sessions') {
|
|
676
|
+
chatUI.clearNotices();
|
|
677
|
+
const list = Session.list();
|
|
678
|
+
if (!list.length) {
|
|
679
|
+
chatUI.notify('No sessions yet.');
|
|
680
|
+
} else {
|
|
681
|
+
list.forEach((s, i) => {
|
|
682
|
+
chatUI.notify(`${i + 1}. ${s.name}${s.active ? ' (current)' : ''} — ${s.count} msg${s.count === 1 ? '' : 's'} — ${s.preview}`);
|
|
683
|
+
});
|
|
684
|
+
chatUI.notify('Open with /session open <name|#> · delete with /session delete <name|#>');
|
|
685
|
+
}
|
|
686
|
+
chatUI.render(messages);
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
if (cmd.startsWith('/session ')) {
|
|
691
|
+
const parts = trimmed.split(/\s+/);
|
|
692
|
+
const sub = parts[1];
|
|
693
|
+
chatUI.clearNotices();
|
|
694
|
+
if (sub === 'new') {
|
|
695
|
+
const name = parts.length > 2 ? trimmed.slice(parts[0].length + parts[1].length + 1).trim() : null;
|
|
696
|
+
const created = Session.create(name || null);
|
|
697
|
+
messages = Session.loadActive();
|
|
698
|
+
chatUI.notify(`Session "${created}" created and active.`);
|
|
699
|
+
chatUI.render(messages);
|
|
700
|
+
} else if (sub === 'open') {
|
|
701
|
+
const target = parts.slice(2).join(' ') || null;
|
|
702
|
+
if (!target) {
|
|
703
|
+
chatUI.notify('Usage: /session open <name|#index> — see /sessions.');
|
|
704
|
+
} else {
|
|
705
|
+
const opened = Session.open(target);
|
|
706
|
+
if (!opened) {
|
|
707
|
+
chatUI.notify(`Session "${target}" not found. Use /sessions to list.`);
|
|
708
|
+
} else {
|
|
709
|
+
messages = Session.loadActive();
|
|
710
|
+
chatUI.notify(`Resumed session "${opened}" (${messages.filter((m) => m.role === 'user').length} previous message(s)).`);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
chatUI.render(messages);
|
|
714
|
+
} else if (sub === 'delete') {
|
|
715
|
+
const target = parts.slice(2).join(' ') || null;
|
|
716
|
+
if (!target) {
|
|
717
|
+
chatUI.notify('Usage: /session delete <name|#> — see /sessions.');
|
|
718
|
+
} else {
|
|
719
|
+
const removed = Session.remove(target);
|
|
720
|
+
if (!removed) {
|
|
721
|
+
chatUI.notify(`Session "${target}" not found. Use /sessions to list.`);
|
|
722
|
+
} else {
|
|
723
|
+
if (Session.activeName() === removed) {
|
|
724
|
+
messages = Session.loadActive();
|
|
725
|
+
chatUI.notify(`Session "${removed}" deleted. Active session is now "${Session.activeName()}".`);
|
|
726
|
+
} else {
|
|
727
|
+
chatUI.notify(`Session "${removed}" deleted.`);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
chatUI.render(messages);
|
|
732
|
+
} else if (sub === 'backup') {
|
|
733
|
+
const target = parts.slice(2).join(' ') || null;
|
|
734
|
+
if (target) {
|
|
735
|
+
const prevActive = Session.activeName();
|
|
736
|
+
const opened = Session.open(target);
|
|
737
|
+
if (!opened) {
|
|
738
|
+
chatUI.notify(`Session "${target}" not found. Use /sessions to list.`);
|
|
739
|
+
} else {
|
|
740
|
+
const saved = Session.autoBackup();
|
|
741
|
+
Session.open(prevActive);
|
|
742
|
+
if (saved) chatUI.notify(`Backed up session "${opened}" → ${saved}. Restore with /session restore.`);
|
|
743
|
+
else chatUI.notify(`Session "${opened}" is empty; nothing to back up.`);
|
|
744
|
+
}
|
|
745
|
+
} else {
|
|
746
|
+
const saved = Session.autoBackup();
|
|
747
|
+
if (saved) chatUI.notify(`Backed up current session → ${saved}. Restore with /session restore.`);
|
|
748
|
+
else chatUI.notify('No messages to back up.');
|
|
749
|
+
}
|
|
750
|
+
chatUI.render(messages);
|
|
751
|
+
} else if (sub === 'backups') {
|
|
752
|
+
const list = Session.listBackups();
|
|
753
|
+
chatUI.clearNotices();
|
|
754
|
+
if (!list.length) {
|
|
755
|
+
chatUI.notify('No session backups found yet. They are created automatically on every fresh start.');
|
|
756
|
+
} else {
|
|
757
|
+
chatUI.notify(`Session backups (${list.length}):`);
|
|
758
|
+
list.slice(0, 20).forEach((b, i) => chatUI.notify(`${i + 1}. ${b.name}`));
|
|
759
|
+
chatUI.notify('Restore one with /session restore <name>');
|
|
760
|
+
}
|
|
761
|
+
chatUI.render(messages);
|
|
762
|
+
} else if (sub === 'restore') {
|
|
763
|
+
const target = parts.slice(2).join(' ') || null;
|
|
764
|
+
if (!target) {
|
|
765
|
+
const list = Session.listBackups();
|
|
766
|
+
if (list.length) {
|
|
767
|
+
chatUI.clearNotices();
|
|
768
|
+
chatUI.notify(`Latest backups (${list.length}):`);
|
|
769
|
+
list.slice(0, 10).forEach((b, i) => chatUI.notify(`${i + 1}. ${b.name}`));
|
|
770
|
+
chatUI.notify('Usage: /session restore <backup-name|#index>');
|
|
771
|
+
} else {
|
|
772
|
+
chatUI.notify('No backups found. Usage: /session restore <backup-name|#>');
|
|
773
|
+
}
|
|
774
|
+
} else {
|
|
775
|
+
let t = target;
|
|
776
|
+
if (/^#\d+$/.test(t)) {
|
|
777
|
+
const list = Session.listBackups();
|
|
778
|
+
const idx = parseInt(t.slice(1), 10);
|
|
779
|
+
const b = list[idx - 1];
|
|
780
|
+
if (!b) {
|
|
781
|
+
chatUI.notify(`Backup #${t.slice(1)} not found.`);
|
|
782
|
+
chatUI.render(messages);
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
785
|
+
t = b.name;
|
|
786
|
+
}
|
|
787
|
+
const restored = Session.restoreBackup(t);
|
|
788
|
+
if (!restored) {
|
|
789
|
+
chatUI.notify(`Backup "${target}" not found. Use /session backups to list.`);
|
|
790
|
+
} else {
|
|
791
|
+
messages = Session.loadActive();
|
|
792
|
+
chatUI.notify(`Restored backup into session "${restored}".`);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
chatUI.render(messages);
|
|
796
|
+
} else {
|
|
797
|
+
chatUI.notify('Usage: /session new [name] · /session open <name|#> · /session delete <name|#> · /session backup [name|#] · /session backups · /session restore <name|#>');
|
|
798
|
+
chatUI.render(messages);
|
|
799
|
+
}
|
|
99
800
|
continue;
|
|
100
801
|
}
|
|
101
802
|
|
|
102
803
|
if (cmd === '/models') {
|
|
103
|
-
const models = provider.
|
|
804
|
+
const models = provider.isLocal
|
|
104
805
|
? Catalog.getLocalModels()
|
|
105
806
|
: Catalog.getCloudModels();
|
|
106
|
-
|
|
807
|
+
const installedSet = new Set();
|
|
808
|
+
if (provider.isLocal) {
|
|
809
|
+
for (const m of await Installer.getInstalledModels(Config.get('engineHost'))) {
|
|
810
|
+
installedSet.add(m);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
chatUI.clearNotices();
|
|
107
814
|
for (const m of models) {
|
|
108
815
|
const info = Catalog.getModelInfo(m);
|
|
109
|
-
const current = m === Config.
|
|
110
|
-
|
|
816
|
+
const current = m === Config.getEffectiveModel(provider.name) ? ' (active)' : '';
|
|
817
|
+
if (provider.isLocal) {
|
|
818
|
+
const have = installedSet.has(info.realModel);
|
|
819
|
+
chatUI.notify(`${m}${have ? ' ✓' : ' (not downloaded)'}${current} - ${info.tier}`);
|
|
820
|
+
} else {
|
|
821
|
+
chatUI.notify(`${m}${current} - ${info.tier}`);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
if (provider.isLocal) {
|
|
825
|
+
const extras = [...installedSet].filter(
|
|
826
|
+
(m) => !Catalog.getModelInfo(m) && m !== Config.getEffectiveModel(provider.name)
|
|
827
|
+
);
|
|
828
|
+
for (const m of extras) {
|
|
829
|
+
chatUI.notify(` ${m} ✓ (raw engine model)`);
|
|
830
|
+
}
|
|
111
831
|
}
|
|
832
|
+
chatUI.render(messages);
|
|
112
833
|
continue;
|
|
113
834
|
}
|
|
114
835
|
|
|
115
836
|
if (cmd.startsWith('/model ')) {
|
|
116
837
|
const modelName = cmd.split(' ')[1];
|
|
117
838
|
if (modelName) {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
839
|
+
const canonical = Catalog.normalize(modelName);
|
|
840
|
+
const current = Config.getEffectiveModel(provider.name);
|
|
841
|
+
const info = Catalog.getModelInfo(canonical);
|
|
842
|
+
const applyModel = (model) => {
|
|
843
|
+
Config.save({ model });
|
|
844
|
+
Config.setProviderModel(provider.name, model);
|
|
845
|
+
chatUI.setModel(model, provider.displayName);
|
|
846
|
+
};
|
|
847
|
+
if (provider.isLocal && Catalog.isCloudModel(canonical)) {
|
|
848
|
+
chatUI.notify(`Cannot use cloud model "${canonical}" on ${provider.displayName} (local). Use /model VRTL-6.pro or /provider openai.`);
|
|
849
|
+
} else if (!info) {
|
|
850
|
+
if (provider.isLocal) {
|
|
851
|
+
const switched = await ensureRawLocalModel(modelName, (m) => chatUI.notify(m));
|
|
852
|
+
if (switched.ok) {
|
|
853
|
+
applyModel(switched.model);
|
|
854
|
+
chatUI.notify(`Model: ${switched.model}${switched.reason ? ` — ${switched.reason}` : ''}`);
|
|
855
|
+
} else {
|
|
856
|
+
chatUI.notify(`Model "${modelName}" is not available. Current: ${current}`);
|
|
857
|
+
}
|
|
858
|
+
} else {
|
|
859
|
+
chatUI.notify(`Unknown model: ${modelName}`);
|
|
860
|
+
}
|
|
861
|
+
} else if (info.isLocal) {
|
|
862
|
+
const switched = await ensureLocalModel(canonical, (m) => chatUI.notify(m));
|
|
863
|
+
if (switched.ok) {
|
|
864
|
+
applyModel(switched.model);
|
|
865
|
+
chatUI.notify(`Model: ${switched.model}${switched.reason ? ` — ${switched.reason}` : ''}`);
|
|
866
|
+
} else {
|
|
867
|
+
chatUI.notify(`Could not switch to "${canonical}": ${switched.reason}. Current: ${current}`);
|
|
868
|
+
}
|
|
121
869
|
} else {
|
|
122
|
-
|
|
870
|
+
applyModel(canonical);
|
|
871
|
+
chatUI.notify(`Model: ${canonical}`);
|
|
123
872
|
}
|
|
124
873
|
}
|
|
874
|
+
chatUI.render(messages);
|
|
125
875
|
continue;
|
|
126
876
|
}
|
|
127
877
|
|
|
128
878
|
if (cmd === '/model') {
|
|
129
|
-
|
|
879
|
+
chatUI.clearNotices();
|
|
880
|
+
chatUI.notify(`Current model: ${Config.getEffectiveModel(provider.name)}`);
|
|
881
|
+
chatUI.render(messages);
|
|
130
882
|
continue;
|
|
131
883
|
}
|
|
132
884
|
|
|
133
885
|
if (cmd.startsWith('/provider ')) {
|
|
134
886
|
const prov = cmd.split(' ')[1];
|
|
135
|
-
if (['
|
|
887
|
+
if (['cortex', 'openai', 'anthropic', 'gemini'].includes(prov)) {
|
|
136
888
|
Config.save({ provider: prov });
|
|
137
|
-
|
|
889
|
+
const next = ProviderFactory.create(prov);
|
|
890
|
+
if (next && await next.isAvailable()) {
|
|
891
|
+
provider = next;
|
|
892
|
+
chatUI.notify(`Provider: ${prov}`);
|
|
893
|
+
} else {
|
|
894
|
+
chatUI.notify(`Provider saved, but ${prov} is not available right now. Will apply on restart.`);
|
|
895
|
+
}
|
|
896
|
+
if (provider.isLocal && Catalog.isCloudModel(Config.getEffectiveModel(provider.name))) {
|
|
897
|
+
const fallback = Catalog.getDefaultModel();
|
|
898
|
+
Config.save({ model: fallback });
|
|
899
|
+
Config.setProviderModel(provider.name, fallback);
|
|
900
|
+
chatUI.setModel(fallback, provider.displayName);
|
|
901
|
+
chatUI.notify(`Cloud model can't run on ${provider.displayName}; switched to ${fallback}.`);
|
|
902
|
+
}
|
|
138
903
|
} else {
|
|
139
|
-
|
|
904
|
+
chatUI.notify(`Unknown provider: ${prov}`);
|
|
140
905
|
}
|
|
906
|
+
chatUI.render(messages);
|
|
141
907
|
continue;
|
|
142
908
|
}
|
|
143
909
|
|
|
144
910
|
if (cmd === '/provider') {
|
|
145
|
-
|
|
911
|
+
chatUI.clearNotices();
|
|
912
|
+
chatUI.notify(`Current provider: ${provider.name}`);
|
|
913
|
+
chatUI.render(messages);
|
|
914
|
+
continue;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
if (cmd.startsWith('/system')) {
|
|
918
|
+
const prompt = trimmed.slice(7).trim();
|
|
919
|
+
chatUI.clearNotices();
|
|
920
|
+
if (prompt.toLowerCase() === 'reset') {
|
|
921
|
+
Config.save({ systemPrompt: '' });
|
|
922
|
+
systemPrompt = loadSystemPrompt();
|
|
923
|
+
chatUI.notify('System prompt reset to default.');
|
|
924
|
+
} else if (prompt) {
|
|
925
|
+
Config.save({ systemPrompt: prompt });
|
|
926
|
+
systemPrompt = prompt;
|
|
927
|
+
chatUI.notify('System prompt updated.');
|
|
928
|
+
} else {
|
|
929
|
+
chatUI.notify(`Current system prompt: ${systemPrompt}`);
|
|
930
|
+
}
|
|
931
|
+
chatUI.render(messages);
|
|
146
932
|
continue;
|
|
147
933
|
}
|
|
148
934
|
|
|
935
|
+
if (cmd.startsWith('/search ')) {
|
|
936
|
+
const query = trimmed.slice(8).trim();
|
|
937
|
+
if (!query) {
|
|
938
|
+
chatUI.notify('Usage: /search <query>');
|
|
939
|
+
chatUI.render(messages);
|
|
940
|
+
continue;
|
|
941
|
+
}
|
|
942
|
+
chatUI.clearNotices();
|
|
943
|
+
chatUI.setStatus('');
|
|
944
|
+
messages.push({ role: 'user', content: trimmed });
|
|
945
|
+
logger.log('USER', trimmed);
|
|
946
|
+
await answerWithSearch(messages, provider, query, systemPrompt, askConfirm, trimmed, chatUI);
|
|
947
|
+
continue;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
if (cmd.startsWith('/fetch ') || cmd === '/fetch') {
|
|
951
|
+
const urlPart = trimmed.slice(6).trim().split(/\s+/)[0];
|
|
952
|
+
if (!urlPart) {
|
|
953
|
+
chatUI.notify('Usage: /fetch <url>');
|
|
954
|
+
chatUI.render(messages);
|
|
955
|
+
continue;
|
|
956
|
+
}
|
|
957
|
+
chatUI.clearNotices();
|
|
958
|
+
chatUI.setStatus('');
|
|
959
|
+
messages.push({ role: 'user', content: trimmed });
|
|
960
|
+
logger.log('USER', trimmed);
|
|
961
|
+
await answerWithFetch(messages, provider, urlPart, systemPrompt, askConfirm, chatUI, trimmed);
|
|
962
|
+
continue;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
if (cmd.startsWith('/download ') || cmd === '/download') {
|
|
966
|
+
const urlPart = trimmed.slice(10).trim().split(/\s+/)[0];
|
|
967
|
+
if (!urlPart) {
|
|
968
|
+
chatUI.notify('Usage: /download <url>');
|
|
969
|
+
chatUI.render(messages);
|
|
970
|
+
continue;
|
|
971
|
+
}
|
|
972
|
+
chatUI.clearNotices();
|
|
973
|
+
chatUI.setStatus('');
|
|
974
|
+
messages.push({ role: 'user', content: trimmed });
|
|
975
|
+
logger.log('USER', trimmed);
|
|
976
|
+
await answerWithDownload(messages, provider, urlPart, systemPrompt, askConfirm, chatUI, trimmed);
|
|
977
|
+
continue;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
const intent = detectIntent(trimmed);
|
|
981
|
+
if (intent.type === 'self') {
|
|
982
|
+
const model = Config.getEffectiveModel(provider.name);
|
|
983
|
+
const info = Catalog.getModelInfo(model);
|
|
984
|
+
chatUI.clearNotices();
|
|
985
|
+
chatUI.notify(
|
|
986
|
+
`I'm running on ${model} (${info ? info.realModel : model}) via the ${provider.displayName} engine.`
|
|
987
|
+
);
|
|
988
|
+
chatUI.render(messages);
|
|
989
|
+
continue;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
if (intent.type === 'greeting') {
|
|
993
|
+
messages.push({ role: 'user', content: trimmed });
|
|
994
|
+
const reply = 'Hello! How can I help you today?';
|
|
995
|
+
messages.push({ role: 'assistant', content: reply });
|
|
996
|
+
logger.log('AI', reply);
|
|
997
|
+
Session.save(messages);
|
|
998
|
+
chatUI.clearNotices();
|
|
999
|
+
chatUI.render(messages);
|
|
1000
|
+
continue;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
// Deterministic math: answer arithmetic ourselves (the tiny engine often
|
|
1004
|
+
// hallucinates), so we never send a pure math ask to the model.
|
|
1005
|
+
if (!looksLikeOperationRequest(trimmed)) {
|
|
1006
|
+
const mathAnswer = trySolveMath(trimmed);
|
|
1007
|
+
if (mathAnswer) {
|
|
1008
|
+
messages.push({ role: 'user', content: trimmed });
|
|
1009
|
+
messages.push({ role: 'assistant', content: mathAnswer });
|
|
1010
|
+
logger.log('AI', mathAnswer);
|
|
1011
|
+
Session.save(messages);
|
|
1012
|
+
chatUI.clearNotices();
|
|
1013
|
+
chatUI.render(messages);
|
|
1014
|
+
continue;
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
if (intent.type === 'knowledge') {
|
|
1019
|
+
const topic = extractSearchTopic(trimmed) || trimmed;
|
|
1020
|
+
chatUI.setStatus('');
|
|
1021
|
+
messages.push({ role: 'user', content: trimmed });
|
|
1022
|
+
await answerWithSearch(messages, provider, topic, systemPrompt, askConfirm, trimmed, chatUI);
|
|
1023
|
+
continue;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// Operation requests: plan and run sandboxed tools (create folders/files, etc.).
|
|
1027
|
+
if (looksLikeOperationRequest(trimmed)) {
|
|
1028
|
+
chatUI.clearNotices();
|
|
1029
|
+
const cwd = process.cwd();
|
|
1030
|
+
const timeoutMs = Config.get('commandTimeoutMs');
|
|
1031
|
+
const done = await answerWithTools(messages, provider, trimmed, systemPrompt, askConfirm, chatUI, cwd, timeoutMs);
|
|
1032
|
+
if (done) continue;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// Refine the last built website (colours, theme, title, sections...).
|
|
1036
|
+
// runWithTools() decides whether it really is a refine request; otherwise
|
|
1037
|
+
// answerWithTools() returns false and normal chat below takes over.
|
|
1038
|
+
if (
|
|
1039
|
+
/[\s\S]*\b(make|switch|change|turn|set|use|add|remove|delete|drop|recolor|restyle|redesign|update|apply|repaint|adjust|rename)\b[\s\S]*\b(it|site|website|page|theme|mode|color|colour|title|name|section|accent|background|style|design)\b[\s\S]*/i.test(trimmed)
|
|
1040
|
+
) {
|
|
1041
|
+
chatUI.clearNotices();
|
|
1042
|
+
const done = await answerWithTools(messages, provider, trimmed, systemPrompt, askConfirm, chatUI, process.cwd(), Config.get('commandTimeoutMs'));
|
|
1043
|
+
if (done) continue;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// If the message contains a bare URL, open it automatically and ask the AI to summarize.
|
|
1047
|
+
if (/https?:\/\/\S+/i.test(trimmed)) {
|
|
1048
|
+
const url = extractUrlFromText(trimmed);
|
|
1049
|
+
if (url) {
|
|
1050
|
+
chatUI.clearNotices();
|
|
1051
|
+
chatUI.setStatus('');
|
|
1052
|
+
messages.push({ role: 'user', content: trimmed });
|
|
1053
|
+
await answerWithFetch(messages, provider, url, systemPrompt, askConfirm, chatUI, trimmed);
|
|
1054
|
+
continue;
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
|
|
149
1058
|
messages.push({ role: 'user', content: trimmed });
|
|
150
|
-
|
|
1059
|
+
if (looksLikeFileRequest(trimmed)) {
|
|
1060
|
+
messages.push({ role: 'user', content: FILE_BLOCK_PROMPT, hidden: true });
|
|
1061
|
+
}
|
|
1062
|
+
Session.save(messages);
|
|
151
1063
|
|
|
1064
|
+
chatUI.setThinking(true);
|
|
1065
|
+
chatUI.render(messages);
|
|
152
1066
|
let response = '';
|
|
1067
|
+
const frame = new FrameThrottle(() => chatUI.render(messages));
|
|
153
1068
|
try {
|
|
154
1069
|
for await (const chunk of provider.stream(messages, {
|
|
155
|
-
model: Config.
|
|
1070
|
+
model: Config.getEffectiveModel(provider.name),
|
|
156
1071
|
systemPrompt,
|
|
1072
|
+
maxTokens: 600,
|
|
157
1073
|
})) {
|
|
158
|
-
Terminal.printAIChunk(chunk);
|
|
159
1074
|
response += chunk;
|
|
1075
|
+
chatUI.setStreaming(response);
|
|
1076
|
+
frame.schedule();
|
|
160
1077
|
}
|
|
161
1078
|
} catch (err) {
|
|
162
|
-
|
|
1079
|
+
chatUI.notify(err.message || 'Stream error');
|
|
1080
|
+
logger.log('ERROR', err.message || 'Stream error');
|
|
163
1081
|
}
|
|
164
|
-
|
|
165
|
-
Terminal.printAIEnd();
|
|
1082
|
+
frame.flush();
|
|
166
1083
|
if (response) {
|
|
167
1084
|
messages.push({ role: 'assistant', content: response });
|
|
1085
|
+
logger.log('AI', response);
|
|
1086
|
+
chatUI.setStreaming(null);
|
|
1087
|
+
await writeFilesFromResponse(response, askConfirm, trimmed, (msg) => chatUI.notify(msg));
|
|
168
1088
|
}
|
|
1089
|
+
chatUI.render(messages);
|
|
1090
|
+
Session.save(messages);
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
async function ensureLocalModel(modelName, notify) {
|
|
1095
|
+
const info = Catalog.getModelInfo(modelName);
|
|
1096
|
+
if (!info) return ensureRawLocalModel(modelName, notify);
|
|
1097
|
+
if (!info.isLocal) return { ok: false, model: null, reason: `${modelName} is not a local model` };
|
|
1098
|
+
return ensureRawLocalModel(info.realModel, notify);
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// Ensure a local engine model is usable, returning the actual model that
|
|
1102
|
+
// should be used. Preference: exact match, then an installed same-family
|
|
1103
|
+
// sibling, then a fresh download, then any installed model. Switching never
|
|
1104
|
+
// dead-ends while at least one local model exists.
|
|
1105
|
+
async function ensureRawLocalModel(realModel, notify) {
|
|
1106
|
+
const host = Config.get('engineHost');
|
|
1107
|
+
const resolved = await Installer.resolveModel(host, realModel);
|
|
1108
|
+
if (resolved.model) {
|
|
1109
|
+
return { ok: true, model: resolved.model, reason: resolved.reason };
|
|
1110
|
+
}
|
|
1111
|
+
if (notify) notify(`Downloading ${realModel}… (this can take a while)`);
|
|
1112
|
+
const pulled = await Installer.pullModel(host, realModel);
|
|
1113
|
+
if (pulled) {
|
|
1114
|
+
if (notify) notify(`Model ${realModel} ready.`);
|
|
1115
|
+
return { ok: true, model: realModel, reason: null };
|
|
169
1116
|
}
|
|
1117
|
+
if (notify) notify(`Could not download ${realModel}.`);
|
|
1118
|
+
const first = await Installer.firstInstalled(host);
|
|
1119
|
+
if (first) {
|
|
1120
|
+
return { ok: true, model: first, reason: `Could not download ${realModel}; using installed ${first}` };
|
|
1121
|
+
}
|
|
1122
|
+
return { ok: false, model: null, reason: `Could not download model: ${realModel}` };
|
|
170
1123
|
}
|
|
171
1124
|
|
|
1125
|
+
export { looksLikeFileRequest, requestedFileName, extractFallbackFile, storyFileName, responseWantsFile, responseIsSoloCode, languageFileName, fallbackHasUsefulCode, validateWrittenCode, looksLikeJunkCode, warnIfJunkCode, writeFilesFromResponse };
|
|
1126
|
+
// Command/tool layer: asks the model to plan filesystem/shell operations,
|
|
1127
|
+
// executes them with the sandboxed executor, then streams the final answer.
|
|
1128
|
+
async function answerWithTools(messages, provider, requestText, systemPrompt, confirm, chatUI, cwd, timeoutMs) {
|
|
1129
|
+
let results = [];
|
|
1130
|
+
try {
|
|
1131
|
+
const out = await runWithTools({
|
|
1132
|
+
provider,
|
|
1133
|
+
messages,
|
|
1134
|
+
requestText,
|
|
1135
|
+
systemPrompt,
|
|
1136
|
+
chatUI,
|
|
1137
|
+
cwd,
|
|
1138
|
+
timeoutMs,
|
|
1139
|
+
});
|
|
1140
|
+
results = out.results;
|
|
1141
|
+
} catch (err) {
|
|
1142
|
+
chatUI.notify(err.message || 'Tool error');
|
|
1143
|
+
logger.log('ERROR', err.message || 'Tool error');
|
|
1144
|
+
chatUI.render(messages);
|
|
1145
|
+
return true;
|
|
1146
|
+
}
|
|
1147
|
+
if (!results.length) return false;
|
|
1148
|
+
|
|
1149
|
+
messages.push({ role: 'user', content: requestText });
|
|
1150
|
+
const resultRows = results.map((r) => {
|
|
1151
|
+
const res = r.result;
|
|
1152
|
+
return {
|
|
1153
|
+
tool: r.name,
|
|
1154
|
+
params: r.name === 'run_command' ? { command: res.command } : { path: r.params.path },
|
|
1155
|
+
success: res.success,
|
|
1156
|
+
stdout: res.stdout,
|
|
1157
|
+
stderr: res.stderr,
|
|
1158
|
+
exitCode: res.exitCode,
|
|
1159
|
+
};
|
|
1160
|
+
});
|
|
1161
|
+
messages.push({ role: 'user', content: TOOL_RESULTS_PROMPT(JSON.stringify(resultRows, null, 2)), hidden: true });
|
|
1162
|
+
Session.save(messages);
|
|
1163
|
+
|
|
1164
|
+
chatUI.setThinking(true);
|
|
1165
|
+
chatUI.render(messages);
|
|
1166
|
+
let response = '';
|
|
1167
|
+
const frame = new FrameThrottle(() => chatUI.render(messages));
|
|
1168
|
+
try {
|
|
1169
|
+
for await (const chunk of provider.stream(messages, {
|
|
1170
|
+
model: Config.getEffectiveModel(provider.name),
|
|
1171
|
+
systemPrompt,
|
|
1172
|
+
maxTokens: 220,
|
|
1173
|
+
})) {
|
|
1174
|
+
response += chunk;
|
|
1175
|
+
chatUI.setStreaming(response);
|
|
1176
|
+
frame.schedule();
|
|
1177
|
+
}
|
|
1178
|
+
} catch (err) {
|
|
1179
|
+
chatUI.notify(err.message || 'Stream error');
|
|
1180
|
+
logger.log('ERROR', err.message || 'Stream error');
|
|
1181
|
+
}
|
|
1182
|
+
frame.flush();
|
|
1183
|
+
if (response) {
|
|
1184
|
+
messages.push({ role: 'assistant', content: response });
|
|
1185
|
+
logger.log('AI', response);
|
|
1186
|
+
chatUI.setStreaming(null);
|
|
1187
|
+
if (/```/.test(response) || /FILE:/i.test(response)) {
|
|
1188
|
+
await writeFilesFromResponse(response, confirm, requestText, (msg) => chatUI.notify(msg));
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
chatUI.render(messages);
|
|
1192
|
+
Session.save(messages);
|
|
1193
|
+
return true;
|
|
1194
|
+
}
|
|
172
1195
|
export async function run() {
|
|
173
1196
|
const args = parseArgs(process.argv);
|
|
174
1197
|
|
|
@@ -184,21 +1207,45 @@ export async function run() {
|
|
|
184
1207
|
|
|
185
1208
|
Config.load();
|
|
186
1209
|
if (args.provider) Config.save({ provider: args.provider });
|
|
187
|
-
if (args.model) Config.save({ model: args.model });
|
|
1210
|
+
if (args.model) Config.save({ model: Catalog.normalize(args.model) });
|
|
1211
|
+
|
|
1212
|
+
await Terminal.showProgress(
|
|
1213
|
+
['Checking engine', 'Installing dependencies', 'Downloading model', 'Optimizing'],
|
|
1214
|
+
() => Installer.ensure()
|
|
1215
|
+
);
|
|
188
1216
|
|
|
189
|
-
|
|
190
|
-
await Installer.ensure();
|
|
1217
|
+
await ensureLocalModel(Config.get('model'));
|
|
191
1218
|
|
|
192
1219
|
const provider = await ProviderFactory.autoDetect();
|
|
193
1220
|
if (!provider) {
|
|
1221
|
+
logger.init();
|
|
1222
|
+
logger.log('ERROR', 'no provider available');
|
|
194
1223
|
Terminal.printError('No AI engine available. Please install or configure a provider.');
|
|
195
1224
|
process.exit(1);
|
|
196
1225
|
}
|
|
197
1226
|
|
|
1227
|
+
const chosenModel = Config.getEffectiveModel(provider.name);
|
|
1228
|
+
if (provider.isLocal && Catalog.isCloudModel(chosenModel)) {
|
|
1229
|
+
const fallback = Catalog.getDefaultModel();
|
|
1230
|
+
Config.save({ model: fallback });
|
|
1231
|
+
Config.setProviderModel(provider.name, fallback);
|
|
1232
|
+
await ensureLocalModel(fallback);
|
|
1233
|
+
logger.init();
|
|
1234
|
+
logger.log('WARN', `cloud model "${chosenModel}" cannot run on local provider ${provider.name}; switched to ${fallback}`);
|
|
1235
|
+
Terminal.printInfo(`"${chosenModel}" is a cloud model and can't run on ${provider.displayName}. Switched to ${fallback}.`);
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
logger.init();
|
|
1239
|
+
logger.log('APP', `started provider=${provider.name} model=${Config.getEffectiveModel(provider.name)}`);
|
|
1240
|
+
|
|
198
1241
|
Config.save({ provider: provider.name });
|
|
199
1242
|
|
|
200
|
-
if (
|
|
201
|
-
|
|
1243
|
+
if (provider.warmup) provider.warmup();
|
|
1244
|
+
|
|
1245
|
+
// Always clear the screen at startup: everything above the CLI is removed so
|
|
1246
|
+
// the banner + conversation start on a clean slate. The banner itself is
|
|
1247
|
+
// drawn inside ChatUI.render(), so it can't be erased by the first render.
|
|
1248
|
+
Terminal.clear();
|
|
202
1249
|
|
|
203
1250
|
const systemPrompt = loadSystemPrompt();
|
|
204
1251
|
await chat(provider, systemPrompt);
|