@vierratale/ai 0.1.0-beta.7 → 0.1.0-beta.9

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