@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.
@@ -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,153 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
2
+ import { resolve, sep } from 'path';
3
+ import { toolResult, SafeCommandExecutor } from './executor.js';
4
+ import { Downloader } from '../utils/downloader.js';
5
+ import { Config } from '../config.js';
6
+
7
+ // Resolve a path relative to the workspace and require it to stay inside.
8
+ // Throws if the path escapes the workspace (or hits the workspace root for
9
+ // destructive operations).
10
+ export function resolveWithin(cwd, p, { allowRoot = false } = {}) {
11
+ const base = resolve(cwd);
12
+ const target = resolve(base, String(p || ''));
13
+ const rootOrEscape = target === base || !target.startsWith(base + sep);
14
+ if (target !== base && !target.startsWith(base + sep)) {
15
+ throw new Error(`Refusing to access a path outside the workspace: ${p}`);
16
+ }
17
+ if (!allowRoot && target === base && String(p || '') !== '.' && String(p || '') !== '') {
18
+ throw new Error('Refusing to use the workspace root as a file path.');
19
+ }
20
+ return target;
21
+ }
22
+
23
+ function ok({ command, stdout = '', cwd }) {
24
+ return toolResult({ success: true, command, stdout, stderr: '', exitCode: 0, cwd });
25
+ }
26
+
27
+ function fail({ command, stderr, exitCode = 1, cwd }) {
28
+ return toolResult({ success: false, command, stdout: '', stderr, exitCode, cwd });
29
+ }
30
+
31
+ export function createFile(cwd, path, content = '') {
32
+ if (!path || typeof path !== 'string' || path.trim() === '') {
33
+ return fail({ command: 'create_file', stderr: 'Missing file path.', cwd });
34
+ }
35
+ try {
36
+ const target = resolveWithin(cwd, path);
37
+ const dir = resolve(target, '..');
38
+ mkdirSync(dir, { recursive: true });
39
+ writeFileSync(target, String(content ?? ''));
40
+ return ok({ command: `create_file("${path}")`, stdout: `created ${path} (${String(content ?? '').length} bytes)`, cwd });
41
+ } catch (err) {
42
+ return fail({ command: `create_file("${path}")`, stderr: err.message, cwd });
43
+ }
44
+ }
45
+
46
+ export function createDirectory(cwd, path) {
47
+ if (!path || typeof path !== 'string' || path.trim() === '') {
48
+ return fail({ command: 'create_directory', stderr: 'Missing directory path.', cwd });
49
+ }
50
+ try {
51
+ const target = resolveWithin(cwd, path, { allowRoot: true });
52
+ if (target === resolve(cwd)) {
53
+ return ok({ command: `create_directory("${path}")`, stdout: `already at workspace root`, cwd });
54
+ }
55
+ mkdirSync(target, { recursive: true });
56
+ return ok({ command: `create_directory("${path}")`, stdout: `created directory ${path}`, cwd });
57
+ } catch (err) {
58
+ return fail({ command: `create_directory("${path}")`, stderr: err.message, cwd });
59
+ }
60
+ }
61
+
62
+ export function readFile(cwd, path) {
63
+ if (!path || typeof path !== 'string' || path.trim() === '') {
64
+ return fail({ command: 'read_file', stderr: 'Missing file path.', cwd });
65
+ }
66
+ try {
67
+ const target = resolveWithin(cwd, path);
68
+ const content = readFileSync(target, 'utf-8');
69
+ return ok({ command: `read_file("${path}")`, stdout: content, cwd });
70
+ } catch (err) {
71
+ return fail({ command: `read_file("${path}")`, stderr: err.message, cwd });
72
+ }
73
+ }
74
+
75
+ export function listDirectory(cwd, path = '.') {
76
+ try {
77
+ const target = resolveWithin(cwd, path, { allowRoot: true });
78
+ const entries = readdirSync(target, { withFileTypes: true });
79
+ const list = entries
80
+ .map((e) => (e.isDirectory() ? `${e.name}/` : e.name))
81
+ .sort()
82
+ .join('\n');
83
+ return ok({ command: `list_directory("${path}")`, stdout: list || '(empty)', cwd });
84
+ } catch (err) {
85
+ return fail({ command: `list_directory("${path}")`, stderr: err.message, cwd });
86
+ }
87
+ }
88
+
89
+ export function deleteFile(cwd, path) {
90
+ if (!path || typeof path !== 'string' || path.trim() === '') {
91
+ return fail({ command: 'delete_file', stderr: 'Missing path.', cwd });
92
+ }
93
+ try {
94
+ const target = resolveWithin(cwd, path);
95
+ if (resolve(cwd) === target) {
96
+ return fail({ command: `delete_file("${path}")`, stderr: 'Refusing to delete the workspace root.', cwd });
97
+ }
98
+ if (!existsSync(target)) {
99
+ return fail({ command: `delete_file("${path}")`, stderr: `No such file or directory: ${path}`, exitCode: 1, cwd });
100
+ }
101
+ rmSync(target, { recursive: true, force: true });
102
+ return ok({ command: `delete_file("${path}")`, stdout: `removed ${path}`, cwd });
103
+ } catch (err) {
104
+ return fail({ command: `delete_file("${path}")`, stderr: err.message, cwd });
105
+ }
106
+ }
107
+
108
+ export async function downloadUrl(cwd, url, dir) {
109
+ if (!url || typeof url !== 'string' || !/^https?:\/\//i.test(url.trim())) {
110
+ return fail({ command: 'download_url', stderr: 'Invalid URL. Provide a valid http(s) address.', cwd });
111
+ }
112
+ const targetDir = dir ? resolveWithin(cwd, dir, { allowRoot: true }) : Config.get('downloadDir');
113
+ try {
114
+ const res = await Downloader.download(url.trim(), { dir: targetDir });
115
+ const kind = res.kind || 'file';
116
+ const note = res.extracted ? '\n(contents extracted into that folder)' : '';
117
+ return ok({
118
+ command: `download_url("${url}")`,
119
+ stdout: `downloaded ${kind} → ${res.path}${note}`,
120
+ cwd,
121
+ });
122
+ } catch (err) {
123
+ return fail({ command: 'download_url', stderr: err.message, cwd });
124
+ }
125
+ }
126
+
127
+ // Structured tool registry shared with the agent loop.
128
+ export const TOOL_EXECUTORS = {
129
+ run_command: (executor, params) => executor.runCommand(params.command),
130
+ create_file: (executor, params) => createFile(executor.cwd, params.path, params.content),
131
+ create_directory: (executor, params) => createDirectory(executor.cwd, params.path),
132
+ read_file: (executor, params) => readFile(executor.cwd, params.path),
133
+ list_directory: (executor, params) => listDirectory(executor.cwd, params.path || '.'),
134
+ delete_file: (executor, params) => deleteFile(executor.cwd, params.path),
135
+ download_url: (executor, params) => downloadUrl(executor.cwd, params.url, params.dir),
136
+ };
137
+
138
+ export function makeExecutor({ cwd, timeoutMs } = {}) {
139
+ return new SafeCommandExecutor({ cwd, timeoutMs });
140
+ }
141
+
142
+ export function dispatchTool(executor, call) {
143
+ const fn = TOOL_EXECUTORS[call && call.name];
144
+ if (!fn) {
145
+ return toolResult({
146
+ success: false,
147
+ command: `${call && call.name ? call.name : '(unknown tool)'}`,
148
+ stderr: `Unknown tool ${call && call.name}.`,
149
+ cwd: executor.cwd,
150
+ });
151
+ }
152
+ return fn(executor, call.params || {});
153
+ }
package/src/config.js CHANGED
@@ -1,23 +1,34 @@
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',
11
- ollamaHost: 'http://127.0.0.1:11434',
12
- numCtx: 8192,
15
+ model: 'VRTL-2.fast',
16
+ engineHost: 'http://127.0.0.1:11434',
17
+ numCtx: 2048,
13
18
  temperature: 0.7,
14
19
  maxTokens: 4096,
20
+ keepAlive: '5m',
21
+ numThreads: 8,
22
+ commandTimeoutMs: 60000,
23
+ planTimeoutMs: 45000,
24
+ downloadDir: join(homedir(), 'Downloads'),
15
25
  };
16
26
 
17
27
  function loadConfigFile() {
18
28
  try {
19
- if (existsSync(CONFIG_FILE)) {
20
- const raw = readFileSync(CONFIG_FILE, 'utf-8');
29
+ const { file } = configPaths();
30
+ if (existsSync(file)) {
31
+ const raw = readFileSync(file, 'utf-8');
21
32
  return JSON.parse(raw);
22
33
  }
23
34
  } catch {}
@@ -33,15 +44,25 @@ export const Config = {
33
44
 
34
45
  load() {
35
46
  const file = loadConfigFile();
47
+ // Migrate legacy 'cortexHost' key to 'engineHost' if present.
48
+ const engineHost = file.engineHost || file.cortexHost || DEFAULTS.engineHost;
36
49
  this._config = {
37
50
  provider: getEnv('VIERRATALE_PROVIDER', file.provider || DEFAULTS.provider),
38
51
  model: getEnv('VIERRATALE_MODEL', file.model || DEFAULTS.model),
39
- ollamaHost: getEnv('VIERRATALE_OLLAMA_HOST', file.ollamaHost || DEFAULTS.ollamaHost),
40
- numCtx: parseInt(getEnv('VIERRATALE_NUM_CTX', String(file.numCtx || DEFAULTS.numCtx))),
41
- temperature: parseFloat(getEnv('VIERRATALE_TEMPERATURE', String(file.temperature || DEFAULTS.temperature))),
42
- maxTokens: parseInt(getEnv('VIERRATALE_MAX_TOKENS', String(file.maxTokens || DEFAULTS.maxTokens))),
52
+ engineHost: getEnv('VIERRATALE_ENGINE_HOST', engineHost),
53
+ numCtx: parseInt(getEnv('VIERRATALE_NUM_CTX', String(file.numCtx ?? DEFAULTS.numCtx))),
54
+ temperature: parseFloat(getEnv('VIERRATALE_TEMPERATURE', String(file.temperature ?? DEFAULTS.temperature))),
55
+ maxTokens: parseInt(getEnv('VIERRATALE_MAX_TOKENS', String(file.maxTokens ?? DEFAULTS.maxTokens))),
56
+ keepAlive: getEnv('VIERRATALE_KEEP_ALIVE', file.keepAlive ?? DEFAULTS.keepAlive),
57
+ numThreads: parseInt(getEnv('VIERRATALE_NUM_THREADS', String(file.numThreads ?? DEFAULTS.numThreads))),
43
58
  openaiApiKey: getEnv('OPENAI_API_KEY', file.openaiApiKey || ''),
44
59
  anthropicApiKey: getEnv('ANTHROPIC_API_KEY', file.anthropicApiKey || ''),
60
+ geminiApiKey: getEnv('GEMINI_API_KEY', file.geminiApiKey || ''),
61
+ systemPrompt: file.systemPrompt || '',
62
+ providerModels: file.providerModels || {},
63
+ downloadDir: getEnv('VIERRATALE_DOWNLOAD_DIR', file.downloadDir ?? DEFAULTS.downloadDir),
64
+ commandTimeoutMs: parseInt(getEnv('VIERRATALE_COMMAND_TIMEOUT_MS', String(file.commandTimeoutMs ?? DEFAULTS.commandTimeoutMs))),
65
+ planTimeoutMs: parseInt(getEnv('VIERRATALE_PLAN_TIMEOUT_MS', String(file.planTimeoutMs ?? DEFAULTS.planTimeoutMs))),
45
66
  };
46
67
  return this._config;
47
68
  },
@@ -56,17 +77,35 @@ export const Config = {
56
77
  return { ...this._config };
57
78
  },
58
79
 
80
+ getProviderModel(provider) {
81
+ if (!this._config) this.load();
82
+ return this._config.providerModels?.[provider] || null;
83
+ },
84
+
85
+ setProviderModel(provider, model) {
86
+ if (!this._config) this.load();
87
+ this._config.providerModels = this._config.providerModels || {};
88
+ this._config.providerModels[provider] = model;
89
+ this.save();
90
+ },
91
+
92
+ getEffectiveModel(provider) {
93
+ const raw = this.getProviderModel(provider) || this.get('model');
94
+ return Catalog.normalize(raw);
95
+ },
96
+
59
97
  save(overrides = {}) {
60
- if (!existsSync(CONFIG_DIR)) {
61
- mkdirSync(CONFIG_DIR, { recursive: true });
98
+ const { dir, file } = configPaths();
99
+ if (!existsSync(dir)) {
100
+ mkdirSync(dir, { recursive: true });
62
101
  }
63
102
  const current = this._config || DEFAULTS;
64
103
  const merged = { ...current, ...overrides };
65
- writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2));
104
+ writeFileSync(file, JSON.stringify(merged, null, 2));
66
105
  this._config = merged;
67
106
  },
68
107
 
69
108
  getConfigDir() {
70
- return CONFIG_DIR;
109
+ return configPaths().dir;
71
110
  },
72
111
  };
package/src/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export { Catalog } from './catalog.js';
2
2
  export { Config } from './config.js';
3
3
  export { Installer } from './installer.js';
4
- export { OllamaProvider } from './providers/ollama.js';
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';