@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.
- package/README.md +120 -16
- package/bin/vierrataleai.js +74 -1
- package/package.json +7 -1
- package/src/catalog.js +78 -49
- package/src/cli.js +864 -103
- package/src/cmd/agent.js +1695 -0
- package/src/cmd/executor.js +257 -0
- package/src/cmd/tools.js +131 -0
- package/src/config.js +24 -10
- package/src/index.js +1 -0
- package/src/installer.js +130 -2
- package/src/prompts/system.json +2 -2
- package/src/prompts/system.md +42 -0
- package/src/providers/anthropic.js +65 -34
- package/src/providers/base.js +10 -0
- package/src/providers/cortex.js +223 -40
- package/src/providers/gemini.js +62 -31
- package/src/providers/openai.js +64 -33
- package/src/session.js +195 -15
- package/src/ui/branding.js +1 -1
- package/src/ui/chatbox.js +271 -0
- package/src/utils/filewriter.js +97 -0
- package/src/utils/intents.js +61 -1
- package/src/utils/logger.js +67 -0
- package/src/utils/math.js +142 -0
- package/build.sh +0 -17
|
@@ -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
|
+
}
|
package/src/cmd/tools.js
ADDED
|
@@ -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
|
-
|
|
6
|
-
const
|
|
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: '
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
91
|
+
const raw = this.getProviderModel(provider) || this.get('model');
|
|
92
|
+
return Catalog.normalize(raw);
|
|
80
93
|
},
|
|
81
94
|
|
|
82
95
|
save(overrides = {}) {
|
|
83
|
-
|
|
84
|
-
|
|
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(
|
|
102
|
+
writeFileSync(file, JSON.stringify(merged, null, 2));
|
|
89
103
|
this._config = merged;
|
|
90
104
|
},
|
|
91
105
|
|
|
92
106
|
getConfigDir() {
|
|
93
|
-
return
|
|
107
|
+
return configPaths().dir;
|
|
94
108
|
},
|
|
95
109
|
};
|
package/src/index.js
CHANGED
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
|
|
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
|
};
|
package/src/prompts/system.json
CHANGED
|
@@ -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
|
+
}
|
package/src/prompts/system.md
CHANGED
|
@@ -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.
|