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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,257 @@
1
+ import { spawn } from 'child_process';
2
+ import { dirname, resolve, sep } from 'path';
3
+
4
+ const ALLOWED_BINS = new Set([
5
+ 'mkdir', 'touch', 'ls', 'pwd', 'cat', 'cp', 'mv', 'rm',
6
+ 'npm', 'npx', 'node', 'python', 'python3', 'git', 'echo', 'printf',
7
+ ]);
8
+
9
+ const SHELL_META = /[;&|<>`$]|\$\{|\(\)|\|\||&&/;
10
+
11
+ // Bins whose *path arguments* must stay inside the workspace. Reads like `ls`
12
+ // are unrestricted on purpose; writes/deletes are contained.
13
+ const PATH_CONTAINED_BINS = new Set(['mkdir', 'touch', 'cat', 'cp', 'mv', 'rm']);
14
+
15
+ const MAX_CAPTURE = 200 * 1024;
16
+
17
+ export function toolResult(overrides = {}) {
18
+ return {
19
+ success: false,
20
+ command: '',
21
+ stdout: '',
22
+ stderr: '',
23
+ exitCode: 0,
24
+ cwd: '',
25
+ timedOut: false,
26
+ ...overrides,
27
+ };
28
+ }
29
+
30
+ // Tokenizer for a single command line. Supports single/double quotes and
31
+ // backslash escaping so arguments with spaces are handled without a shell.
32
+ export function parseCommandLine(line) {
33
+ const argv = [];
34
+ let buf = null;
35
+ let quote = null;
36
+ const flush = () => {
37
+ if (buf !== null) {
38
+ argv.push(buf);
39
+ buf = null;
40
+ }
41
+ };
42
+ for (let i = 0; i < line.length; i++) {
43
+ const c = line[i];
44
+ if (quote) {
45
+ if (c === quote) {
46
+ quote = null;
47
+ } else if (c === '\\' && quote === '"') {
48
+ if (i + 1 < line.length) buf += line[++i];
49
+ else buf += c;
50
+ } else {
51
+ buf += c;
52
+ }
53
+ continue;
54
+ }
55
+ if (c === "'" || c === '"') {
56
+ if (buf === null) buf = '';
57
+ quote = c;
58
+ continue;
59
+ }
60
+ if (c === ' ' || c === '\t' || c === '\n') {
61
+ flush();
62
+ continue;
63
+ }
64
+ if (c === '\\') {
65
+ if (buf === null) buf = '';
66
+ if (i + 1 < line.length) buf += line[++i];
67
+ else buf += c;
68
+ continue;
69
+ }
70
+ if (buf === null) buf = '';
71
+ buf += c;
72
+ }
73
+ flush();
74
+ return argv;
75
+ }
76
+
77
+ export function isAllowedBin(bin) {
78
+ if (!/^[A-Za-z0-9._-]+$/.test(bin || '')) return false;
79
+ return ALLOWED_BINS.has(bin);
80
+ }
81
+
82
+ export class SafeCommandExecutor {
83
+ constructor({ cwd, timeoutMs } = {}) {
84
+ this.cwd = resolve(cwd || process.cwd());
85
+ this.timeoutMs = timeoutMs || 60000;
86
+ }
87
+
88
+ // Validate a command line and normalize it to an argv array, or throw with a
89
+ // human-readable reason.
90
+ validate(argv) {
91
+ if (!argv.length) {
92
+ throw new Error('Empty command — nothing to run.');
93
+ }
94
+ const bin = argv[0];
95
+ if (!isAllowedBin(bin)) {
96
+ throw new Error(`Command "${bin}" is not on the allowed list. Use one of: ${[...ALLOWED_BINS].join(', ')}.`);
97
+ }
98
+ const args = argv.slice(1);
99
+
100
+ for (const a of args) {
101
+ if (SHELL_META.test(a)) {
102
+ throw new Error(`Shell operators and variable syntax are not allowed in arguments: ${JSON.stringify(a)}`);
103
+ }
104
+ }
105
+
106
+ if ((bin === 'node' || bin === 'python' || bin === 'python3') && args.includes('-e') || args.includes('-c')) {
107
+ throw new Error(`Inline code (-e/-c) is disabled for ${bin}. Run a file inside the workspace instead (e.g. ${bin} script.js).`);
108
+ }
109
+ if (bin === 'git' && args.includes('clean')) {
110
+ throw new Error('`git clean` is disabled by the safety policy.');
111
+ }
112
+ if (bin === 'rm') {
113
+ this._checkRm(args);
114
+ }
115
+ if (PATH_CONTAINED_BINS.has(bin)) {
116
+ this._checkPaths(bin, args);
117
+ }
118
+ return argv;
119
+ }
120
+
121
+ _resolve(argvIdxArg) {
122
+ const base = resolve(this.cwd);
123
+ const target = resolve(base, argvIdxArg);
124
+ const outside = target !== base && !target.startsWith(base + sep);
125
+ return { base, target, outside };
126
+ }
127
+
128
+ _isHomeish(p) {
129
+ return p === '~' || p.startsWith('~/');
130
+ }
131
+
132
+ _checkRm(args) {
133
+ const targets = args.filter((a) => !a.startsWith('-'));
134
+ if (!targets.length) {
135
+ throw new Error('`rm` needs a target path.');
136
+ }
137
+ for (const t of targets) {
138
+ if (this._isHomeish(t)) {
139
+ throw new Error(`Refusing to remove ${t} — that path is outside the workspace.`);
140
+ }
141
+ if (t === '*' || t.includes('*')) {
142
+ throw new Error('Wildcards are not expanded (commands run without a shell). Give an explicit path for `rm`.');
143
+ }
144
+ const { base, target, outside } = this._resolve(t);
145
+ if (outside || target === base) {
146
+ throw new Error(`Refusing to remove ${t} — it resolves outside the workspace (${base}).`);
147
+ }
148
+ }
149
+ }
150
+
151
+ _checkPaths(bin, args) {
152
+ for (const a of args) {
153
+ if (a.startsWith('-') || a === '--') continue;
154
+ if (this._isHomeish(a)) {
155
+ throw new Error(`Refusing to use ${a} — home-directory paths are outside the workspace.`);
156
+ }
157
+ if (!this._looksLikePath(a)) continue;
158
+ const { base, target, outside } = this._resolve(a);
159
+ if (outside) {
160
+ throw new Error(`${bin}: refusing to touch ${a} — it resolves outside the workspace (${base}).`);
161
+ }
162
+ }
163
+ }
164
+
165
+ // Avoid treating plain words (e.g. a file NAME in `npm install foo`) as
166
+ // paths. Only flags, or tokens that contain a path separator or a known
167
+ // relative prefix, are checked.
168
+ _looksLikePath(a) {
169
+ if (a.includes('/') || a.includes('\\') || a.startsWith('.') || a === '..') return true;
170
+ if (/\.[A-Za-z0-9]{1,8}$/.test(a)) return true;
171
+ return false;
172
+ }
173
+
174
+ runCommand(commandLine) {
175
+ if (typeof commandLine !== 'string' || !commandLine.trim()) {
176
+ return Promise.resolve(toolResult({
177
+ success: false,
178
+ command: String(commandLine || ''),
179
+ stderr: 'Empty or invalid command.',
180
+ cwd: this.cwd,
181
+ }));
182
+ }
183
+ let argv;
184
+ try {
185
+ argv = this.validate(parseCommandLine(commandLine));
186
+ } catch (err) {
187
+ return Promise.resolve(toolResult({
188
+ success: false,
189
+ command: commandLine,
190
+ stderr: err.message,
191
+ cwd: this.cwd,
192
+ }));
193
+ }
194
+ return this.run(argv, commandLine);
195
+ }
196
+
197
+ run(argv, display = argv.join(' ')) {
198
+ return new Promise((resolvePromise) => {
199
+ let stdout = '';
200
+ let stderr = '';
201
+ let settled = false;
202
+ let timedOut = false;
203
+ let childExitCode = 1;
204
+
205
+ let child;
206
+ try {
207
+ child = spawn(argv[0], argv.slice(1), { cwd: this.cwd, shell: false });
208
+ } catch (err) {
209
+ return resolvePromise(toolResult({
210
+ success: false,
211
+ command: display,
212
+ stderr: err.message,
213
+ cwd: this.cwd,
214
+ }));
215
+ }
216
+
217
+ const finish = (code, dueToTimeout) => {
218
+ if (settled) return;
219
+ settled = true;
220
+ let out = stdout;
221
+ let errOut = stderr;
222
+ if (out.length > MAX_CAPTURE) out = out.slice(-MAX_CAPTURE) + '\n…[truncated]';
223
+ if (errOut.length > MAX_CAPTURE) errOut = errOut.slice(-MAX_CAPTURE) + '\n…[truncated]';
224
+ resolvePromise(toolResult({
225
+ success: code === 0 && !dueToTimeout,
226
+ command: display,
227
+ stdout: out,
228
+ stderr: errOut,
229
+ exitCode: dueToTimeout ? 124 : code,
230
+ cwd: this.cwd,
231
+ timedOut: !!dueToTimeout,
232
+ }));
233
+ };
234
+
235
+ const timer = setTimeout(() => {
236
+ timedOut = true;
237
+ try { child.kill('SIGKILL'); } catch {}
238
+ }, this.timeoutMs);
239
+
240
+ child.stdout.on('data', (d) => {
241
+ if (stdout.length < MAX_CAPTURE * 2) stdout += d.toString();
242
+ });
243
+ child.stderr.on('data', (d) => {
244
+ if (stderr.length < MAX_CAPTURE * 2) stderr += d.toString();
245
+ });
246
+ child.on('error', (err) => {
247
+ clearTimeout(timer);
248
+ stderr = err.message;
249
+ finish(127, false);
250
+ });
251
+ child.on('close', (code) => {
252
+ clearTimeout(timer);
253
+ finish(timedOut ? 124 : (code == null ? 1 : code), timedOut);
254
+ });
255
+ });
256
+ }
257
+ }
@@ -0,0 +1,131 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
2
+ import { resolve, sep } from 'path';
3
+ import { toolResult, SafeCommandExecutor } from './executor.js';
4
+
5
+ // Resolve a path relative to the workspace and require it to stay inside.
6
+ // Throws if the path escapes the workspace (or hits the workspace root for
7
+ // destructive operations).
8
+ export function resolveWithin(cwd, p, { allowRoot = false } = {}) {
9
+ const base = resolve(cwd);
10
+ const target = resolve(base, String(p || ''));
11
+ const rootOrEscape = target === base || !target.startsWith(base + sep);
12
+ if (target !== base && !target.startsWith(base + sep)) {
13
+ throw new Error(`Refusing to access a path outside the workspace: ${p}`);
14
+ }
15
+ if (!allowRoot && target === base && String(p || '') !== '.' && String(p || '') !== '') {
16
+ throw new Error('Refusing to use the workspace root as a file path.');
17
+ }
18
+ return target;
19
+ }
20
+
21
+ function ok({ command, stdout = '', cwd }) {
22
+ return toolResult({ success: true, command, stdout, stderr: '', exitCode: 0, cwd });
23
+ }
24
+
25
+ function fail({ command, stderr, exitCode = 1, cwd }) {
26
+ return toolResult({ success: false, command, stdout: '', stderr, exitCode, cwd });
27
+ }
28
+
29
+ export function createFile(cwd, path, content = '') {
30
+ if (!path || typeof path !== 'string' || path.trim() === '') {
31
+ return fail({ command: 'create_file', stderr: 'Missing file path.', cwd });
32
+ }
33
+ try {
34
+ const target = resolveWithin(cwd, path);
35
+ const dir = resolve(target, '..');
36
+ mkdirSync(dir, { recursive: true });
37
+ writeFileSync(target, String(content ?? ''));
38
+ return ok({ command: `create_file("${path}")`, stdout: `created ${path} (${String(content ?? '').length} bytes)`, cwd });
39
+ } catch (err) {
40
+ return fail({ command: `create_file("${path}")`, stderr: err.message, cwd });
41
+ }
42
+ }
43
+
44
+ export function createDirectory(cwd, path) {
45
+ if (!path || typeof path !== 'string' || path.trim() === '') {
46
+ return fail({ command: 'create_directory', stderr: 'Missing directory path.', cwd });
47
+ }
48
+ try {
49
+ const target = resolveWithin(cwd, path, { allowRoot: true });
50
+ if (target === resolve(cwd)) {
51
+ return ok({ command: `create_directory("${path}")`, stdout: `already at workspace root`, cwd });
52
+ }
53
+ mkdirSync(target, { recursive: true });
54
+ return ok({ command: `create_directory("${path}")`, stdout: `created directory ${path}`, cwd });
55
+ } catch (err) {
56
+ return fail({ command: `create_directory("${path}")`, stderr: err.message, cwd });
57
+ }
58
+ }
59
+
60
+ export function readFile(cwd, path) {
61
+ if (!path || typeof path !== 'string' || path.trim() === '') {
62
+ return fail({ command: 'read_file', stderr: 'Missing file path.', cwd });
63
+ }
64
+ try {
65
+ const target = resolveWithin(cwd, path);
66
+ const content = readFileSync(target, 'utf-8');
67
+ return ok({ command: `read_file("${path}")`, stdout: content, cwd });
68
+ } catch (err) {
69
+ return fail({ command: `read_file("${path}")`, stderr: err.message, cwd });
70
+ }
71
+ }
72
+
73
+ export function listDirectory(cwd, path = '.') {
74
+ try {
75
+ const target = resolveWithin(cwd, path, { allowRoot: true });
76
+ const entries = readdirSync(target, { withFileTypes: true });
77
+ const list = entries
78
+ .map((e) => (e.isDirectory() ? `${e.name}/` : e.name))
79
+ .sort()
80
+ .join('\n');
81
+ return ok({ command: `list_directory("${path}")`, stdout: list || '(empty)', cwd });
82
+ } catch (err) {
83
+ return fail({ command: `list_directory("${path}")`, stderr: err.message, cwd });
84
+ }
85
+ }
86
+
87
+ export function deleteFile(cwd, path) {
88
+ if (!path || typeof path !== 'string' || path.trim() === '') {
89
+ return fail({ command: 'delete_file', stderr: 'Missing path.', cwd });
90
+ }
91
+ try {
92
+ const target = resolveWithin(cwd, path);
93
+ if (resolve(cwd) === target) {
94
+ return fail({ command: `delete_file("${path}")`, stderr: 'Refusing to delete the workspace root.', cwd });
95
+ }
96
+ if (!existsSync(target)) {
97
+ return fail({ command: `delete_file("${path}")`, stderr: `No such file or directory: ${path}`, exitCode: 1, cwd });
98
+ }
99
+ rmSync(target, { recursive: true, force: true });
100
+ return ok({ command: `delete_file("${path}")`, stdout: `removed ${path}`, cwd });
101
+ } catch (err) {
102
+ return fail({ command: `delete_file("${path}")`, stderr: err.message, cwd });
103
+ }
104
+ }
105
+
106
+ // Structured tool registry shared with the agent loop.
107
+ export const TOOL_EXECUTORS = {
108
+ run_command: (executor, params) => executor.runCommand(params.command),
109
+ create_file: (executor, params) => createFile(executor.cwd, params.path, params.content),
110
+ create_directory: (executor, params) => createDirectory(executor.cwd, params.path),
111
+ read_file: (executor, params) => readFile(executor.cwd, params.path),
112
+ list_directory: (executor, params) => listDirectory(executor.cwd, params.path || '.'),
113
+ delete_file: (executor, params) => deleteFile(executor.cwd, params.path),
114
+ };
115
+
116
+ export function makeExecutor({ cwd, timeoutMs } = {}) {
117
+ return new SafeCommandExecutor({ cwd, timeoutMs });
118
+ }
119
+
120
+ export function dispatchTool(executor, call) {
121
+ const fn = TOOL_EXECUTORS[call && call.name];
122
+ if (!fn) {
123
+ return toolResult({
124
+ success: false,
125
+ command: `${call && call.name ? call.name : '(unknown tool)'}`,
126
+ stderr: `Unknown tool ${call && call.name}.`,
127
+ cwd: executor.cwd,
128
+ });
129
+ }
130
+ return fn(executor, call.params || {});
131
+ }
package/src/config.js CHANGED
@@ -1,24 +1,33 @@
1
1
  import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'fs';
2
2
  import { join } from 'path';
3
3
  import { homedir } from 'os';
4
+ import { Catalog } from './catalog.js';
4
5
 
5
- const CONFIG_DIR = join(homedir(), '.config', 'vierrataleai');
6
- const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
6
+ function configPaths() {
7
+ const dir = process.env.VIERRATALE_CONFIG_DIR
8
+ ? join(process.env.VIERRATALE_CONFIG_DIR)
9
+ : join(homedir(), '.config', 'vierrataleai');
10
+ return { dir, file: join(dir, 'config.json') };
11
+ }
7
12
 
8
13
  const DEFAULTS = {
9
14
  provider: 'auto',
10
- model: 'vierratale-fast',
15
+ model: 'VRTL-2.fast',
11
16
  engineHost: 'http://127.0.0.1:11434',
12
17
  numCtx: 2048,
13
18
  temperature: 0.7,
14
19
  maxTokens: 4096,
15
20
  keepAlive: '5m',
21
+ numThreads: 6,
22
+ commandTimeoutMs: 60000,
23
+ planTimeoutMs: 45000,
16
24
  };
17
25
 
18
26
  function loadConfigFile() {
19
27
  try {
20
- if (existsSync(CONFIG_FILE)) {
21
- const raw = readFileSync(CONFIG_FILE, 'utf-8');
28
+ const { file } = configPaths();
29
+ if (existsSync(file)) {
30
+ const raw = readFileSync(file, 'utf-8');
22
31
  return JSON.parse(raw);
23
32
  }
24
33
  } catch {}
@@ -44,11 +53,14 @@ export const Config = {
44
53
  temperature: parseFloat(getEnv('VIERRATALE_TEMPERATURE', String(file.temperature ?? DEFAULTS.temperature))),
45
54
  maxTokens: parseInt(getEnv('VIERRATALE_MAX_TOKENS', String(file.maxTokens ?? DEFAULTS.maxTokens))),
46
55
  keepAlive: getEnv('VIERRATALE_KEEP_ALIVE', file.keepAlive ?? DEFAULTS.keepAlive),
56
+ numThreads: parseInt(getEnv('VIERRATALE_NUM_THREADS', String(file.numThreads ?? DEFAULTS.numThreads))),
47
57
  openaiApiKey: getEnv('OPENAI_API_KEY', file.openaiApiKey || ''),
48
58
  anthropicApiKey: getEnv('ANTHROPIC_API_KEY', file.anthropicApiKey || ''),
49
59
  geminiApiKey: getEnv('GEMINI_API_KEY', file.geminiApiKey || ''),
50
60
  systemPrompt: file.systemPrompt || '',
51
61
  providerModels: file.providerModels || {},
62
+ commandTimeoutMs: parseInt(getEnv('VIERRATALE_COMMAND_TIMEOUT_MS', String(file.commandTimeoutMs ?? DEFAULTS.commandTimeoutMs))),
63
+ planTimeoutMs: parseInt(getEnv('VIERRATALE_PLAN_TIMEOUT_MS', String(file.planTimeoutMs ?? DEFAULTS.planTimeoutMs))),
52
64
  };
53
65
  return this._config;
54
66
  },
@@ -76,20 +88,22 @@ export const Config = {
76
88
  },
77
89
 
78
90
  getEffectiveModel(provider) {
79
- return this.getProviderModel(provider) || this.get('model');
91
+ const raw = this.getProviderModel(provider) || this.get('model');
92
+ return Catalog.normalize(raw);
80
93
  },
81
94
 
82
95
  save(overrides = {}) {
83
- if (!existsSync(CONFIG_DIR)) {
84
- mkdirSync(CONFIG_DIR, { recursive: true });
96
+ const { dir, file } = configPaths();
97
+ if (!existsSync(dir)) {
98
+ mkdirSync(dir, { recursive: true });
85
99
  }
86
100
  const current = this._config || DEFAULTS;
87
101
  const merged = { ...current, ...overrides };
88
- writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2));
102
+ writeFileSync(file, JSON.stringify(merged, null, 2));
89
103
  this._config = merged;
90
104
  },
91
105
 
92
106
  getConfigDir() {
93
- return CONFIG_DIR;
107
+ return configPaths().dir;
94
108
  },
95
109
  };
package/src/index.js CHANGED
@@ -4,3 +4,4 @@ export { Installer } from './installer.js';
4
4
  export { CortexProvider } from './providers/cortex.js';
5
5
  export { OpenAIProvider } from './providers/openai.js';
6
6
  export { Branding } from './ui/branding.js';
7
+ export { FileWriter } from './utils/filewriter.js';
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
 
@@ -54,6 +56,88 @@ async function getInstalledModels(host) {
54
56
  }
55
57
  }
56
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
+
57
141
  export const Installer = {
58
142
  async pullModel(host, model) {
59
143
  return pullModel(host, model);
@@ -63,6 +147,17 @@ export const Installer = {
63
147
  return getInstalledModels(host);
64
148
  },
65
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
+
66
161
  async ensure() {
67
162
  const host = Config.get('engineHost');
68
163
 
@@ -89,7 +184,8 @@ export const Installer = {
89
184
 
90
185
  const realModel = Catalog.getRealModel(Config.get('model'));
91
186
  const installed = await getInstalledModels(host);
92
- const needsPull = !installed.includes(realModel);
187
+ const resolved = chooseModel(installed, realModel);
188
+ const needsPull = resolved.model == null;
93
189
 
94
190
  if (needsPull) {
95
191
  await pullModel(host, realModel);
@@ -102,4 +198,36 @@ export const Installer = {
102
198
  const host = Config.get('engineHost');
103
199
  return isEngineRunning(host);
104
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 };
232
+ },
105
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.