@vierratale/ai 0.1.0-beta.1 → 0.1.0-beta.10

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/src/cli.js CHANGED
@@ -1,14 +1,26 @@
1
- import { createInterface } from 'readline';
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, showBanner } from './ui/banner.js';
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: ollama, openai (default: auto-detect)
30
- --model, -m <name> Model: vierratale-lite/fast/balanced/pro/cloud
31
- --clear Clear screen on start
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
- const messages = [];
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 rl = createInterface({
50
- input: process.stdin,
51
- output: process.stdout,
52
- terminal: false,
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
- rl.on('close', () => {
590
+ const hadExit = () => {
56
591
  if (!exited) {
57
592
  exited = true;
58
- process.exit(0);
593
+ editor.exit();
59
594
  }
60
- });
595
+ };
61
596
 
62
- const prompt = () => new Promise((resolve) => {
63
- rl.question(
64
- `${Branding.colors.bold}${Branding.colors.primary}${Branding.USER_PROMPT} >${Branding.colors.reset} `,
65
- resolve
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 prompt();
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
- Terminal.printInfo('Goodbye!');
86
- exited = true;
87
- rl.close();
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
- Terminal.clear();
93
- showBanner(provider.name, Config.get('model'));
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
- showHelp();
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.name === 'ollama'
804
+ const models = provider.isLocal
104
805
  ? Catalog.getLocalModels()
105
806
  : Catalog.getCloudModels();
106
- Terminal.printInfo('Available models:');
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.get('model') ? ' (active)' : '';
110
- console.log(` ${Branding.colors.accent}${m}${Branding.colors.dim}${current} - ${info.tier}${Branding.colors.reset}`);
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
- if (Catalog.getModelInfo(modelName)) {
119
- Config.save({ model: modelName });
120
- Terminal.printSuccess(`Model: ${modelName}`);
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
- Terminal.printError(`Unknown model: ${modelName}`);
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
- Terminal.printInfo(`Current model: ${Config.get('model')}`);
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 (['ollama', 'openai'].includes(prov)) {
887
+ if (['cortex', 'openai', 'anthropic', 'gemini'].includes(prov)) {
136
888
  Config.save({ provider: prov });
137
- Terminal.printSuccess(`Provider: ${prov}`);
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
- Terminal.printError(`Unknown provider: ${prov}`);
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
- Terminal.printInfo(`Current provider: ${provider.name}`);
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
- Terminal.printAIStart();
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.get('model'),
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
- Terminal.printError(err.message || 'Stream error');
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
- Terminal.printInfo('Initializing...');
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 (args.clear) Terminal.clear();
201
- showBanner(provider.name, Config.get('model'));
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);