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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,27 +12,43 @@ npm install -g @vierratale/ai
12
12
  npx @vierratale/ai
13
13
  ```
14
14
 
15
+ The first launch detects and, if needed, installs and starts the local engine
16
+ (**Cortex** - "secretly" Ollama under the hood), then pulls the model you use.
17
+
15
18
  ## Usage
16
19
 
17
20
  ```bash
18
21
  vierrataleai # Start chat (auto-detect provider)
19
22
  vierrataleai --provider cortex # Use local models
20
- vierrataleai --provider openai # Use cloud models
23
+ vierrataleai --provider openai # Use cloud models (OpenAI)
24
+ vierrataleai --provider anthropic # Use cloud models (Claude)
25
+ vierrataleai --provider gemini # Use cloud models (Gemini)
21
26
  vierrataleai --model vierratale-pro # Use specific model
22
27
  ```
23
28
 
24
29
  ## Models
25
30
 
26
- | Model | Description |
27
- |-------|-------------|
28
- | vierratale-lite | Minimal, fast responses |
29
- | vierratale-fast | Fast, balanced quality |
30
- | vierratale-small | Small, efficient |
31
- | vierratale-balanced | Balanced performance |
32
- | vierratale-plus | Enhanced capability |
33
- | vierratale-pro | Professional grade |
34
- | vierratale-cloud | Cloud-powered |
35
- | vierratale-cloud-pro | Cloud professional |
31
+ Local models run through the **Cortex** engine:
32
+
33
+ | Model | Backend |
34
+ |-------|---------|
35
+ | vierratale-lite | qwen2.5-coder:0.5b |
36
+ | vierratale-fast | qwen2.5-coder:1.5b |
37
+ | vierratale-small | qwen2.5-coder:3b |
38
+ | vierratale-balanced | qwen2.5-coder:7b |
39
+ | vierratale-plus | qwen2.5-coder:14b |
40
+ | vierratale-pro | qwen2.5-coder:32b |
41
+
42
+ Cloud models are available when the matching provider + API key is configured:
43
+
44
+ | Model | Backend |
45
+ |-------|---------|
46
+ | vierratale-cloud-mini | gpt-4o-mini (OpenAI) |
47
+ | vierratale-cloud | gpt-4o (OpenAI) |
48
+ | vierratale-cloud-fast | claude-3-5-haiku-20241022 (Anthropic) |
49
+ | vierratale-cloud-pro | claude-sonnet-4-20250514 (Anthropic) |
50
+ | vierratale-cloud-lite | gemini-2.0-flash (Gemini) |
51
+ | vierratale-cloud-plus | gemini-1.5-pro (Gemini) |
36
52
 
37
53
  ## Commands
38
54
 
@@ -40,6 +56,7 @@ vierrataleai --model vierratale-pro # Use specific model
40
56
  - `/model [name]` - Switch model
41
57
  - `/provider [name]` - Switch provider
42
58
  - `/models` - List available models
59
+ - `/system [text]` - Set a custom system prompt / persona
43
60
  - `/search <query>` - Search the web and summarize with AI
44
61
  - `/clear` - Clear screen
45
62
  - `/new` - Start a new conversation (clear memory)
@@ -67,9 +84,11 @@ you can continue where you left off. Use `/new` to start fresh.
67
84
  Config file: `~/.config/vierrataleai/config.json`
68
85
 
69
86
  Environment variables:
70
- - `VIERRATALE_PROVIDER` - Default provider
87
+ - `VIERRATALE_PROVIDER` - Default provider (auto/cortex/openai/anthropic/gemini)
71
88
  - `VIERRATALE_MODEL` - Default model
72
- - `OPENAI_API_KEY` - OpenAI API key (for cloud models)
89
+ - `OPENAI_API_KEY` - OpenAI API key (openai provider)
90
+ - `ANTHROPIC_API_KEY` - Anthropic API key (anthropic provider)
91
+ - `GEMINI_API_KEY` - Google AI API key (gemini provider)
73
92
 
74
93
  ## License
75
94
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vierratale/ai",
3
- "version": "0.1.0-beta.5",
3
+ "version": "0.1.0-beta.7",
4
4
  "description": "VierrataleAI - Intelligent terminal assistant",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,7 +11,9 @@
11
11
  ".": "./src/index.js"
12
12
  },
13
13
  "scripts": {
14
- "start": "node bin/vierrataleai.js"
14
+ "start": "node bin/vierrataleai.js",
15
+ "test": "node --test test/",
16
+ "postinstall": "node scripts/postinstall.js"
15
17
  },
16
18
  "keywords": ["ai", "chatbot", "terminal", "assistant", "llm"],
17
19
  "author": "Vierratale",
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+ // Optional engine setup after install. Never fails the package install.
3
+ import { execSync } from 'child_process';
4
+ import { existsSync } from 'fs';
5
+
6
+ const isInstalled = () => {
7
+ try {
8
+ execSync('which ollama 2>/dev/null', { stdio: 'ignore' });
9
+ return true;
10
+ } catch {}
11
+ return existsSync('/usr/local/bin/ollama') || existsSync('/usr/bin/ollama');
12
+ };
13
+
14
+ try {
15
+ if (isInstalled()) {
16
+ process.exit(0);
17
+ }
18
+
19
+ const nonInteractive =
20
+ !process.stdout.isTTY || process.env.CI || process.env.NODE_ENV === 'test';
21
+
22
+ // Only auto-install when explicitly requested. Otherwise just inform the user
23
+ // that the engine will be installed on first launch.
24
+ if (process.env.VIERRATALE_INSTALL_ENGINE === '1' && !nonInteractive) {
25
+ execSync('curl -fsSL https://ollama.com/install.sh | sh', { stdio: 'inherit' });
26
+ } else {
27
+ console.log(
28
+ '\n[vierrataleai] The local engine (ollama) was not detected.\n' +
29
+ ' - Run the CLI: it will attempt to install and start the engine automatically.\n' +
30
+ ' - Or install it yourself: curl -fsSL https://ollama.com/install.sh | sh\n'
31
+ );
32
+ }
33
+ } catch {
34
+ // Never fail the install because of engine setup.
35
+ }
package/src/catalog.js CHANGED
@@ -60,6 +60,26 @@ export const Catalog = {
60
60
  return 'vierratale-fast';
61
61
  },
62
62
 
63
+ getDefaultCloudModel() {
64
+ return 'vierratale-cloud-mini';
65
+ },
66
+
67
+ isLocalModel(displayName) {
68
+ return LOCAL_MODELS.includes(displayName);
69
+ },
70
+
71
+ isCloudModel(displayName) {
72
+ return CLOUD_MODELS.includes(displayName);
73
+ },
74
+
75
+ getVendorForCloudModel(displayName) {
76
+ const real = REVERSE[displayName];
77
+ if (real && real.startsWith('claude-')) return 'anthropic';
78
+ if (real && real.startsWith('gemini-')) return 'gemini';
79
+ if (real && real.startsWith('gpt-')) return 'openai';
80
+ return 'openai';
81
+ },
82
+
63
83
  getModelInfo(displayName) {
64
84
  const real = REVERSE[displayName];
65
85
  if (!real) return null;
package/src/cli.js CHANGED
@@ -7,6 +7,7 @@ import { Branding } from './ui/branding.js';
7
7
  import { Terminal } from './ui/terminal.js';
8
8
  import { loadSystemPrompt, showBanner } from './ui/banner.js';
9
9
  import { WebSearch } from './utils/websearch.js';
10
+ import { WebFetch } from './utils/webfetch.js';
10
11
  import { detectIntent } from './utils/intents.js';
11
12
  import { Session } from './session.js';
12
13
 
@@ -41,6 +42,7 @@ Commands (in chat):
41
42
  /provider [name] Switch provider
42
43
  /models List available models
43
44
  /search <query> Search the web and summarize with AI
45
+ /fetch <url> Open a link and summarize its content
44
46
  /clear Clear screen
45
47
  /new Start a new conversation (clear memory)
46
48
  /quit Exit
@@ -72,7 +74,55 @@ async function answerWithSearch(messages, provider, query, systemPrompt) {
72
74
  let response = '';
73
75
  try {
74
76
  for await (const chunk of provider.stream(messages, {
75
- model: Config.get('model'),
77
+ model: Config.getEffectiveModel(provider.name),
78
+ systemPrompt,
79
+ })) {
80
+ Terminal.printAIChunk(chunk);
81
+ response += chunk;
82
+ }
83
+ } catch (err) {
84
+ Terminal.printError(err.message || 'Stream error');
85
+ }
86
+ Terminal.printAIEnd();
87
+ if (response) {
88
+ messages.push({ role: 'assistant', content: response });
89
+ }
90
+ Session.save(messages);
91
+ }
92
+
93
+ function extractUrlFromText(text) {
94
+ const m = text.match(/https?:\/\/[^\s<>"']+/i);
95
+ return m ? m[0] : null;
96
+ }
97
+
98
+ async function answerWithFetch(messages, provider, url, systemPrompt) {
99
+ Terminal.printInfo(`Opening ${url}...`);
100
+ let page;
101
+ try {
102
+ page = await WebFetch.fetch(url);
103
+ } catch (err) {
104
+ Terminal.printError(err.message || 'Could not fetch the link.');
105
+ return;
106
+ }
107
+
108
+ Terminal.printSuccess(`Opened: ${page.title || page.url}`);
109
+ const content = page.text.trim();
110
+ if (!content) {
111
+ Terminal.printWarning('Nothing readable found on that page.');
112
+ return;
113
+ }
114
+
115
+ messages.push({
116
+ role: 'user',
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.`,
118
+ });
119
+ Session.save(messages);
120
+
121
+ Terminal.printAIStart();
122
+ let response = '';
123
+ try {
124
+ for await (const chunk of provider.stream(messages, {
125
+ model: Config.getEffectiveModel(provider.name),
76
126
  systemPrompt,
77
127
  })) {
78
128
  Terminal.printAIChunk(chunk);
@@ -102,21 +152,32 @@ async function chat(provider, systemPrompt) {
102
152
  terminal: false,
103
153
  });
104
154
 
155
+ let eof = false;
156
+ let pendingResolver = null;
157
+
105
158
  rl.on('close', () => {
106
- if (!exited) {
107
- exited = true;
108
- process.exit(0);
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;
109
165
  }
110
166
  });
111
167
 
112
168
  const prompt = () => new Promise((resolve) => {
169
+ if (eof) return resolve(null);
170
+ pendingResolver = resolve;
113
171
  rl.question(
114
172
  `\n${Branding.colors.bold}${Branding.colors.primary}┌ ${Branding.USER_PROMPT}${Branding.colors.reset} `,
115
- resolve
173
+ (ans) => {
174
+ pendingResolver = null;
175
+ resolve(ans);
176
+ }
116
177
  );
117
178
  });
118
179
 
119
- while (!exited) {
180
+ while (!exited && !eof) {
120
181
  let input;
121
182
  try {
122
183
  input = await prompt();
@@ -140,7 +201,7 @@ async function chat(provider, systemPrompt) {
140
201
 
141
202
  if (cmd === '/clear') {
142
203
  Terminal.clear();
143
- showBanner(provider.name, Config.get('model'), provider.displayName);
204
+ showBanner(provider.name, Config.getEffectiveModel(provider.name), provider.displayName);
144
205
  continue;
145
206
  }
146
207
 
@@ -157,13 +218,13 @@ async function chat(provider, systemPrompt) {
157
218
  }
158
219
 
159
220
  if (cmd === '/models') {
160
- const models = provider.name === 'cortex'
221
+ const models = provider.isLocal
161
222
  ? Catalog.getLocalModels()
162
223
  : Catalog.getCloudModels();
163
224
  Terminal.printInfo('Available models:');
164
225
  for (const m of models) {
165
226
  const info = Catalog.getModelInfo(m);
166
- const current = m === Config.get('model') ? ' (active)' : '';
227
+ const current = m === Config.getEffectiveModel(provider.name) ? ' (active)' : '';
167
228
  console.log(` ${Branding.colors.accent}${m}${Branding.colors.dim}${current} - ${info.tier}${Branding.colors.reset}`);
168
229
  }
169
230
  continue;
@@ -174,6 +235,7 @@ async function chat(provider, systemPrompt) {
174
235
  if (modelName) {
175
236
  if (Catalog.getModelInfo(modelName)) {
176
237
  Config.save({ model: modelName });
238
+ Config.setProviderModel(provider.name, modelName);
177
239
  Terminal.printSuccess(`Model: ${modelName}`);
178
240
  } else {
179
241
  Terminal.printError(`Unknown model: ${modelName}`);
@@ -183,13 +245,13 @@ async function chat(provider, systemPrompt) {
183
245
  }
184
246
 
185
247
  if (cmd === '/model') {
186
- Terminal.printInfo(`Current model: ${Config.get('model')}`);
248
+ Terminal.printInfo(`Current model: ${Config.getEffectiveModel(provider.name)}`);
187
249
  continue;
188
250
  }
189
251
 
190
252
  if (cmd.startsWith('/provider ')) {
191
253
  const prov = cmd.split(' ')[1];
192
- if (['cortex', 'openai'].includes(prov)) {
254
+ if (['cortex', 'openai', 'anthropic', 'gemini'].includes(prov)) {
193
255
  Config.save({ provider: prov });
194
256
  const next = ProviderFactory.create(prov);
195
257
  if (next && await next.isAvailable()) {
@@ -209,6 +271,22 @@ async function chat(provider, systemPrompt) {
209
271
  continue;
210
272
  }
211
273
 
274
+ if (cmd.startsWith('/system')) {
275
+ const prompt = trimmed.slice(7).trim();
276
+ if (prompt.toLowerCase() === 'reset') {
277
+ Config.save({ systemPrompt: '' });
278
+ systemPrompt = loadSystemPrompt();
279
+ Terminal.printSuccess('System prompt reset to default.');
280
+ } else if (prompt) {
281
+ Config.save({ systemPrompt: prompt });
282
+ systemPrompt = prompt;
283
+ Terminal.printSuccess('System prompt updated.');
284
+ } else {
285
+ Terminal.printInfo(`Current system prompt: ${systemPrompt}`);
286
+ }
287
+ continue;
288
+ }
289
+
212
290
  if (cmd.startsWith('/search ')) {
213
291
  const query = trimmed.slice(8).trim();
214
292
  if (!query) {
@@ -219,6 +297,16 @@ async function chat(provider, systemPrompt) {
219
297
  continue;
220
298
  }
221
299
 
300
+ if (cmd.startsWith('/fetch ') || cmd === '/fetch') {
301
+ const urlPart = trimmed.slice(6).trim().split(/\s+/)[0];
302
+ if (!urlPart) {
303
+ Terminal.printWarning('Usage: /fetch <url>');
304
+ continue;
305
+ }
306
+ await answerWithFetch(messages, provider, urlPart, systemPrompt);
307
+ continue;
308
+ }
309
+
222
310
  const intent = detectIntent(trimmed);
223
311
  if (intent.type === 'knowledge') {
224
312
  Terminal.printSuccess(`Auto-search: "${trimmed}"`);
@@ -226,6 +314,16 @@ async function chat(provider, systemPrompt) {
226
314
  continue;
227
315
  }
228
316
 
317
+ // If the message contains a bare URL, open it automatically and ask the AI to summarize.
318
+ if (/https?:\/\/\S+/i.test(trimmed)) {
319
+ const url = extractUrlFromText(trimmed);
320
+ if (url) {
321
+ Terminal.printSuccess(`Auto-open link detected. Fetching...`);
322
+ await answerWithFetch(messages, provider, url, systemPrompt);
323
+ continue;
324
+ }
325
+ }
326
+
229
327
  messages.push({ role: 'user', content: trimmed });
230
328
  Session.save(messages);
231
329
  Terminal.printAIStart();
@@ -233,7 +331,7 @@ async function chat(provider, systemPrompt) {
233
331
  let response = '';
234
332
  try {
235
333
  for await (const chunk of provider.stream(messages, {
236
- model: Config.get('model'),
334
+ model: Config.getEffectiveModel(provider.name),
237
335
  systemPrompt,
238
336
  })) {
239
337
  Terminal.printAIChunk(chunk);
@@ -280,7 +378,7 @@ export async function run() {
280
378
  Config.save({ provider: provider.name });
281
379
 
282
380
  if (args.clear) Terminal.clear();
283
- showBanner(provider.name, Config.get('model'), provider.displayName);
381
+ showBanner(provider.name, Config.getEffectiveModel(provider.name), provider.displayName);
284
382
 
285
383
  const systemPrompt = loadSystemPrompt();
286
384
  await chat(provider, systemPrompt);
package/src/config.js CHANGED
@@ -9,9 +9,10 @@ const DEFAULTS = {
9
9
  provider: 'auto',
10
10
  model: 'vierratale-fast',
11
11
  engineHost: 'http://127.0.0.1:11434',
12
- numCtx: 8192,
12
+ numCtx: 2048,
13
13
  temperature: 0.7,
14
14
  maxTokens: 4096,
15
+ keepAlive: '5m',
15
16
  };
16
17
 
17
18
  function loadConfigFile() {
@@ -39,11 +40,15 @@ export const Config = {
39
40
  provider: getEnv('VIERRATALE_PROVIDER', file.provider || DEFAULTS.provider),
40
41
  model: getEnv('VIERRATALE_MODEL', file.model || DEFAULTS.model),
41
42
  engineHost: getEnv('VIERRATALE_ENGINE_HOST', engineHost),
42
- numCtx: parseInt(getEnv('VIERRATALE_NUM_CTX', String(file.numCtx || DEFAULTS.numCtx))),
43
- temperature: parseFloat(getEnv('VIERRATALE_TEMPERATURE', String(file.temperature || DEFAULTS.temperature))),
44
- maxTokens: parseInt(getEnv('VIERRATALE_MAX_TOKENS', String(file.maxTokens || DEFAULTS.maxTokens))),
43
+ numCtx: parseInt(getEnv('VIERRATALE_NUM_CTX', String(file.numCtx ?? DEFAULTS.numCtx))),
44
+ temperature: parseFloat(getEnv('VIERRATALE_TEMPERATURE', String(file.temperature ?? DEFAULTS.temperature))),
45
+ maxTokens: parseInt(getEnv('VIERRATALE_MAX_TOKENS', String(file.maxTokens ?? DEFAULTS.maxTokens))),
46
+ keepAlive: getEnv('VIERRATALE_KEEP_ALIVE', file.keepAlive ?? DEFAULTS.keepAlive),
45
47
  openaiApiKey: getEnv('OPENAI_API_KEY', file.openaiApiKey || ''),
46
48
  anthropicApiKey: getEnv('ANTHROPIC_API_KEY', file.anthropicApiKey || ''),
49
+ geminiApiKey: getEnv('GEMINI_API_KEY', file.geminiApiKey || ''),
50
+ systemPrompt: file.systemPrompt || '',
51
+ providerModels: file.providerModels || {},
47
52
  };
48
53
  return this._config;
49
54
  },
@@ -58,6 +63,22 @@ export const Config = {
58
63
  return { ...this._config };
59
64
  },
60
65
 
66
+ getProviderModel(provider) {
67
+ if (!this._config) this.load();
68
+ return this._config.providerModels?.[provider] || null;
69
+ },
70
+
71
+ setProviderModel(provider, model) {
72
+ if (!this._config) this.load();
73
+ this._config.providerModels = this._config.providerModels || {};
74
+ this._config.providerModels[provider] = model;
75
+ this.save();
76
+ },
77
+
78
+ getEffectiveModel(provider) {
79
+ return this.getProviderModel(provider) || this.get('model');
80
+ },
81
+
61
82
  save(overrides = {}) {
62
83
  if (!existsSync(CONFIG_DIR)) {
63
84
  mkdirSync(CONFIG_DIR, { recursive: true });
@@ -0,0 +1,99 @@
1
+ import { BaseProvider } from './base.js';
2
+ import { Catalog } from '../catalog.js';
3
+ import { Config } from '../config.js';
4
+
5
+ const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages';
6
+
7
+ export class AnthropicProvider extends BaseProvider {
8
+ constructor() {
9
+ super('anthropic');
10
+ }
11
+
12
+ get displayName() {
13
+ return 'Nebula';
14
+ }
15
+
16
+ _resolveModel(options) {
17
+ const requested = options.model || Config.get('model');
18
+ if (Catalog.isLocalModel(requested) || !Catalog.isCloudModel(requested)) {
19
+ return Catalog.getRealModel(Catalog.getDefaultCloudModel());
20
+ }
21
+ const real = Catalog.getRealModel(requested);
22
+ return real.startsWith('claude-') ? real : 'claude-3-5-haiku-20241022';
23
+ }
24
+
25
+ async isAvailable() {
26
+ const key = Config.get('anthropicApiKey');
27
+ return Boolean(key);
28
+ }
29
+
30
+ async listModels() {
31
+ const key = Config.get('anthropicApiKey');
32
+ if (!key) return [];
33
+ const models = Object.keys(Catalog.getAllModels()).filter((m) => m.startsWith('claude-'));
34
+ return models.map((real) => ({
35
+ realName: real,
36
+ displayName: Catalog.getDisplayName(real),
37
+ }));
38
+ }
39
+
40
+ async *stream(messages, options = {}) {
41
+ const key = Config.get('anthropicApiKey');
42
+ if (!key) throw new Error('Anthropic API key not configured');
43
+
44
+ const model = this._resolveModel(options);
45
+ const systemPrompt = options.systemPrompt || '';
46
+
47
+ const system = [];
48
+ if (systemPrompt) system.push({ type: 'text', text: systemPrompt });
49
+ const apiMessages = messages.map((m) => ({
50
+ role: m.role === 'assistant' ? 'assistant' : 'user',
51
+ content: m.content,
52
+ }));
53
+
54
+ const resp = await fetch(ANTHROPIC_URL, {
55
+ method: 'POST',
56
+ headers: {
57
+ 'Content-Type': 'application/json',
58
+ 'x-api-key': key,
59
+ 'anthropic-version': '2023-06-01',
60
+ },
61
+ body: JSON.stringify({
62
+ model,
63
+ system: system.length ? system : undefined,
64
+ messages: apiMessages,
65
+ max_tokens: options.maxTokens || Config.get('maxTokens'),
66
+ stream: true,
67
+ temperature: options.temperature || Config.get('temperature'),
68
+ }),
69
+ });
70
+
71
+ if (!resp.ok) {
72
+ throw new Error('Failed to connect to cloud engine');
73
+ }
74
+
75
+ const reader = resp.body.getReader();
76
+ const decoder = new TextDecoder();
77
+ let buffer = '';
78
+
79
+ while (true) {
80
+ const { done, value } = await reader.read();
81
+ if (done) break;
82
+
83
+ buffer += decoder.decode(value, { stream: true });
84
+ const lines = buffer.split('\n');
85
+ buffer = lines.pop() || '';
86
+
87
+ for (const line of lines) {
88
+ if (!line.startsWith('data: ')) continue;
89
+ const data = line.slice(6);
90
+ try {
91
+ const json = JSON.parse(data);
92
+ if (json.type === 'content_block_delta' && json.delta?.text) {
93
+ yield json.delta.text;
94
+ }
95
+ } catch {}
96
+ }
97
+ }
98
+ }
99
+ }
@@ -7,6 +7,10 @@ export class BaseProvider {
7
7
  return 'Cortex';
8
8
  }
9
9
 
10
+ get isLocal() {
11
+ return false;
12
+ }
13
+
10
14
  async *stream(messages, options = {}) {
11
15
  throw new Error('stream() must be implemented');
12
16
  }
@@ -12,6 +12,10 @@ export class CortexProvider extends BaseProvider {
12
12
  return 'Cortex';
13
13
  }
14
14
 
15
+ get isLocal() {
16
+ return true;
17
+ }
18
+
15
19
  async isAvailable() {
16
20
  try {
17
21
  const resp = await fetch(`${this.host}/api/tags`, { signal: AbortSignal.timeout(3000) });
@@ -55,6 +59,7 @@ export class CortexProvider extends BaseProvider {
55
59
  model,
56
60
  messages: engineMessages,
57
61
  stream: true,
62
+ keep_alive: Config.get('keepAlive'),
58
63
  options: {
59
64
  num_ctx: Config.get('numCtx'),
60
65
  temperature: options.temperature || Config.get('temperature'),
@@ -0,0 +1,97 @@
1
+ import { BaseProvider } from './base.js';
2
+ import { Catalog } from '../catalog.js';
3
+ import { Config } from '../config.js';
4
+
5
+ const GEMINI_URL = 'https://generativelanguage.googleapis.com/v1beta/models';
6
+
7
+ export class GeminiProvider extends BaseProvider {
8
+ constructor() {
9
+ super('gemini');
10
+ }
11
+
12
+ get displayName() {
13
+ return 'Nebula';
14
+ }
15
+
16
+ _resolveModel(options) {
17
+ const requested = options.model || Config.get('model');
18
+ if (Catalog.isLocalModel(requested) || !Catalog.isCloudModel(requested)) {
19
+ return Catalog.getRealModel(Catalog.getDefaultCloudModel());
20
+ }
21
+ const real = Catalog.getRealModel(requested);
22
+ return real.startsWith('gemini-') ? real : 'gemini-2.0-flash';
23
+ }
24
+
25
+ async isAvailable() {
26
+ const key = Config.get('geminiApiKey');
27
+ return Boolean(key);
28
+ }
29
+
30
+ async listModels() {
31
+ const key = Config.get('geminiApiKey');
32
+ if (!key) return [];
33
+ const models = Object.keys(Catalog.getAllModels()).filter((m) => m.startsWith('gemini-'));
34
+ return models.map((real) => ({
35
+ realName: real,
36
+ displayName: Catalog.getDisplayName(real),
37
+ }));
38
+ }
39
+
40
+ async *stream(messages, options = {}) {
41
+ const key = Config.get('geminiApiKey');
42
+ if (!key) throw new Error('Gemini API key not configured');
43
+
44
+ const model = this._resolveModel(options);
45
+ const systemPrompt = options.systemPrompt || '';
46
+
47
+ const contents = messages.map((m) => ({
48
+ role: m.role === 'assistant' ? 'model' : 'user',
49
+ parts: [{ text: m.content }],
50
+ }));
51
+ if (systemPrompt) {
52
+ contents.unshift({ role: 'user', parts: [{ text: `System: ${systemPrompt}` }] });
53
+ }
54
+
55
+ const resp = await fetch(
56
+ `${GEMINI_URL}/${model}:streamGenerateContent?key=${key}&alt=sse`,
57
+ {
58
+ method: 'POST',
59
+ headers: { 'Content-Type': 'application/json' },
60
+ body: JSON.stringify({
61
+ contents,
62
+ generationConfig: {
63
+ temperature: options.temperature || Config.get('temperature'),
64
+ maxOutputTokens: options.maxTokens || Config.get('maxTokens'),
65
+ },
66
+ }),
67
+ }
68
+ );
69
+
70
+ if (!resp.ok) {
71
+ throw new Error('Failed to connect to cloud engine');
72
+ }
73
+
74
+ const reader = resp.body.getReader();
75
+ const decoder = new TextDecoder();
76
+ let buffer = '';
77
+
78
+ while (true) {
79
+ const { done, value } = await reader.read();
80
+ if (done) break;
81
+
82
+ buffer += decoder.decode(value, { stream: true });
83
+ const lines = buffer.split('\n');
84
+ buffer = lines.pop() || '';
85
+
86
+ for (const line of lines) {
87
+ if (!line.startsWith('data: ')) continue;
88
+ const data = line.slice(6);
89
+ try {
90
+ const json = JSON.parse(data);
91
+ const text = json.candidates?.[0]?.content?.parts?.[0]?.text;
92
+ if (text) yield text;
93
+ } catch {}
94
+ }
95
+ }
96
+ }
97
+ }
@@ -1,5 +1,7 @@
1
1
  import { CortexProvider } from './cortex.js';
2
2
  import { OpenAIProvider } from './openai.js';
3
+ import { AnthropicProvider } from './anthropic.js';
4
+ import { GeminiProvider } from './gemini.js';
3
5
  import { Config } from '../config.js';
4
6
 
5
7
  let providers = {};
@@ -9,6 +11,8 @@ function getProviders() {
9
11
  providers = {
10
12
  cortex: new CortexProvider(),
11
13
  openai: new OpenAIProvider(),
14
+ anthropic: new AnthropicProvider(),
15
+ gemini: new GeminiProvider(),
12
16
  };
13
17
  }
14
18
  return providers;
@@ -23,7 +27,7 @@ export const ProviderFactory = {
23
27
  if (await ps[requested].isAvailable()) return ps[requested];
24
28
  }
25
29
 
26
- for (const name of ['cortex', 'openai']) {
30
+ for (const name of ['cortex', 'openai', 'anthropic', 'gemini']) {
27
31
  if (await ps[name].isAvailable()) return ps[name];
28
32
  }
29
33
 
@@ -45,11 +45,21 @@ export class OpenAIProvider extends BaseProvider {
45
45
  }
46
46
  }
47
47
 
48
+ // If the configured model is a local (ollama) model, use a cloud default so
49
+ // we never send a qwen model name to the OpenAI API.
50
+ _resolveModel(options) {
51
+ const requested = options.model || Config.get('model');
52
+ if (Catalog.isLocalModel(requested) || !Catalog.isCloudModel(requested)) {
53
+ return Catalog.getRealModel(Catalog.getDefaultCloudModel());
54
+ }
55
+ return Catalog.getRealModel(requested);
56
+ }
57
+
48
58
  async *stream(messages, options = {}) {
49
59
  const key = Config.get('openaiApiKey');
50
60
  if (!key) throw new Error('Cloud API key not configured');
51
61
 
52
- const model = Catalog.getRealModel(options.model || Config.get('model'));
62
+ const model = this._resolveModel(options);
53
63
  const systemPrompt = options.systemPrompt || '';
54
64
 
55
65
  const apiMessages = [];
package/src/ui/banner.js CHANGED
@@ -3,10 +3,13 @@ import { join, dirname } from 'path';
3
3
  import { fileURLToPath } from 'url';
4
4
  import { Branding } from './branding.js';
5
5
  import { Terminal } from './terminal.js';
6
+ import { Config } from '../config.js';
6
7
 
7
8
  const __dirname = dirname(fileURLToPath(import.meta.url));
8
9
 
9
10
  export function loadSystemPrompt() {
11
+ const custom = Config.get('systemPrompt');
12
+ if (custom) return custom;
10
13
  try {
11
14
  const jsonPath = join(__dirname, '..', 'prompts', 'system.json');
12
15
  const data = JSON.parse(readFileSync(jsonPath, 'utf-8'));
@@ -20,16 +23,18 @@ export function showBanner(providerName, model, providerDisplayName) {
20
23
  const C = Branding.colors;
21
24
  const width = 44;
22
25
  const border = ` ╔${'═'.repeat(width)}╗`;
26
+ const visible = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
27
+ const pad = (s) => s + ' '.repeat(Math.max(1, width - visible(s).length));
23
28
 
24
29
  console.log();
25
30
  console.log(`${C.primary}${Branding.BANNER}${C.reset}`);
26
- console.log(` ${C.bold}${C.accent}▸ Vierratale AI ▸ Cortex Engine${C.reset}`);
31
+ console.log(` ${C.bold}${C.accent}▸ Vierratale AI ▸ ${providerDisplayName || 'Cortex'} Engine${C.reset}`);
27
32
  console.log();
28
33
  console.log(border);
29
- console.log(` ${C.bold}${C.primary}Model${C.reset}${' '.repeat(width - 12)}║`);
30
- console.log(` ${C.accent}${model}${C.reset}${' '.repeat(Math.max(1, width - 5 - model.length))}║`);
31
- console.log(` ${C.bold}${C.primary}Engine${C.reset}${' '.repeat(width - 13)}║`);
32
- console.log(` ${C.accent}${providerDisplayName || 'Cortex'}${C.reset}${' '.repeat(Math.max(1, width - 7 - (providerDisplayName || 'Cortex').length))}║`);
34
+ console.log(` ║${pad(' ' + C.bold + C.primary + 'Model' + C.reset)}║`);
35
+ console.log(` ║${pad(' ' + C.accent + model + C.reset)}║`);
36
+ console.log(` ║${pad(' ' + C.bold + C.primary + 'Engine' + C.reset)}║`);
37
+ console.log(` ║${pad(' ' + C.accent + (providerDisplayName || 'Cortex') + C.reset)}║`);
33
38
  console.log(` ╚${'═'.repeat(width)}╝`);
34
39
  console.log();
35
40
  console.log(` ${C.dim}Type /help for commands · /quit to exit${C.reset}`);
@@ -1,6 +1,6 @@
1
1
  export const Branding = {
2
2
  APP_NAME: 'VierrataleAI',
3
- VERSION: '0.1.0-beta.5',
3
+ VERSION: '0.1.0-beta.6',
4
4
 
5
5
  colors: {
6
6
  primary: '\x1b[38;2;124;58;237m',
@@ -0,0 +1,115 @@
1
+ const UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36';
2
+
3
+ const MAX_BYTES = 200000; // 200KB cap
4
+ const MAX_TEXT = 8000; // ~8k chars of readable text
5
+
6
+ const BLOCK_TAGS = new Set([
7
+ 'script', 'style', 'noscript', 'svg', 'head', 'title',
8
+ 'nav', 'footer', 'aside', 'iframe', 'form', 'button', 'noscript',
9
+ ]);
10
+
11
+ export class WebFetch {
12
+ static normalizeUrl(input) {
13
+ const trimmed = (input || '').trim();
14
+ if (!trimmed) return null;
15
+ let url = trimmed;
16
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) {
17
+ // Already has an explicit scheme — must be http(s).
18
+ if (!/^https?:\/\//i.test(url)) return null;
19
+ } else if (!/^https?:\/\//i.test(url)) {
20
+ url = `https://${url}`;
21
+ }
22
+ try {
23
+ const u = new URL(url);
24
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
25
+ return u.toString();
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ static async fetch(rawUrl, maxText = MAX_TEXT) {
32
+ const url = this.normalizeUrl(rawUrl);
33
+ if (!url) {
34
+ throw new Error('Invalid URL. Provide a valid http(s) address.');
35
+ }
36
+
37
+ let resp;
38
+ try {
39
+ resp = await fetch(url, {
40
+ headers: { 'User-Agent': UA, Accept: 'text/html,application/xhtml+xml' },
41
+ redirect: 'follow',
42
+ signal: AbortSignal.timeout(15000),
43
+ });
44
+ } catch {
45
+ throw new Error(`Could not reach ${url}`);
46
+ }
47
+
48
+ if (!resp.ok) {
49
+ throw new Error(`Request failed (${resp.status}) for ${url}`);
50
+ }
51
+
52
+ // Refuse to read the whole body past the cap.
53
+ const reader = resp.body.getReader();
54
+ const decoder = new TextDecoder('utf-8', { fatal: false });
55
+ let html = '';
56
+ let received = 0;
57
+ while (received < MAX_BYTES) {
58
+ const { done, value } = await reader.read();
59
+ if (done) break;
60
+ received += value.length;
61
+ html += decoder.decode(value, { stream: true });
62
+ }
63
+ reader.cancel();
64
+ html += decoder.decode();
65
+
66
+ return {
67
+ url: resp.url || url,
68
+ title: this._extractTitle(html),
69
+ text: this._extractText(html).slice(0, maxText),
70
+ };
71
+ }
72
+
73
+ static _extractTitle(html) {
74
+ const m = /<title[^>]*>([^<]*)<\/title>/i.exec(html);
75
+ return m ? this._stripEntities(m[1].trim()) : '';
76
+ }
77
+
78
+ static _extractText(html) {
79
+ // Strip block tags' contents that add no readable value.
80
+ let s = html.replace(/<(script|style|noscript|svg|head|iframe|form|nav|footer|aside)[^>]*>[\s\S]*?<\/\1>/gi, ' ');
81
+
82
+ // Force spacing around block-level elements so words don't merge.
83
+ s = s.replace(/<\/(p|div|h[1-6]|li|tr|br|section|article)>/gi, '\n');
84
+ s = s.replace(/<(br|li|tr)[^>]*>/gi, '\n');
85
+
86
+ // Remove remaining tags.
87
+ s = s.replace(/<[^>]+>/g, ' ');
88
+
89
+ // College entities.
90
+ s = this._stripEntities(s);
91
+
92
+ // Collapse whitespace and trim lines.
93
+ return s
94
+ .replace(/[ \t]+/g, ' ')
95
+ .replace(/ *\n */g, '\n')
96
+ .replace(/\n{3,}/g, '\n\n')
97
+ .replace(/[ \t]+/g, ' ')
98
+ .replace(/\u00a0/g, ' ')
99
+ .trim();
100
+ }
101
+
102
+ static _stripEntities(text) {
103
+ const map = {
104
+ '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"',
105
+ '&#39;': "'", '&#039;': "'", '&apos;': "'", '&nbsp;': ' ',
106
+ '&ndash;': '–', '&mdash;': '—', '&hellip;': '...', '&copy;': '©',
107
+ '&#x27;': "'", '&rsquo;': '’', '&lsquo;': '‘', '&ldquo;': '“', '&rdquo;': '”',
108
+ };
109
+ return String(text)
110
+ .replace(/<[^>]*>/g, '')
111
+ .replace(/&[a-zA-Z0-9#]+;/g, (m) => map[m] ?? '')
112
+ .replace(/\s+/g, ' ')
113
+ .trim();
114
+ }
115
+ }