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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/installer.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { execSync, exec } from 'child_process';
2
- import { existsSync } from 'fs';
2
+ import { existsSync, mkdirSync, symlinkSync, chmodSync, writeFileSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { homedir, platform } from 'os';
3
5
  import { Catalog } from './catalog.js';
4
6
  import { Config } from './config.js';
5
7
 
@@ -9,13 +11,13 @@ function runSilent(cmd) {
9
11
  });
10
12
  }
11
13
 
12
- function isOllamaRunning(host) {
14
+ function isEngineRunning(host) {
13
15
  return fetch(`${host}/api/tags`, { signal: AbortSignal.timeout(3000) })
14
16
  .then((r) => r.ok)
15
17
  .catch(() => false);
16
18
  }
17
19
 
18
- function isOllamaInstalled() {
20
+ function isEngineInstalled() {
19
21
  try {
20
22
  execSync('which ollama 2>/dev/null', { stdio: 'ignore' });
21
23
  return true;
@@ -24,8 +26,9 @@ function isOllamaInstalled() {
24
26
  return paths.some((p) => existsSync(p));
25
27
  }
26
28
 
27
- async function installOllama() {
29
+ async function installEngine() {
28
30
  await runSilent('curl -fsSL https://ollama.com/install.sh | sh');
31
+ return isEngineInstalled();
29
32
  }
30
33
 
31
34
  async function pullModel(host, model) {
@@ -53,27 +56,136 @@ async function getInstalledModels(host) {
53
56
  }
54
57
  }
55
58
 
59
+ // Scripts in the same model family share a base name before any variant
60
+ // suffix (e.g. qwen2.5-coder:1.5b is the sibling of qwen2.5:1.5b).
61
+ function familyOf(model) {
62
+ return model
63
+ .split(':')[0]
64
+ .toLowerCase()
65
+ .replace(/-(coder|instruct|reasoning|chat|base|tiny|nano)$/, '');
66
+ }
67
+
68
+ function sizeOf(model) {
69
+ const size = model.split(':')[1] || '';
70
+ const match = size.match(/(\d+(?:\.\d+)?)(m|b)/i);
71
+ if (!match) return Number.POSITIVE_INFINITY;
72
+ const value = parseFloat(match[1]);
73
+ return match[2].toLowerCase() === 'b' ? value * 1000 : value;
74
+ }
75
+
76
+ // Find the already-installed model that best matches the requested one.
77
+ // Prefers an exact match, then any same-family sibling (closest size).
78
+ function chooseModel(installed, realModel) {
79
+ if (installed.includes(realModel)) {
80
+ return { model: realModel, substituted: false, reason: null };
81
+ }
82
+ const fam = familyOf(realModel);
83
+ let best = null;
84
+ let bestDist = Number.POSITIVE_INFINITY;
85
+ for (const m of installed) {
86
+ if (familyOf(m) !== fam) continue;
87
+ const dist = Math.abs(sizeOf(m) - sizeOf(realModel)) + 0.5;
88
+ if (dist < bestDist) {
89
+ bestDist = dist;
90
+ best = m;
91
+ }
92
+ }
93
+ if (best) {
94
+ return {
95
+ model: best,
96
+ substituted: true,
97
+ reason: `"${realModel}" is not installed; using ${best} (same family)`,
98
+ };
99
+ }
100
+ return { model: null, substituted: true, reason: `"${realModel}" is not installed` };
101
+ }
102
+
103
+ async function resolveModel(host, realModel) {
104
+ const installed = await getInstalledModels(host);
105
+ return chooseModel(installed, realModel);
106
+ }
107
+
108
+ // Best local bin dir for a globally-callable command on this platform.
109
+ function localBinDir() {
110
+ if (process.env.XDG_BIN_HOME) return process.env.XDG_BIN_HOME;
111
+ if (process.env.HOME) return join(homedir(), '.local', 'bin');
112
+ return '/usr/local/bin';
113
+ }
114
+
115
+ function installAppCommand() {
116
+ try {
117
+ const dir = localBinDir();
118
+ mkdirSync(dir, { recursive: true, mode: 0o755 });
119
+ const launcherPath = join(dir, 'vierrataleai');
120
+ if (platform() === 'win32') {
121
+ if (existsSync(launcherPath)) return launcherPath;
122
+ writeFileSync(launcherPath, `@echo off\r\nnode "${process.argv[1]}" %*\r\n`);
123
+ return launcherPath;
124
+ }
125
+ // Prefer a real symlink; fall back to a small shim that calls the node bin.
126
+ if (existsSync(launcherPath)) return launcherPath;
127
+ const target = process.argv[1] && existsSync(process.argv[1]) ? process.argv[1] : join(Config.getConfigDir(), '..', '..', 'src', 'cli.js');
128
+ try {
129
+ symlinkSync(target, launcherPath);
130
+ chmodSync(launcherPath, 0o755);
131
+ } catch {
132
+ writeFileSync(launcherPath, `#!/bin/sh\nnode "${target}" "$@"\n`);
133
+ chmodSync(launcherPath, 0o755);
134
+ }
135
+ return launcherPath;
136
+ } catch {
137
+ return null;
138
+ }
139
+ }
140
+
56
141
  export const Installer = {
142
+ async pullModel(host, model) {
143
+ return pullModel(host, model);
144
+ },
145
+
146
+ async getInstalledModels(host) {
147
+ return getInstalledModels(host);
148
+ },
149
+
150
+ async resolveModel(host, realModel) {
151
+ return resolveModel(host, realModel);
152
+ },
153
+
154
+ async firstInstalled(host) {
155
+ const installed = await getInstalledModels(host);
156
+ return installed[0] || null;
157
+ },
158
+
159
+ chooseModel,
160
+
57
161
  async ensure() {
58
- const host = Config.get('ollamaHost');
162
+ const host = Config.get('engineHost');
59
163
 
60
- if (!isOllamaInstalled()) {
61
- await installOllama();
164
+ if (!isEngineInstalled()) {
165
+ await installEngine();
62
166
  }
63
167
 
64
- const running = await isOllamaRunning(host);
168
+ const running = await isEngineRunning(host);
65
169
  if (!running) {
66
170
  try {
67
- execSync('ollama serve &', { stdio: 'ignore' });
68
- await new Promise((r) => setTimeout(r, 3000));
171
+ // No systemd available here, so launch the daemon directly, detached,
172
+ // and poll until it responds instead of relying on a service manager.
173
+ const child = exec('ollama serve', {
174
+ stdio: 'ignore',
175
+ detached: true,
176
+ });
177
+ if (child.unref) child.unref();
178
+ for (let i = 0; i < 30; i++) {
179
+ await new Promise((r) => setTimeout(r, 1000));
180
+ if (await isEngineRunning(host)) break;
181
+ }
69
182
  } catch {}
70
183
  }
71
184
 
72
185
  const realModel = Catalog.getRealModel(Config.get('model'));
73
186
  const installed = await getInstalledModels(host);
74
- const needsPull = !installed.some(
75
- (m) => m === realModel || m.startsWith(realModel.split(':')[0])
76
- );
187
+ const resolved = chooseModel(installed, realModel);
188
+ const needsPull = resolved.model == null;
77
189
 
78
190
  if (needsPull) {
79
191
  await pullModel(host, realModel);
@@ -83,7 +195,39 @@ export const Installer = {
83
195
  },
84
196
 
85
197
  async isReady() {
86
- const host = Config.get('ollamaHost');
87
- return isOllamaRunning(host);
198
+ const host = Config.get('engineHost');
199
+ return isEngineRunning(host);
200
+ },
201
+
202
+ // Full install: engine + model + a global "vierrataleai" command.
203
+ async install({ includeCommand = true } = {}) {
204
+ const host = Config.get('engineHost');
205
+
206
+ if (!isEngineInstalled()) {
207
+ await installEngine();
208
+ }
209
+
210
+ const running = await isEngineRunning(host);
211
+ if (!running) {
212
+ try {
213
+ const child = exec('ollama serve', { stdio: 'ignore', detached: true });
214
+ if (child.unref) child.unref();
215
+ for (let i = 0; i < 30; i++) {
216
+ await new Promise((r) => setTimeout(r, 1000));
217
+ if (await isEngineRunning(host)) break;
218
+ }
219
+ } catch {}
220
+ }
221
+
222
+ const realModel = Catalog.getRealModel(Config.get('model'));
223
+ const installed = await getInstalledModels(host);
224
+ const resolved = chooseModel(installed, realModel);
225
+ if (resolved.model == null) {
226
+ await pullModel(host, realModel);
227
+ }
228
+
229
+ let command = null;
230
+ if (includeCommand) command = installAppCommand();
231
+ return { installed: await isEngineInstalled(), running: await isEngineRunning(host), command };
88
232
  },
89
233
  };
@@ -1,3 +1,3 @@
1
1
  {
2
- "system": "You are VierrataleAI, an intelligent AI assistant created by Vierratale. You are helpful, concise, and accurate. You provide clear answers and help users with their questions and tasks. When asked about your identity, always respond that you are VierrataleAI, an AI assistant created by Vierratale."
3
- }
2
+ "system": "You are VierrataleAI, an intelligent AI assistant created by Vierratale. You are helpful, concise, and accurate. You provide clear answers and help users with their questions and tasks. When asked about your identity, always respond that you are VierrataleAI, an AI assistant created by Vierratale. You CAN write files and folders directly to the user's disk. When the user asks you to create, save, or generate code, configs, scripts, projects, web pages, or any other files, DO NOT refuse — answer with the FILE:/FOLDER: format instead. Use this exact format for EACH file: a line \"FILE: relative/path/to/file\" on its own line, immediately followed by a markdown code fence containing the file content. This works for any file type, including JSON config files and HTML pages: write a config as \"FILE: config.json\" followed by a ```json fence, and a web page as \"FILE: index.html\" followed by a ```html fence with the full <!doctype html>... markup inside. Use paths relative to the user's working directory. Emit one FILE: block per file; nested folders are created automatically. If you need an empty folder, emit \"FOLDER: relative/path\" on its own line. When asked for a story, folk tale, legend, or myth, give the real, well-known version using the web search results you were given when available, and also save the full story to a .txt file using FILE: <title>.txt followed by a ```text fence. Keep any explanation short and outside of the FILE:/FOLDER: blocks."
3
+ }
@@ -13,3 +13,45 @@ You are **VierrataleAI**, an intelligent AI assistant created by Vierratale.
13
13
  - Help users with tasks including coding, writing, analysis, and general knowledge.
14
14
  - If you don't know something, say so honestly.
15
15
  - Use markdown formatting when appropriate for readability.
16
+
17
+ ## Writing Files and Folders
18
+
19
+ You CAN write files and folders directly to the user's disk. When the user asks
20
+ you to create, save, or generate code, configs, scripts, projects, or any other
21
+ files, DO NOT refuse — answer with the format below. Use this exact format for
22
+ EACH file:
23
+
24
+ FILE: relative/path/to/file.js
25
+ ```js
26
+ // file content here
27
+ ```
28
+
29
+ This works for any file type, including JSON and HTML. For example, write a
30
+ config file exactly like this:
31
+
32
+ FILE: config.json
33
+ ```json
34
+ {
35
+ "name": "my-app",
36
+ "port": 3000
37
+ }
38
+ ```
39
+
40
+ And a web page like this:
41
+
42
+ FILE: index.html
43
+ ```html
44
+ <!doctype html>
45
+ <html>
46
+ <head><title>My Page</title></head>
47
+ <body><h1>Hello</h1></body>
48
+ </html>
49
+ ```
50
+
51
+ Rules:
52
+ - Put `FILE: <path>` on its own line, immediately followed by a markdown code fence containing the file content. No text between the header and the fence.
53
+ - Use paths relative to the user's working directory (e.g. `src/app.js`, `config.json`).
54
+ - Emit one `FILE:` block per file; nested folders are created automatically.
55
+ - If you need an empty folder, emit `FOLDER: <path>` on its own line.
56
+ - When asked for a story, folk tale, legend, or myth, give the real, well-known version — use the web search results you were given when available — and also save the full story to a .txt file using `FILE: <title>.txt` followed by a ```text fence.
57
+ - Keep any explanation short and outside of the `FILE:`/`FOLDER:` blocks.
@@ -0,0 +1,130 @@
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('[ERR-0004] Cloud API key not configured. Add it to the config and try again.');
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 controller = new AbortController();
55
+ const connectTimer = setTimeout(() => controller.abort(), 240000);
56
+
57
+ let resp;
58
+ try {
59
+ resp = await fetch(ANTHROPIC_URL, {
60
+ method: 'POST',
61
+ headers: {
62
+ 'Content-Type': 'application/json',
63
+ 'x-api-key': key,
64
+ 'anthropic-version': '2023-06-01',
65
+ },
66
+ body: JSON.stringify({
67
+ model,
68
+ system: system.length ? system : undefined,
69
+ messages: apiMessages,
70
+ max_tokens: options.maxTokens || Config.get('maxTokens'),
71
+ stream: true,
72
+ temperature: options.temperature || Config.get('temperature'),
73
+ }),
74
+ signal: controller.signal,
75
+ });
76
+ } catch (err) {
77
+ if (err.name === 'AbortError') {
78
+ throw new Error('[ERR-0001] Could not reach the API (no response within 60s). Check your network connection.');
79
+ }
80
+ throw new Error('[ERR-0001] Could not reach the API (no response). Check your network connection.');
81
+ } finally {
82
+ clearTimeout(connectTimer);
83
+ }
84
+
85
+ if (!resp.ok) {
86
+ throw new Error('[ERR-0003] The cloud provider returned HTTP ' + resp.status + '. Check your API key in config.');
87
+ }
88
+
89
+ const reader = resp.body.getReader();
90
+ const decoder = new TextDecoder();
91
+ let buffer = '';
92
+ let idle = null;
93
+ const settle = () => {
94
+ if (idle) clearTimeout(idle);
95
+ idle = setTimeout(() => controller.abort(), 240000);
96
+ };
97
+ settle();
98
+ try {
99
+ while (true) {
100
+ let value, done;
101
+ try {
102
+ ({ done, value } = await reader.read());
103
+ } catch (err) {
104
+ if (err.name === 'AbortError') {
105
+ throw new Error('[ERR-0001] Lost the connection to the cloud API (no data for 60s).');
106
+ }
107
+ throw err;
108
+ }
109
+ if (done) break;
110
+ settle();
111
+ buffer += decoder.decode(value, { stream: true });
112
+ const lines = buffer.split('\n');
113
+ buffer = lines.pop() || '';
114
+
115
+ for (const line of lines) {
116
+ if (!line.startsWith('data: ')) continue;
117
+ const data = line.slice(6);
118
+ try {
119
+ const json = JSON.parse(data);
120
+ if (json.type === 'content_block_delta' && json.delta?.text) {
121
+ yield json.delta.text;
122
+ }
123
+ } catch {}
124
+ }
125
+ }
126
+ } finally {
127
+ if (idle) clearTimeout(idle);
128
+ }
129
+ }
130
+ }
@@ -3,10 +3,28 @@ export class BaseProvider {
3
3
  this.name = name;
4
4
  }
5
5
 
6
+ get displayName() {
7
+ return 'Cortex';
8
+ }
9
+
10
+ get isLocal() {
11
+ return false;
12
+ }
13
+
6
14
  async *stream(messages, options = {}) {
7
15
  throw new Error('stream() must be implemented');
8
16
  }
9
17
 
18
+ // Non-streaming convenience: collects the stream. Providers may override
19
+ // with a proper single-shot request (see CortexProvider).
20
+ async complete(messages, options = {}) {
21
+ let out = '';
22
+ for await (const chunk of this.stream(messages, options)) {
23
+ out += chunk;
24
+ }
25
+ return out;
26
+ }
27
+
10
28
  async listModels() {
11
29
  throw new Error('listModels() must be implemented');
12
30
  }