@vierratale/ai 0.1.0-beta.14 → 0.1.0-beta.16

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