@vierratale/ai 0.1.0-beta.12 → 0.1.0-beta.14
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/LICENSE +21 -0
- package/README.md +12 -3
- package/bin/vierrataleai.js +13 -11
- package/package.json +22 -22
- package/src/cli.js +8 -6
- package/src/cmd/agent.js +28 -2
- package/src/cmd/executor.js +14 -0
- package/src/cmd/tools.js +19 -2
- package/src/engine.js +150 -0
- package/src/index.js +1 -1
- package/src/ui/branding.js +1 -1
- package/src/utils/filetree.js +44 -0
- package/src/installer.js +0 -233
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vierratale
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -4,11 +4,20 @@ Intelligent terminal assistant that runs AI models locally and in the cloud.
|
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
7
|
+
Install requirements and run from source:
|
|
8
|
+
|
|
7
9
|
```bash
|
|
8
|
-
|
|
9
|
-
|
|
10
|
+
git clone <your-repo-url>/vierratale-ai.git
|
|
11
|
+
cd vierratale-ai
|
|
12
|
+
|
|
13
|
+
./install.sh --node # installs requirements + links the commands
|
|
14
|
+
vierratale # start chatting
|
|
15
|
+
```
|
|
10
16
|
|
|
11
|
-
|
|
17
|
+
Or run directly from npm:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install -g @vierratale/ai
|
|
12
21
|
npx @vierratale/ai
|
|
13
22
|
```
|
|
14
23
|
|
package/bin/vierrataleai.js
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// VierrataleAI launcher + installer.
|
|
3
3
|
//
|
|
4
|
-
// Running without flags starts the assistant
|
|
5
|
-
//
|
|
6
|
-
//
|
|
4
|
+
// Running without flags starts the assistant and auto-detects dependencies:
|
|
5
|
+
// if the AI engine (ollama) or the model is missing, they are installed first.
|
|
6
|
+
// Running with --install (or --setup / install) additionally creates a global
|
|
7
|
+
// "vierrataleai" / "vierratale" command, then starts the assistant.
|
|
7
8
|
//
|
|
8
9
|
// Installation progress is always the fake animated bar — real engine/model
|
|
9
10
|
// output is deliberately hidden so first-run feels quick and clean.
|
|
10
11
|
import { run } from '../src/cli.js';
|
|
11
|
-
import {
|
|
12
|
+
import { Engine } from '../src/engine.js';
|
|
12
13
|
import { Config } from '../src/config.js';
|
|
13
14
|
import { Branding } from '../src/ui/branding.js';
|
|
14
|
-
import { Terminal } from '../src/ui/terminal.js';
|
|
15
15
|
|
|
16
16
|
const C = Branding.colors;
|
|
17
17
|
|
|
@@ -23,6 +23,8 @@ const INSTALL_STEPS = [
|
|
|
23
23
|
'Optimizing for this device',
|
|
24
24
|
];
|
|
25
25
|
|
|
26
|
+
const READY_STEPS = ['Checking engine', 'Installing dependencies', 'Downloading model', 'Optimizing'];
|
|
27
|
+
|
|
26
28
|
function delay(ms) {
|
|
27
29
|
return new Promise((r) => setTimeout(r, ms));
|
|
28
30
|
}
|
|
@@ -53,17 +55,17 @@ async function fakeProgress(steps, work) {
|
|
|
53
55
|
async function main() {
|
|
54
56
|
const args = process.argv.slice(2);
|
|
55
57
|
const doInstall = args.includes('--install') || args.includes('--setup') || args.includes('install');
|
|
58
|
+
const quick = args.includes('--version') || args.includes('-v') || args.includes('--help') || args.includes('-h');
|
|
56
59
|
|
|
57
60
|
Config.load();
|
|
58
61
|
|
|
59
62
|
if (doInstall) {
|
|
60
63
|
console.log(`\n ${C.bold}${C.primary}${Branding.APP_NAME} ${Branding.VERSION}${C.reset} ${C.dim}— installer${C.reset}\n`);
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
64
|
+
await fakeProgress(INSTALL_STEPS, Engine.ensure());
|
|
65
|
+
console.log();
|
|
66
|
+
} else if (!quick) {
|
|
67
|
+
// Auto-detect: install anything that's missing (engine, model) before starting.
|
|
68
|
+
await fakeProgress(READY_STEPS, Engine.ensure());
|
|
67
69
|
console.log();
|
|
68
70
|
}
|
|
69
71
|
|
package/package.json
CHANGED
|
@@ -1,31 +1,31 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vierratale/ai",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.14",
|
|
4
4
|
"description": "VierrataleAI - Intelligent terminal assistant",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"
|
|
7
|
-
|
|
8
|
-
"
|
|
9
|
-
"src/",
|
|
10
|
-
"README.md"
|
|
11
|
-
],
|
|
12
|
-
"bin": {
|
|
13
|
-
"vierrataleai": "./bin/vierrataleai.js"
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=18"
|
|
14
9
|
},
|
|
15
|
-
"
|
|
16
|
-
|
|
17
|
-
"
|
|
10
|
+
"bin": {
|
|
11
|
+
"vierrataleai": "./bin/vierrataleai.js",
|
|
12
|
+
"vierratale": "./bin/vierrataleai.js"
|
|
18
13
|
},
|
|
19
14
|
"scripts": {
|
|
20
|
-
"start": "node bin/vierrataleai.js",
|
|
21
|
-
"test": "node --test \"test/*.test.js\"",
|
|
22
15
|
"postinstall": "node scripts/postinstall.js"
|
|
23
16
|
},
|
|
24
|
-
"
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
"
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
17
|
+
"files": [
|
|
18
|
+
"bin",
|
|
19
|
+
"src",
|
|
20
|
+
"scripts",
|
|
21
|
+
"LICENSE",
|
|
22
|
+
"README.md"
|
|
23
|
+
],
|
|
24
|
+
"keywords": [
|
|
25
|
+
"ai",
|
|
26
|
+
"chatbot",
|
|
27
|
+
"terminal",
|
|
28
|
+
"assistant",
|
|
29
|
+
"llm"
|
|
30
|
+
]
|
|
31
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -2,7 +2,7 @@ import { execFile } from 'child_process';
|
|
|
2
2
|
import { promisify } from 'util';
|
|
3
3
|
import { Config } from './config.js';
|
|
4
4
|
import { Catalog } from './catalog.js';
|
|
5
|
-
import {
|
|
5
|
+
import { Engine } from './engine.js';
|
|
6
6
|
import { ProviderFactory } from './providers/index.js';
|
|
7
7
|
import { Branding } from './ui/branding.js';
|
|
8
8
|
import { Terminal } from './ui/terminal.js';
|
|
@@ -47,6 +47,8 @@ Options:
|
|
|
47
47
|
--version, -v Show version
|
|
48
48
|
--help, -h Show this help
|
|
49
49
|
|
|
50
|
+
Missing dependencies (engine + model) are installed automatically on start.
|
|
51
|
+
|
|
50
52
|
Commands (in chat):
|
|
51
53
|
/help Show commands
|
|
52
54
|
/model [name] Switch model
|
|
@@ -809,7 +811,7 @@ async function chat(provider, systemPrompt) {
|
|
|
809
811
|
: Catalog.getCloudModels();
|
|
810
812
|
const installedSet = new Set();
|
|
811
813
|
if (provider.isLocal) {
|
|
812
|
-
for (const m of await
|
|
814
|
+
for (const m of await Engine.getInstalledModels(Config.get('engineHost'))) {
|
|
813
815
|
installedSet.add(m);
|
|
814
816
|
}
|
|
815
817
|
}
|
|
@@ -1112,18 +1114,18 @@ async function ensureLocalModel(modelName, notify) {
|
|
|
1112
1114
|
// dead-ends while at least one local model exists.
|
|
1113
1115
|
async function ensureRawLocalModel(realModel, notify) {
|
|
1114
1116
|
const host = Config.get('engineHost');
|
|
1115
|
-
const resolved = await
|
|
1117
|
+
const resolved = await Engine.resolveModel(host, realModel);
|
|
1116
1118
|
if (resolved.model) {
|
|
1117
1119
|
return { ok: true, model: resolved.model, reason: resolved.reason };
|
|
1118
1120
|
}
|
|
1119
1121
|
if (notify) notify(`Downloading ${realModel}… (this can take a while)`);
|
|
1120
|
-
const pulled = await
|
|
1122
|
+
const pulled = await Engine.pullModel(host, realModel);
|
|
1121
1123
|
if (pulled) {
|
|
1122
1124
|
if (notify) notify(`Model ${realModel} ready.`);
|
|
1123
1125
|
return { ok: true, model: realModel, reason: null };
|
|
1124
1126
|
}
|
|
1125
1127
|
if (notify) notify(`Could not download ${realModel}.`);
|
|
1126
|
-
const first = await
|
|
1128
|
+
const first = await Engine.firstInstalled(host);
|
|
1127
1129
|
if (first) {
|
|
1128
1130
|
return { ok: true, model: first, reason: `Could not download ${realModel}; using installed ${first}` };
|
|
1129
1131
|
}
|
|
@@ -1262,7 +1264,7 @@ export async function run() {
|
|
|
1262
1264
|
|
|
1263
1265
|
await Terminal.showProgress(
|
|
1264
1266
|
['Checking engine', 'Installing dependencies', 'Downloading model', 'Optimizing'],
|
|
1265
|
-
() =>
|
|
1267
|
+
() => Engine.ensure()
|
|
1266
1268
|
);
|
|
1267
1269
|
|
|
1268
1270
|
await ensureLocalModel(Config.get('model'));
|
package/src/cmd/agent.js
CHANGED
|
@@ -3,7 +3,8 @@ import { readFileSync } from 'node:fs';
|
|
|
3
3
|
import { join } from 'path';
|
|
4
4
|
import { Config } from '../config.js';
|
|
5
5
|
import { logger } from '../utils/logger.js';
|
|
6
|
-
import {
|
|
6
|
+
import { renderFileTree } from '../utils/filetree.js';
|
|
7
|
+
import { makeExecutor, dispatchTool, TOOL_EXECUTORS, pickPackageManager } from './tools.js';
|
|
7
8
|
|
|
8
9
|
const MAX_PLANNING_ROUNDS = 3;
|
|
9
10
|
const MAX_STEPS = 8;
|
|
@@ -25,12 +26,13 @@ Reply with EXACTLY ONE JSON array of the tool calls needed to do the job, in ord
|
|
|
25
26
|
{"name":"todo_clear"}
|
|
26
27
|
|
|
27
28
|
Rules:
|
|
28
|
-
- run_command runs WITHOUT a shell. Allowed commands: mkdir touch ls pwd cat cp mv rm npm npx node python python3 git echo printf.
|
|
29
|
+
- run_command runs WITHOUT a shell. Allowed commands: mkdir touch ls pwd cat cp mv rm npm npx node python python3 git echo printf apt apt-get pkg.
|
|
29
30
|
- run_command supports arguments with spaces (quote them). No shell operators (&& | > ; etc.), no wildcards for rm, no inline -e/-c code for node/python, no paths outside the workspace.
|
|
30
31
|
- To create a file containing code or text, ALWAYS use create_file (never echo/cat > …). Pass the full content in "content".
|
|
31
32
|
- Prefer create_file over redirecting output.
|
|
32
33
|
- If you need to create a folder first, plan create_directory before create_file inside it.
|
|
33
34
|
- To download a file, web asset, archive or folder listing from the web, use download_url with the full URL. dir is optional (defaults to ~/Downloads).
|
|
35
|
+
- To install a system package the user asked for, use run_command with pkg (Termux/Android) or apt/apt-get (Linux), e.g. {"name":"run_command","command":"pkg install -y curl wget"}. The -y/--yes confirm flag is added automatically — never use && or sudo.
|
|
34
36
|
- For multi-step work, track your progress with todo_add/todo_list/todo_update (e.g. todo_add for each remaining subtask, todo_update to mark steps done).
|
|
35
37
|
- Code you create must be runnable. For Python servers bind to 127.0.0.1, read PORT from the environment (default 8000), and fall back to a free port (port 0) when the default is taken. HTML/CSS/JS must be self-contained and reference each other by relative filename.
|
|
36
38
|
- If NO tools are needed, reply exactly: NONE
|
|
@@ -207,6 +209,24 @@ export function heuristicPlan(requestText) {
|
|
|
207
209
|
return [{ name: 'run_command', params: { command: tail } }];
|
|
208
210
|
}
|
|
209
211
|
|
|
212
|
+
// System package installs: explicit "pkg/apt install …" and generic
|
|
213
|
+
// "install <pkg>" requests ("pasang curl", "install wget").
|
|
214
|
+
const pkgInstall = /\b(pkg|apt-get|apt)\s+(install|i|add)\s+(.+)$/i.exec(text);
|
|
215
|
+
if (pkgInstall) {
|
|
216
|
+
const pkgs = pkgInstall[3].trim().split(/\s+/).slice(0, 8).filter((p) => /^[a-z0-9][a-z0-9.+_~-]*$/i.test(p));
|
|
217
|
+
if (pkgs.length) return [{ name: 'run_command', params: { command: `${pkgInstall[1]} install ${pkgs.join(' ')}` } }];
|
|
218
|
+
}
|
|
219
|
+
const INSTALL_BLOCKED = new Set(['project', 'proyek', 'folder', 'direktori', 'file', 'berkas', 'code', 'kode', 'dependencies', 'dependensi', 'app', 'aplikasi', 'gui', 'the', 'a', 'an', 'for', 'to', 'in', 'on', 'from', 'with', 'and', 'dan', 'lalu', 'semua', 'ini', 'itu', 'now', 'please', 'tolong']);
|
|
220
|
+
const genericInstall = /\b(install|pasang|installkan|menginstall|menginstal|set\s*up|setup)\b/i.exec(text);
|
|
221
|
+
const devInstallSpeak = /node modules|modules|dependenc\w*|package\.json|requirements(\.txt)?|virtualenv|pip\b|cargo\b|go get\b/gi;
|
|
222
|
+
if (genericInstall && !/\b(npm|yarn|pnpm|npx|bun|pip|pip3|uv|conda|apt|pkg)\b/i.test(text) && !devInstallSpeak.test(text)) {
|
|
223
|
+
const target = text.match(/\b(install|pasang|installkan|menginstall|menginstal|set\s*up|setup)\b\s+(?:(the|a|an|package|paket|tool|alat|program|utility|latest)\s+)?([a-z0-9][a-z0-9.+_~-]{1,63})\b/i);
|
|
224
|
+
if (target && !INSTALL_BLOCKED.has(target[3].toLowerCase())) {
|
|
225
|
+
const manager = pickPackageManager();
|
|
226
|
+
return [{ name: 'run_command', params: { command: `${manager} install ${target[3]}` } }];
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
210
230
|
m = /\b(jalankan|run|execute|exec|kerjakan)\b\s+(node|python3?)\b\s*([A-Za-z0-9_.\/-]+(?:\.[A-Za-z0-9]+)?)?/i.exec(text);
|
|
211
231
|
if (m) {
|
|
212
232
|
const cmd = `${m[2]} ${m[3] || ''}`.trim();
|
|
@@ -1787,6 +1807,12 @@ export async function runWithTools({
|
|
|
1787
1807
|
}
|
|
1788
1808
|
|
|
1789
1809
|
if (results.length) {
|
|
1810
|
+
const madeFiles = results.some((r) => (r.name === 'create_file' || r.name === 'create_directory') && r.result.success);
|
|
1811
|
+
if (madeFiles) {
|
|
1812
|
+
const treeText = renderFileTree(cwd || process.cwd()).join('\n');
|
|
1813
|
+
logger.log('TREE', treeText.slice(0, 2500));
|
|
1814
|
+
if (chatUI) chatUI.notify(treeText);
|
|
1815
|
+
}
|
|
1790
1816
|
if (chatUI) {
|
|
1791
1817
|
chatUI.setStatus('');
|
|
1792
1818
|
chatUI.setThinking(true);
|
package/src/cmd/executor.js
CHANGED
|
@@ -4,8 +4,16 @@ import { dirname, resolve, sep } from 'path';
|
|
|
4
4
|
const ALLOWED_BINS = new Set([
|
|
5
5
|
'mkdir', 'touch', 'ls', 'pwd', 'cat', 'cp', 'mv', 'rm',
|
|
6
6
|
'npm', 'npx', 'node', 'python', 'python3', 'git', 'echo', 'printf',
|
|
7
|
+
'apt', 'apt-get', 'pkg',
|
|
7
8
|
]);
|
|
8
9
|
|
|
10
|
+
// Package-manager commands never run interactively (no TTY), so auto-append a
|
|
11
|
+
// confirm flag to mutating subcommands instead of letting apt/pkg block on a
|
|
12
|
+
// prompt. pkg is the Termux/Android wrapper, apt/apt-get the Linux ones.
|
|
13
|
+
const INSTALL_MANAGERS = new Set(['apt', 'apt-get', 'pkg']);
|
|
14
|
+
const INSTALL_SUBCOMMANDS = new Set(['install', 'upgrade', 'remove', 'autoremove', 'purge']);
|
|
15
|
+
const INSTALL_CONFIRM_FLAGS = /^-y$|^--yes$|^-dy$|^--assume-yes$/i;
|
|
16
|
+
|
|
9
17
|
const SHELL_META = /[;&|<>`$]|\$\{|\(\)|\|\||&&/;
|
|
10
18
|
|
|
11
19
|
// Bins whose *path arguments* must stay inside the workspace. Reads like `ls`
|
|
@@ -115,6 +123,12 @@ export class SafeCommandExecutor {
|
|
|
115
123
|
if (PATH_CONTAINED_BINS.has(bin)) {
|
|
116
124
|
this._checkPaths(bin, args);
|
|
117
125
|
}
|
|
126
|
+
if (INSTALL_MANAGERS.has(bin)) {
|
|
127
|
+
const subIdx = argv.findIndex((a) => INSTALL_SUBCOMMANDS.has(a));
|
|
128
|
+
if (subIdx !== -1 && !argv.some((a) => INSTALL_CONFIRM_FLAGS.test(a))) {
|
|
129
|
+
argv.splice(subIdx + 1, 0, '-y');
|
|
130
|
+
}
|
|
131
|
+
}
|
|
118
132
|
return argv;
|
|
119
133
|
}
|
|
120
134
|
|
package/src/cmd/tools.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
|
2
|
-
import { resolve, sep } from 'path';
|
|
1
|
+
import { accessSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync, X_OK } from 'fs';
|
|
2
|
+
import { delimiter, join, resolve, sep } from 'path';
|
|
3
3
|
import { toolResult, SafeCommandExecutor } from './executor.js';
|
|
4
4
|
import { Downloader } from '../utils/downloader.js';
|
|
5
5
|
import { Config } from '../config.js';
|
|
@@ -106,6 +106,23 @@ export function deleteFile(cwd, path) {
|
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
// Pick the package manager to use for installing system packages: pkg is the
|
|
110
|
+
// Termux/Android wrapper, everywhere else apt/apt-get.
|
|
111
|
+
export function pickPackageManager() {
|
|
112
|
+
const dirs = (process.env.PATH || '').split(delimiter).filter(Boolean);
|
|
113
|
+
const available = (bin) => dirs.some((dir) => {
|
|
114
|
+
try {
|
|
115
|
+
accessSync(join(dir, bin), X_OK);
|
|
116
|
+
return true;
|
|
117
|
+
} catch {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
if (available('pkg')) return 'pkg';
|
|
122
|
+
if (available('apt-get')) return 'apt-get';
|
|
123
|
+
return 'apt';
|
|
124
|
+
}
|
|
125
|
+
|
|
109
126
|
export async function downloadUrl(cwd, url, dir) {
|
|
110
127
|
if (!url || typeof url !== 'string' || !/^https?:\/\//i.test(url.trim())) {
|
|
111
128
|
return fail({ command: 'download_url', stderr: 'Invalid URL. Provide a valid http(s) address.', cwd });
|
package/src/engine.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { execSync, exec } from 'child_process';
|
|
2
|
+
import { existsSync } from 'fs';
|
|
3
|
+
import { Catalog } from './catalog.js';
|
|
4
|
+
import { Config } from './config.js';
|
|
5
|
+
|
|
6
|
+
function runSilent(cmd) {
|
|
7
|
+
return new Promise((resolve) => {
|
|
8
|
+
exec(cmd, { stdio: 'ignore' }, (err) => resolve(!err));
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function isEngineRunning(host) {
|
|
13
|
+
return fetch(`${host}/api/tags`, { signal: AbortSignal.timeout(3000) })
|
|
14
|
+
.then((r) => r.ok)
|
|
15
|
+
.catch(() => false);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isEngineInstalled() {
|
|
19
|
+
try {
|
|
20
|
+
execSync('which ollama 2>/dev/null', { stdio: 'ignore' });
|
|
21
|
+
return true;
|
|
22
|
+
} catch {}
|
|
23
|
+
const paths = ['/usr/local/bin/ollama', '/usr/bin/ollama'];
|
|
24
|
+
return paths.some((p) => existsSync(p));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function installEngine() {
|
|
28
|
+
await runSilent('curl -fsSL https://ollama.com/install.sh | sh');
|
|
29
|
+
return isEngineInstalled();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function getInstalledModels(host) {
|
|
33
|
+
try {
|
|
34
|
+
const resp = await fetch(`${host}/api/tags`);
|
|
35
|
+
if (!resp.ok) return [];
|
|
36
|
+
const data = await resp.json();
|
|
37
|
+
return (data.models || []).map((m) => m.name);
|
|
38
|
+
} catch {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function pullModel(host, model) {
|
|
44
|
+
try {
|
|
45
|
+
const resp = await fetch(`${host}/api/pull`, {
|
|
46
|
+
method: 'POST',
|
|
47
|
+
headers: { 'Content-Type': 'application/json' },
|
|
48
|
+
body: JSON.stringify({ name: model, stream: false }),
|
|
49
|
+
signal: AbortSignal.timeout(600000),
|
|
50
|
+
});
|
|
51
|
+
return resp.ok;
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function familyOf(model) {
|
|
58
|
+
return model
|
|
59
|
+
.split(':')[0]
|
|
60
|
+
.toLowerCase()
|
|
61
|
+
.replace(/-(coder|instruct|reasoning|chat|base|tiny|nano)$/, '');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function sizeOf(model) {
|
|
65
|
+
const size = model.split(':')[1] || '';
|
|
66
|
+
const match = size.match(/(\d+(?:\.\d+)?)(m|b)/i);
|
|
67
|
+
if (!match) return Number.POSITIVE_INFINITY;
|
|
68
|
+
const value = parseFloat(match[1]);
|
|
69
|
+
return match[2].toLowerCase() === 'b' ? value * 1000 : value;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function chooseModel(installed, realModel) {
|
|
73
|
+
if (installed.includes(realModel)) {
|
|
74
|
+
return { model: realModel, substituted: false, reason: null };
|
|
75
|
+
}
|
|
76
|
+
const fam = familyOf(realModel);
|
|
77
|
+
let best = null;
|
|
78
|
+
let bestDist = Number.POSITIVE_INFINITY;
|
|
79
|
+
for (const m of installed) {
|
|
80
|
+
if (familyOf(m) !== fam) continue;
|
|
81
|
+
const dist = Math.abs(sizeOf(m) - sizeOf(realModel)) + 0.5;
|
|
82
|
+
if (dist < bestDist) {
|
|
83
|
+
bestDist = dist;
|
|
84
|
+
best = m;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (best) {
|
|
88
|
+
return {
|
|
89
|
+
model: best,
|
|
90
|
+
substituted: true,
|
|
91
|
+
reason: `"${realModel}" is not installed; using ${best} (same family)`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
return { model: null, substituted: true, reason: `"${realModel}" is not installed` };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function resolveModel(host, realModel) {
|
|
98
|
+
const installed = await getInstalledModels(host);
|
|
99
|
+
return chooseModel(installed, realModel);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function ensure() {
|
|
103
|
+
const host = Config.get('engineHost');
|
|
104
|
+
|
|
105
|
+
if (!isEngineInstalled()) {
|
|
106
|
+
await installEngine();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const running = await isEngineRunning(host);
|
|
110
|
+
if (!running) {
|
|
111
|
+
try {
|
|
112
|
+
const child = exec('ollama serve', {
|
|
113
|
+
stdio: 'ignore',
|
|
114
|
+
detached: true,
|
|
115
|
+
});
|
|
116
|
+
if (child.unref) child.unref();
|
|
117
|
+
for (let i = 0; i < 30; i++) {
|
|
118
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
119
|
+
if (await isEngineRunning(host)) break;
|
|
120
|
+
}
|
|
121
|
+
} catch {}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const realModel = Catalog.getRealModel(Config.get('model'));
|
|
125
|
+
const installed = await getInstalledModels(host);
|
|
126
|
+
const resolved = chooseModel(installed, realModel);
|
|
127
|
+
if (resolved.model == null) {
|
|
128
|
+
await pullModel(host, realModel);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function isReady() {
|
|
135
|
+
const host = Config.get('engineHost');
|
|
136
|
+
return isEngineRunning(host);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export const Engine = {
|
|
140
|
+
ensure,
|
|
141
|
+
isReady,
|
|
142
|
+
pullModel,
|
|
143
|
+
getInstalledModels,
|
|
144
|
+
resolveModel,
|
|
145
|
+
chooseModel,
|
|
146
|
+
firstInstalled: async (host) => {
|
|
147
|
+
const installed = await getInstalledModels(host);
|
|
148
|
+
return installed[0] || null;
|
|
149
|
+
},
|
|
150
|
+
};
|
package/src/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { Catalog } from './catalog.js';
|
|
2
2
|
export { Config } from './config.js';
|
|
3
|
-
export {
|
|
3
|
+
export { Engine } from './engine.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';
|
package/src/ui/branding.js
CHANGED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { readdirSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
|
|
4
|
+
// Directories that are never worth expanding in a workspace tree.
|
|
5
|
+
const SKIP_DIRS = new Set(['.git', 'node_modules', '__pycache__', '.cache']);
|
|
6
|
+
|
|
7
|
+
// Render a compact box-drawing tree of a directory. Returns an array of lines
|
|
8
|
+
// starting with "." for the workspace root. Bounded so huge folders (node_modules,
|
|
9
|
+
// caches, download dirs) can never flood a chat with thousands of lines.
|
|
10
|
+
export function renderFileTree(rootDir, { maxDepth = 4, maxEntries = 150 } = {}) {
|
|
11
|
+
const root = rootDir || process.cwd();
|
|
12
|
+
const lines = ['.'];
|
|
13
|
+
let entries = 0;
|
|
14
|
+
|
|
15
|
+
const walk = (dir, depth, prefix) => {
|
|
16
|
+
if (entries >= maxEntries) return;
|
|
17
|
+
let names;
|
|
18
|
+
try {
|
|
19
|
+
names = readdirSync(dir, { withFileTypes: true });
|
|
20
|
+
} catch {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
names = names
|
|
24
|
+
.filter((e) => !SKIP_DIRS.has(e.name))
|
|
25
|
+
.sort((a, b) => {
|
|
26
|
+
if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1;
|
|
27
|
+
return a.name.localeCompare(b.name);
|
|
28
|
+
});
|
|
29
|
+
for (let i = 0; i < names.length; i++) {
|
|
30
|
+
if (entries >= maxEntries) return;
|
|
31
|
+
const e = names[i];
|
|
32
|
+
const last = i === names.length - 1;
|
|
33
|
+
lines.push(`${prefix}${last ? '└── ' : '├── '}${e.name}`);
|
|
34
|
+
entries++;
|
|
35
|
+
if (e.isDirectory() && depth < maxDepth) {
|
|
36
|
+
walk(join(dir, e.name), depth + 1, `${prefix}${last ? ' ' : '│ '}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
walk(root, 0, '');
|
|
42
|
+
if (entries >= maxEntries) lines.push(`… showing first ${entries} entries`);
|
|
43
|
+
return lines;
|
|
44
|
+
}
|
package/src/installer.js
DELETED
|
@@ -1,233 +0,0 @@
|
|
|
1
|
-
import { execSync, exec } from 'child_process';
|
|
2
|
-
import { existsSync, mkdirSync, symlinkSync, chmodSync, writeFileSync } from 'fs';
|
|
3
|
-
import { join } from 'path';
|
|
4
|
-
import { homedir, platform } from 'os';
|
|
5
|
-
import { Catalog } from './catalog.js';
|
|
6
|
-
import { Config } from './config.js';
|
|
7
|
-
|
|
8
|
-
function runSilent(cmd) {
|
|
9
|
-
return new Promise((resolve) => {
|
|
10
|
-
exec(cmd, { stdio: 'ignore' }, (err) => resolve(!err));
|
|
11
|
-
});
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function isEngineRunning(host) {
|
|
15
|
-
return fetch(`${host}/api/tags`, { signal: AbortSignal.timeout(3000) })
|
|
16
|
-
.then((r) => r.ok)
|
|
17
|
-
.catch(() => false);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function isEngineInstalled() {
|
|
21
|
-
try {
|
|
22
|
-
execSync('which ollama 2>/dev/null', { stdio: 'ignore' });
|
|
23
|
-
return true;
|
|
24
|
-
} catch {}
|
|
25
|
-
const paths = ['/usr/local/bin/ollama', '/usr/bin/ollama'];
|
|
26
|
-
return paths.some((p) => existsSync(p));
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
async function installEngine() {
|
|
30
|
-
await runSilent('curl -fsSL https://ollama.com/install.sh | sh');
|
|
31
|
-
return isEngineInstalled();
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
async function pullModel(host, model) {
|
|
35
|
-
try {
|
|
36
|
-
const resp = await fetch(`${host}/api/pull`, {
|
|
37
|
-
method: 'POST',
|
|
38
|
-
headers: { 'Content-Type': 'application/json' },
|
|
39
|
-
body: JSON.stringify({ name: model, stream: false }),
|
|
40
|
-
signal: AbortSignal.timeout(600000),
|
|
41
|
-
});
|
|
42
|
-
return resp.ok;
|
|
43
|
-
} catch {
|
|
44
|
-
return false;
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
async function getInstalledModels(host) {
|
|
49
|
-
try {
|
|
50
|
-
const resp = await fetch(`${host}/api/tags`);
|
|
51
|
-
if (!resp.ok) return [];
|
|
52
|
-
const data = await resp.json();
|
|
53
|
-
return (data.models || []).map((m) => m.name);
|
|
54
|
-
} catch {
|
|
55
|
-
return [];
|
|
56
|
-
}
|
|
57
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
async ensure() {
|
|
162
|
-
const host = Config.get('engineHost');
|
|
163
|
-
|
|
164
|
-
if (!isEngineInstalled()) {
|
|
165
|
-
await installEngine();
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
const running = await isEngineRunning(host);
|
|
169
|
-
if (!running) {
|
|
170
|
-
try {
|
|
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
|
-
}
|
|
182
|
-
} catch {}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
const realModel = Catalog.getRealModel(Config.get('model'));
|
|
186
|
-
const installed = await getInstalledModels(host);
|
|
187
|
-
const resolved = chooseModel(installed, realModel);
|
|
188
|
-
const needsPull = resolved.model == null;
|
|
189
|
-
|
|
190
|
-
if (needsPull) {
|
|
191
|
-
await pullModel(host, realModel);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
return true;
|
|
195
|
-
},
|
|
196
|
-
|
|
197
|
-
async isReady() {
|
|
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 };
|
|
232
|
-
},
|
|
233
|
-
};
|