@vierratale/ai 0.1.0-beta.11 → 0.1.0-beta.13
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 +12 -3
- package/bin/vierrataleai.js +17 -5
- package/package.json +21 -22
- package/src/cli.js +54 -1
- package/src/cmd/agent.js +145 -31
- package/src/cmd/executor.js +14 -0
- package/src/cmd/todos.js +64 -0
- package/src/cmd/tools.js +53 -2
- package/src/installer.js +82 -15
- package/src/ui/branding.js +1 -1
- package/src/utils/filetree.js +44 -0
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,9 +1,10 @@
|
|
|
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.
|
|
@@ -12,6 +13,7 @@ import { Installer } from '../src/installer.js';
|
|
|
12
13
|
import { Config } from '../src/config.js';
|
|
13
14
|
import { Branding } from '../src/ui/branding.js';
|
|
14
15
|
import { Terminal } from '../src/ui/terminal.js';
|
|
16
|
+
import { dirname } from 'path';
|
|
15
17
|
|
|
16
18
|
const C = Branding.colors;
|
|
17
19
|
|
|
@@ -23,6 +25,8 @@ const INSTALL_STEPS = [
|
|
|
23
25
|
'Optimizing for this device',
|
|
24
26
|
];
|
|
25
27
|
|
|
28
|
+
const READY_STEPS = ['Checking engine', 'Installing dependencies', 'Downloading model', 'Optimizing'];
|
|
29
|
+
|
|
26
30
|
function delay(ms) {
|
|
27
31
|
return new Promise((r) => setTimeout(r, ms));
|
|
28
32
|
}
|
|
@@ -53,6 +57,7 @@ async function fakeProgress(steps, work) {
|
|
|
53
57
|
async function main() {
|
|
54
58
|
const args = process.argv.slice(2);
|
|
55
59
|
const doInstall = args.includes('--install') || args.includes('--setup') || args.includes('install');
|
|
60
|
+
const quick = args.includes('--version') || args.includes('-v') || args.includes('--help') || args.includes('-h');
|
|
56
61
|
|
|
57
62
|
Config.load();
|
|
58
63
|
|
|
@@ -60,11 +65,18 @@ async function main() {
|
|
|
60
65
|
console.log(`\n ${C.bold}${C.primary}${Branding.APP_NAME} ${Branding.VERSION}${C.reset} ${C.dim}— installer${C.reset}\n`);
|
|
61
66
|
const result = await fakeProgress(INSTALL_STEPS, Installer.install());
|
|
62
67
|
if (!result.command) {
|
|
63
|
-
Terminal.printInfo('Could not create a global
|
|
68
|
+
Terminal.printInfo('Could not create a global command; run it via the bin script.');
|
|
64
69
|
} else {
|
|
65
|
-
Terminal.printSuccess(`Engine + model installed; "vierrataleai" command available at ${result.command}`);
|
|
70
|
+
Terminal.printSuccess(`Engine + model installed; "vierrataleai" (or "vierratale") command available at ${result.commandAlias || result.command}`);
|
|
71
|
+
if (!result.commandOnPath) {
|
|
72
|
+
Terminal.printInfo(`"vierrataleai" won't resolve in a new shell: add ${dirname(result.command)} to PATH, e.g. export PATH="$HOME/.local/bin:$PATH", then reopen the terminal.`);
|
|
73
|
+
}
|
|
66
74
|
}
|
|
67
75
|
console.log();
|
|
76
|
+
} else if (!quick) {
|
|
77
|
+
// Auto-detect: install anything that's missing (engine, model) before starting.
|
|
78
|
+
await fakeProgress(READY_STEPS, Installer.ensure());
|
|
79
|
+
console.log();
|
|
68
80
|
}
|
|
69
81
|
|
|
70
82
|
return run();
|
package/package.json
CHANGED
|
@@ -1,31 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vierratale/ai",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.13",
|
|
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
|
+
"README.md"
|
|
22
|
+
],
|
|
23
|
+
"keywords": [
|
|
24
|
+
"ai",
|
|
25
|
+
"chatbot",
|
|
26
|
+
"terminal",
|
|
27
|
+
"assistant",
|
|
28
|
+
"llm"
|
|
29
|
+
]
|
|
30
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -18,6 +18,7 @@ import { Session } from './session.js';
|
|
|
18
18
|
import { ChatUI, FrameThrottle } from './ui/chatbox.js';
|
|
19
19
|
import { logger } from './utils/logger.js';
|
|
20
20
|
import { runWithTools, TOOL_RESULTS_PROMPT, looksLikeOperationRequest } from './cmd/agent.js';
|
|
21
|
+
import { loadTodos, addTodo, updateTodo, clearTodos, saveTodos, formatTodos, todosFile } from './cmd/todos.js';
|
|
21
22
|
|
|
22
23
|
function parseArgs(args) {
|
|
23
24
|
const parsed = { provider: null, model: null, clear: false, version: false, help: false, install: false };
|
|
@@ -46,6 +47,8 @@ Options:
|
|
|
46
47
|
--version, -v Show version
|
|
47
48
|
--help, -h Show this help
|
|
48
49
|
|
|
50
|
+
Missing dependencies (engine + model) are installed automatically on start.
|
|
51
|
+
|
|
49
52
|
Commands (in chat):
|
|
50
53
|
/help Show commands
|
|
51
54
|
/model [name] Switch model
|
|
@@ -54,6 +57,7 @@ Commands (in chat):
|
|
|
54
57
|
/search <query> Search the web and summarize with AI
|
|
55
58
|
/fetch <url> Open a link and summarize its content
|
|
56
59
|
/download <url> Download a file, folder listing or archive
|
|
60
|
+
/todos List the todo list (add <text> | done/undone <n> | clear)
|
|
57
61
|
/log [n|path] Show the last n log entries (default 30), or the log file path
|
|
58
62
|
/clear Clear screen
|
|
59
63
|
/new Start a new empty session (keeps old ones)
|
|
@@ -79,6 +83,7 @@ export const SLASH_COMMANDS = [
|
|
|
79
83
|
{ name: 'fetch', args: '<url>', desc: 'Open a link and summarize' },
|
|
80
84
|
{ name: 'download', args: '<url>', desc: 'Download a file / folder / archive' },
|
|
81
85
|
{ name: 'system', desc: 'Show or set the system prompt' },
|
|
86
|
+
{ name: 'todos', args: '[add <text> | done <n> | undone <n> | clear]', desc: 'Manage the todo list' },
|
|
82
87
|
{ name: 'sessions', desc: 'List saved sessions' },
|
|
83
88
|
{ name: 'session', args: 'new | open | delete | backup | restore', desc: 'Manage sessions' },
|
|
84
89
|
{ name: 'log', args: '[n | path]', desc: 'Show recent log entries' },
|
|
@@ -656,7 +661,7 @@ async function chat(provider, systemPrompt) {
|
|
|
656
661
|
if (cmd === '/help') {
|
|
657
662
|
chatUI.clearNotices();
|
|
658
663
|
chatUI.notify(
|
|
659
|
-
`Commands:\n /search <query> · /fetch <url> · /model [name] · /provider [name]\n /models · /log [n|path] · /new · /sessions · /session new|open|delete|backup|restore\n /clear · /help · /quit`
|
|
664
|
+
`Commands:\n /search <query> · /fetch <url> · /model [name] · /provider [name]\n /models · /todos · /log [n|path] · /new · /sessions · /session new|open|delete|backup|restore\n /clear · /help · /quit`
|
|
660
665
|
);
|
|
661
666
|
chatUI.render(messages);
|
|
662
667
|
continue;
|
|
@@ -977,6 +982,11 @@ async function chat(provider, systemPrompt) {
|
|
|
977
982
|
continue;
|
|
978
983
|
}
|
|
979
984
|
|
|
985
|
+
if (cmd === '/todos' || cmd.startsWith('/todos ')) {
|
|
986
|
+
handleTodosCommand(trimmed, chatUI, messages, process.cwd());
|
|
987
|
+
continue;
|
|
988
|
+
}
|
|
989
|
+
|
|
980
990
|
const intent = detectIntent(trimmed);
|
|
981
991
|
if (intent.type === 'self') {
|
|
982
992
|
const model = Config.getEffectiveModel(provider.name);
|
|
@@ -1125,6 +1135,49 @@ async function ensureRawLocalModel(realModel, notify) {
|
|
|
1125
1135
|
export { looksLikeFileRequest, requestedFileName, extractFallbackFile, storyFileName, responseWantsFile, responseIsSoloCode, languageFileName, fallbackHasUsefulCode, validateWrittenCode, looksLikeJunkCode, warnIfJunkCode, writeFilesFromResponse };
|
|
1126
1136
|
// Command/tool layer: asks the model to plan filesystem/shell operations,
|
|
1127
1137
|
// executes them with the sandboxed executor, then streams the final answer.
|
|
1138
|
+
function handleTodosCommand(trimmed, chatUI, messages, cwd) {
|
|
1139
|
+
chatUI.clearNotices();
|
|
1140
|
+
const rest = trimmed.slice('/todos'.length).trim();
|
|
1141
|
+
let out;
|
|
1142
|
+
if (!rest) {
|
|
1143
|
+
out = `Todos (${todosFile(cwd)}):\n${formatTodos(loadTodos(cwd))}`;
|
|
1144
|
+
} else if (/^add\s+/i.test(rest)) {
|
|
1145
|
+
const err = addTodo(cwd, rest.replace(/^add\s+/i, '').trim());
|
|
1146
|
+
out = err && err.error ? err.error : `Todos (${todosFile(cwd)}):\n${formatTodos(loadTodos(cwd))}`;
|
|
1147
|
+
} else if (/^done\s+\d+$/i.test(rest)) {
|
|
1148
|
+
const err = updateTodo(cwd, parseInt(rest.replace(/^done\s+/i, ''), 10), { done: true });
|
|
1149
|
+
out = err && err.error ? err.error : `Todos (${todosFile(cwd)}):\n${formatTodos(loadTodos(cwd))}`;
|
|
1150
|
+
} else if (/^undone\s+\d+$/i.test(rest)) {
|
|
1151
|
+
const err = updateTodo(cwd, parseInt(rest.replace(/^undone\s+/i, ''), 10), { done: false });
|
|
1152
|
+
out = err && err.error ? err.error : `Todos (${todosFile(cwd)}):\n${formatTodos(loadTodos(cwd))}`;
|
|
1153
|
+
} else if (/^remove\s+\d+$/i.test(rest)) {
|
|
1154
|
+
const n = parseInt(rest.replace(/^remove\s+/i, ''), 10);
|
|
1155
|
+
const todos = loadTodos(cwd);
|
|
1156
|
+
if (!Number.isInteger(n - 1) || n - 1 < 0 || n - 1 >= todos.length) {
|
|
1157
|
+
out = `No todo #${n}.`;
|
|
1158
|
+
} else {
|
|
1159
|
+
const next = todos.filter((_, i) => i !== n - 1);
|
|
1160
|
+
saveTodos(cwd, next);
|
|
1161
|
+
out = `Todos (${todosFile(cwd)}):\n${formatTodos(loadTodos(cwd))}`;
|
|
1162
|
+
}
|
|
1163
|
+
} else if (/^clear$/i.test(rest)) {
|
|
1164
|
+
clearTodos(cwd);
|
|
1165
|
+
out = `Todos cleared (${todosFile(cwd)}). (no todos yet)`;
|
|
1166
|
+
} else if (/^\d+$/.test(rest)) {
|
|
1167
|
+
const n = parseInt(rest, 10);
|
|
1168
|
+
const todos = loadTodos(cwd);
|
|
1169
|
+
const err = (Number.isInteger(n - 1) && n - 1 >= 0 && n - 1 < todos.length)
|
|
1170
|
+
? updateTodo(cwd, n, { done: !todos[n - 1].done })
|
|
1171
|
+
: { error: `No todo #${n}.` };
|
|
1172
|
+
out = err && err.error ? err.error : `Todos (${todosFile(cwd)}):\n${formatTodos(loadTodos(cwd))}`;
|
|
1173
|
+
} else {
|
|
1174
|
+
out = 'Usage: /todos [add <text> | done <n> | undone <n> | remove <n> | <n> (toggle) | clear]';
|
|
1175
|
+
}
|
|
1176
|
+
chatUI.notify(out);
|
|
1177
|
+
logger.log('CMD', `todos: ${rest || '(list)'}`);
|
|
1178
|
+
chatUI.render(messages);
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1128
1181
|
async function answerWithTools(messages, provider, requestText, systemPrompt, confirm, chatUI, cwd, timeoutMs) {
|
|
1129
1182
|
let results = [];
|
|
1130
1183
|
try {
|
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;
|
|
@@ -19,14 +20,20 @@ Reply with EXACTLY ONE JSON array of the tool calls needed to do the job, in ord
|
|
|
19
20
|
{"name":"list_directory","path":"."}
|
|
20
21
|
{"name":"delete_file","path":"old.txt"}
|
|
21
22
|
{"name":"download_url","url":"https://example.com/file.zip","dir":"downloads"}
|
|
23
|
+
{"name":"todo_add","text":"install dependencies"}
|
|
24
|
+
{"name":"todo_list"}
|
|
25
|
+
{"name":"todo_update","index":1,"done":true}
|
|
26
|
+
{"name":"todo_clear"}
|
|
22
27
|
|
|
23
28
|
Rules:
|
|
24
|
-
- 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.
|
|
25
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.
|
|
26
31
|
- To create a file containing code or text, ALWAYS use create_file (never echo/cat > …). Pass the full content in "content".
|
|
27
32
|
- Prefer create_file over redirecting output.
|
|
28
33
|
- If you need to create a folder first, plan create_directory before create_file inside it.
|
|
29
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.
|
|
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).
|
|
30
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.
|
|
31
38
|
- If NO tools are needed, reply exactly: NONE
|
|
32
39
|
- Output the JSON array only — no prose, no markdown fences unless the array is inside them.`;
|
|
@@ -37,6 +44,20 @@ ${resultsJson}
|
|
|
37
44
|
|
|
38
45
|
Give the user a short, natural reply (in the user's own language) telling them what happened. Keep the whole reply under 40 words. Do NOT tell them to run the commands yourself. If they asked for a file containing code and files were created with content, you may also emit FILE: blocks if more files are still required.`;
|
|
39
46
|
|
|
47
|
+
// Auto error fixing: shown to the model when a tool call fails so it can emit
|
|
48
|
+
// a corrected plan instead of giving up. Bounded by MAX_AUTO_FIXES per run.
|
|
49
|
+
const MAX_AUTO_FIXES = 3;
|
|
50
|
+
|
|
51
|
+
const TOOL_FIX_PROMPT = (call, result) => `A tool call made while working in the workspace FAILED. Fix the problem so the original request can complete.
|
|
52
|
+
|
|
53
|
+
Failed tool call:
|
|
54
|
+
${JSON.stringify(call, null, 2)}
|
|
55
|
+
|
|
56
|
+
Failure:
|
|
57
|
+
${JSON.stringify({ success: false, exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr }, null, 2)}
|
|
58
|
+
|
|
59
|
+
Emit a NEW tool plan (a JSON array of tool calls) that corrects the failure — for example re-create the file with the error fixed, run a command to repair it, or download/read what is missing. If your previous plan skipped a required step (like creating a parent folder), include it. Use todo tools to track remaining subtasks if useful. Reply with exactly one JSON array, or the single word NONE if no tool call can fix this.`;
|
|
60
|
+
|
|
40
61
|
// ---------------------------------------------------------------------------
|
|
41
62
|
// Request detection
|
|
42
63
|
// ---------------------------------------------------------------------------
|
|
@@ -84,6 +105,18 @@ export function normalizeToolCall(raw) {
|
|
|
84
105
|
} else if (name === 'download_url') {
|
|
85
106
|
if (typeof raw.url !== 'string' || raw.url.trim() === '') return null;
|
|
86
107
|
params = { url: raw.url, dir: typeof raw.dir === 'string' && raw.dir.trim() ? raw.dir.trim() : undefined };
|
|
108
|
+
} else if (name === 'todo_add') {
|
|
109
|
+
if (typeof raw.text !== 'string' || raw.text.trim() === '') return null;
|
|
110
|
+
params = { text: raw.text };
|
|
111
|
+
} else if (name === 'todo_update') {
|
|
112
|
+
if (!Number.isInteger(raw.index) && !/^\d+$/.test(String(raw.index ?? ''))) return null;
|
|
113
|
+
params = {
|
|
114
|
+
index: Number(raw.index),
|
|
115
|
+
done: typeof raw.done === 'boolean' ? raw.done : undefined,
|
|
116
|
+
text: typeof raw.text === 'string' && raw.text.trim() ? raw.text : undefined,
|
|
117
|
+
};
|
|
118
|
+
} else if (name === 'todo_list' || name === 'todo_clear') {
|
|
119
|
+
params = {};
|
|
87
120
|
} else {
|
|
88
121
|
return null;
|
|
89
122
|
}
|
|
@@ -176,6 +209,24 @@ export function heuristicPlan(requestText) {
|
|
|
176
209
|
return [{ name: 'run_command', params: { command: tail } }];
|
|
177
210
|
}
|
|
178
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
|
+
|
|
179
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);
|
|
180
231
|
if (m) {
|
|
181
232
|
const cmd = `${m[2]} ${m[3] || ''}`.trim();
|
|
@@ -1631,6 +1682,75 @@ export async function planOnce(provider, messages, requestText, options = {}) {
|
|
|
1631
1682
|
return heuristic;
|
|
1632
1683
|
}
|
|
1633
1684
|
|
|
1685
|
+
async function executeToolCall({ executor, call, chatUI, messages, cwd }) {
|
|
1686
|
+
const label = call.name === 'run_command' ? call.params.command
|
|
1687
|
+
: call.name === 'download_url' ? `download_url(${call.params.url})`
|
|
1688
|
+
: (call.name === 'todo_add' || call.name === 'todo_update')
|
|
1689
|
+
? `${call.name}(${JSON.stringify(call.params ?? {})})`
|
|
1690
|
+
: `${call.name}(${call.params.path})`;
|
|
1691
|
+
if (chatUI) {
|
|
1692
|
+
chatUI.setStatus(`Running: ${label}`);
|
|
1693
|
+
chatUI.setThinking(true);
|
|
1694
|
+
chatUI.render(messages);
|
|
1695
|
+
}
|
|
1696
|
+
const result = await dispatchTool(executor, call);
|
|
1697
|
+
logger.log('EXEC', `${label} -> ${result.exitCode}${result.timedOut ? ' [timeout]' : ''}`);
|
|
1698
|
+
if (result.success) {
|
|
1699
|
+
if (chatUI) chatUI.notify(`\u2713 ${label}`);
|
|
1700
|
+
if (result.stdout.trim()) logger.log('EXEC', `${label} stdout: ${result.stdout.trim().slice(0, 500)}`);
|
|
1701
|
+
if (result.stderr.trim()) logger.log('WARN', `${label} stderr: ${result.stderr.trim().slice(0, 500)}`);
|
|
1702
|
+
if (call.name === 'create_file' && /\.py$/i.test(call.params.path)) {
|
|
1703
|
+
const check = spawnSync('python3', [
|
|
1704
|
+
'-c', 'import ast,sys; ast.parse(open(sys.argv[1], encoding="utf-8").read())',
|
|
1705
|
+
join(cwd || '.', call.params.path),
|
|
1706
|
+
], { encoding: 'utf-8', timeout: 30000 });
|
|
1707
|
+
if (check.status === 1) {
|
|
1708
|
+
const detail = (check.stderr || '').trim().split('\n').slice(-2).join(' ');
|
|
1709
|
+
if (chatUI) chatUI.notify(`\u2717 python check failed: ${call.params.path} (${detail})`);
|
|
1710
|
+
logger.log('WARN', `${label} failed python syntax check: ${detail}`);
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
} else {
|
|
1714
|
+
if (chatUI) chatUI.notify(`\u2717 ${label}: ${(result.stderr || 'failed').trim().slice(0, 300)}`);
|
|
1715
|
+
logger.log('ERROR', `${label} failed: ${(result.stderr || result.stdout || 'exit ' + result.exitCode).trim().slice(0, 500)}`);
|
|
1716
|
+
}
|
|
1717
|
+
return { label, result };
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
// Ask the model for a corrected tool plan after a failed call. Returns []
|
|
1721
|
+
// when the model emits nothing usable. Does NOT mutate the session messages.
|
|
1722
|
+
async function planAutoFix(provider, messages, requestText, call, result) {
|
|
1723
|
+
const history = (messages && messages.length ? messages.slice(-6) : [{ role: 'user', content: String(requestText || '') }]);
|
|
1724
|
+
const deadline = Config.get('planTimeoutMs');
|
|
1725
|
+
const controller = new AbortController();
|
|
1726
|
+
let text = '';
|
|
1727
|
+
let timer;
|
|
1728
|
+
try {
|
|
1729
|
+
text = await Promise.race([
|
|
1730
|
+
provider.complete([...history, { role: 'user', content: TOOL_FIX_PROMPT(call, result) }], {
|
|
1731
|
+
model: Config.getEffectiveModel(provider.name),
|
|
1732
|
+
system_prompt: TOOL_PLAN_PROMPT,
|
|
1733
|
+
maxTokens: 400,
|
|
1734
|
+
signal: controller.signal,
|
|
1735
|
+
}),
|
|
1736
|
+
new Promise((_resolve, reject) => {
|
|
1737
|
+
timer = setTimeout(() => {
|
|
1738
|
+
if (!controller.signal.aborted) controller.abort();
|
|
1739
|
+
reject(Object.assign(new Error(`auto-fix exceeded ${deadline}ms`), { name: 'DeadlineError' }));
|
|
1740
|
+
}, deadline);
|
|
1741
|
+
}),
|
|
1742
|
+
]);
|
|
1743
|
+
} catch (err) {
|
|
1744
|
+
logger.log('FIX', `auto-fix plan failed: ${err.message}`);
|
|
1745
|
+
return [];
|
|
1746
|
+
} finally {
|
|
1747
|
+
clearTimeout(timer);
|
|
1748
|
+
}
|
|
1749
|
+
const plan = parseToolPlan(text);
|
|
1750
|
+
if (plan.length) logger.log('FIX', `auto-fix plan: ${plan.map((p) => p.name).join(', ')}`);
|
|
1751
|
+
return plan;
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1634
1754
|
export async function runWithTools({
|
|
1635
1755
|
provider,
|
|
1636
1756
|
messages,
|
|
@@ -1648,6 +1768,7 @@ export async function runWithTools({
|
|
|
1648
1768
|
const executor = makeExecutor({ cwd, timeoutMs });
|
|
1649
1769
|
const results = [];
|
|
1650
1770
|
const plannedByModel = [];
|
|
1771
|
+
let autoFixes = 0;
|
|
1651
1772
|
|
|
1652
1773
|
for (let round = 0; round < MAX_PLANNING_ROUNDS; round++) {
|
|
1653
1774
|
let plan;
|
|
@@ -1662,43 +1783,36 @@ export async function runWithTools({
|
|
|
1662
1783
|
const steps = Math.min(plan.length, MAX_STEPS - results.length);
|
|
1663
1784
|
let allOk = true;
|
|
1664
1785
|
for (const call of plan.slice(0, steps)) {
|
|
1665
|
-
const
|
|
1666
|
-
: call.name === 'download_url' ? `download_url(${call.params.url})`
|
|
1667
|
-
: `${call.name}(${call.params.path})`;
|
|
1668
|
-
if (chatUI) {
|
|
1669
|
-
chatUI.setStatus(`Running: ${label}`);
|
|
1670
|
-
chatUI.setThinking(true);
|
|
1671
|
-
chatUI.render(messages);
|
|
1672
|
-
}
|
|
1673
|
-
const result = await dispatchTool(executor, call);
|
|
1786
|
+
const { result } = await executeToolCall({ executor, call, chatUI, messages, cwd });
|
|
1674
1787
|
results.push({ name: call.name, params: call.params, result });
|
|
1675
|
-
|
|
1676
|
-
if (result.success)
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
if (
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
logger.log('WARN', `${label} failed python syntax check: ${detail}`);
|
|
1689
|
-
}
|
|
1788
|
+
if (!result.success) allOk = false;
|
|
1789
|
+
if (result.timedOut && !result.success) break;
|
|
1790
|
+
|
|
1791
|
+
if (!result.success && autoFixes < MAX_AUTO_FIXES && results.length < MAX_STEPS) {
|
|
1792
|
+
autoFixes++;
|
|
1793
|
+
if (chatUI) chatUI.setStatus('Auto-fixing failed step…');
|
|
1794
|
+
const fixPlan = await planAutoFix(provider, messages, requestText, call, result);
|
|
1795
|
+
const fixSteps = Math.min(fixPlan.length, MAX_STEPS - results.length);
|
|
1796
|
+
let fixedOk = fixPlan.length > 0;
|
|
1797
|
+
for (const fcall of fixPlan.slice(0, fixSteps)) {
|
|
1798
|
+
const f = await executeToolCall({ executor, call: fcall, chatUI, messages, cwd });
|
|
1799
|
+
results.push({ name: fcall.name, params: fcall.params, result: f.result });
|
|
1800
|
+
if (!f.result.success) { fixedOk = false; break; }
|
|
1690
1801
|
}
|
|
1691
|
-
|
|
1692
|
-
allOk =
|
|
1693
|
-
if (chatUI) chatUI.notify(`\u2717 ${label}: ${(result.stderr || 'failed').trim().slice(0, 300)}`);
|
|
1694
|
-
logger.log('ERROR', `${label} failed: ${(result.stderr || result.stdout || 'exit ' + result.exitCode).trim().slice(0, 500)}`);
|
|
1802
|
+
if (fixedOk && chatUI) chatUI.notify('Auto-fixed after failure.');
|
|
1803
|
+
if (fixedOk) allOk = true;
|
|
1695
1804
|
}
|
|
1696
|
-
if (result.timedOut && !result.success) break;
|
|
1697
1805
|
}
|
|
1698
1806
|
if (allOk || !results.length || round >= MAX_PLANNING_ROUNDS - 1) break;
|
|
1699
1807
|
}
|
|
1700
1808
|
|
|
1701
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
|
+
}
|
|
1702
1816
|
if (chatUI) {
|
|
1703
1817
|
chatUI.setStatus('');
|
|
1704
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/todos.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
|
|
4
|
+
const FILE_NAME = '.todos.json';
|
|
5
|
+
|
|
6
|
+
export function todosFile(cwd) {
|
|
7
|
+
return join(cwd || '.', FILE_NAME);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function loadTodos(cwd) {
|
|
11
|
+
try {
|
|
12
|
+
const data = JSON.parse(readFileSync(todosFile(cwd), 'utf-8'));
|
|
13
|
+
if (!Array.isArray(data)) return [];
|
|
14
|
+
return data.map((t, i) => {
|
|
15
|
+
if (t && typeof t === 'object') {
|
|
16
|
+
return {
|
|
17
|
+
text: String(t.text ?? '').trim(),
|
|
18
|
+
done: Boolean(t.done),
|
|
19
|
+
createdAt: t.createdAt ?? null,
|
|
20
|
+
updatedAt: t.updatedAt ?? null,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
return { text: String(t ?? '').trim(), done: false, createdAt: null, updatedAt: null };
|
|
24
|
+
}).filter((t) => t.text.length > 0);
|
|
25
|
+
} catch {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function saveTodos(cwd, todos) {
|
|
31
|
+
writeFileSync(todosFile(cwd), JSON.stringify(todos, null, 2) + '\n', 'utf-8');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function formatTodos(todos) {
|
|
35
|
+
if (!todos || !todos.length) return '(no todos yet)';
|
|
36
|
+
return todos.map((t, i) => `${i + 1}. ${t.done ? '[x]' : '[ ]'} ${t.text}`).join('\n');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function addTodo(cwd, text) {
|
|
40
|
+
const clean = String(text ?? '').trim();
|
|
41
|
+
if (!clean) return { error: 'Missing todo text.' };
|
|
42
|
+
const todos = loadTodos(cwd);
|
|
43
|
+
todos.push({ text: clean, done: false, createdAt: new Date().toISOString(), updatedAt: null });
|
|
44
|
+
saveTodos(cwd, todos);
|
|
45
|
+
return { okay: true };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function updateTodo(cwd, index, { done, text } = {}) {
|
|
49
|
+
const todos = loadTodos(cwd);
|
|
50
|
+
const i = Number(index) - 1;
|
|
51
|
+
if (!Number.isInteger(i) || i < 0 || i >= todos.length) {
|
|
52
|
+
return { error: `No todo #${index}.` };
|
|
53
|
+
}
|
|
54
|
+
if (typeof done === 'boolean') todos[i].done = done;
|
|
55
|
+
if (typeof text === 'string' && text.trim()) todos[i].text = text.trim();
|
|
56
|
+
todos[i].updatedAt = new Date().toISOString();
|
|
57
|
+
saveTodos(cwd, todos);
|
|
58
|
+
return { okay: true };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function clearTodos(cwd) {
|
|
62
|
+
saveTodos(cwd, []);
|
|
63
|
+
return { okay: true };
|
|
64
|
+
}
|
package/src/cmd/tools.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
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';
|
|
6
|
+
import { loadTodos, addTodo, updateTodo, clearTodos, formatTodos } from './todos.js';
|
|
6
7
|
|
|
7
8
|
// Resolve a path relative to the workspace and require it to stay inside.
|
|
8
9
|
// Throws if the path escapes the workspace (or hits the workspace root for
|
|
@@ -105,6 +106,23 @@ export function deleteFile(cwd, path) {
|
|
|
105
106
|
}
|
|
106
107
|
}
|
|
107
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
|
+
|
|
108
126
|
export async function downloadUrl(cwd, url, dir) {
|
|
109
127
|
if (!url || typeof url !== 'string' || !/^https?:\/\//i.test(url.trim())) {
|
|
110
128
|
return fail({ command: 'download_url', stderr: 'Invalid URL. Provide a valid http(s) address.', cwd });
|
|
@@ -124,6 +142,35 @@ export async function downloadUrl(cwd, url, dir) {
|
|
|
124
142
|
}
|
|
125
143
|
}
|
|
126
144
|
|
|
145
|
+
export function todoAdd(cwd, text) {
|
|
146
|
+
const err = addTodo(cwd, text);
|
|
147
|
+
if (err && err.error) return fail({ command: `todo_add("${text}")`, stderr: err.error, cwd });
|
|
148
|
+
return ok({ command: `todo_add("${text}")`, stdout: formatTodos(loadTodos(cwd)), cwd });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function todoList(cwd) {
|
|
152
|
+
try {
|
|
153
|
+
return ok({ command: 'todo_list', stdout: formatTodos(loadTodos(cwd)), cwd });
|
|
154
|
+
} catch (err) {
|
|
155
|
+
return fail({ command: 'todo_list', stderr: err.message, cwd });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function todoUpdate(cwd, index, done, text) {
|
|
160
|
+
const err = updateTodo(cwd, index, { done, text });
|
|
161
|
+
if (err && err.error) return fail({ command: `todo_update(${index})`, stderr: err.error, cwd });
|
|
162
|
+
return ok({ command: `todo_update(${index})`, stdout: formatTodos(loadTodos(cwd)), cwd });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function todoClear(cwd) {
|
|
166
|
+
try {
|
|
167
|
+
clearTodos(cwd);
|
|
168
|
+
return ok({ command: 'todo_clear', stdout: '(no todos yet)', cwd });
|
|
169
|
+
} catch (err) {
|
|
170
|
+
return fail({ command: 'todo_clear', stderr: err.message, cwd });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
127
174
|
// Structured tool registry shared with the agent loop.
|
|
128
175
|
export const TOOL_EXECUTORS = {
|
|
129
176
|
run_command: (executor, params) => executor.runCommand(params.command),
|
|
@@ -133,6 +180,10 @@ export const TOOL_EXECUTORS = {
|
|
|
133
180
|
list_directory: (executor, params) => listDirectory(executor.cwd, params.path || '.'),
|
|
134
181
|
delete_file: (executor, params) => deleteFile(executor.cwd, params.path),
|
|
135
182
|
download_url: (executor, params) => downloadUrl(executor.cwd, params.url, params.dir),
|
|
183
|
+
todo_add: (executor, params) => todoAdd(executor.cwd, params ? params.text : undefined),
|
|
184
|
+
todo_list: (executor, params) => todoList(executor.cwd),
|
|
185
|
+
todo_update: (executor, params) => todoUpdate(executor.cwd, params ? params.index : undefined, params ? params.done : undefined, params ? params.text : undefined),
|
|
186
|
+
todo_clear: (executor, params) => todoClear(executor.cwd),
|
|
136
187
|
};
|
|
137
188
|
|
|
138
189
|
export function makeExecutor({ cwd, timeoutMs } = {}) {
|
package/src/installer.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execSync, exec } from 'child_process';
|
|
2
|
-
import { existsSync, mkdirSync, symlinkSync, chmodSync, writeFileSync } from 'fs';
|
|
3
|
-
import { join } from 'path';
|
|
2
|
+
import { existsSync, mkdirSync, symlinkSync, chmodSync, writeFileSync, lstatSync, readlinkSync, rmSync, realpathSync } from 'fs';
|
|
3
|
+
import { join, dirname, resolve } from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
4
5
|
import { homedir, platform } from 'os';
|
|
5
6
|
import { Catalog } from './catalog.js';
|
|
6
7
|
import { Config } from './config.js';
|
|
@@ -112,19 +113,61 @@ function localBinDir() {
|
|
|
112
113
|
return '/usr/local/bin';
|
|
113
114
|
}
|
|
114
115
|
|
|
115
|
-
function
|
|
116
|
+
function isOnPath(dir) {
|
|
117
|
+
const sep = platform() === 'win32' ? ';' : ':';
|
|
118
|
+
let dirResolved;
|
|
116
119
|
try {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
120
|
+
dirResolved = existsSync(dir) ? realpathSync(dir) : resolve(dir);
|
|
121
|
+
} catch {
|
|
122
|
+
dirResolved = resolve(dir);
|
|
123
|
+
}
|
|
124
|
+
return String(process.env.PATH || '')
|
|
125
|
+
.split(sep)
|
|
126
|
+
.some((entry) => {
|
|
127
|
+
entry = entry.trim();
|
|
128
|
+
if (!entry) return false;
|
|
129
|
+
try {
|
|
130
|
+
const e = existsSync(entry) ? realpathSync(entry) : resolve(entry);
|
|
131
|
+
return e === dirResolved;
|
|
132
|
+
} catch {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Prefer a bin dir that is already on PATH (and writable) so the installed
|
|
139
|
+
// command is callable immediately; fall back to the local bin dir.
|
|
140
|
+
function chooseBinDir() {
|
|
141
|
+
const local = localBinDir();
|
|
142
|
+
if (isOnPath(local)) return local;
|
|
143
|
+
for (const candidate of ['/usr/local/bin', '/usr/bin']) {
|
|
144
|
+
try {
|
|
145
|
+
mkdirSync(candidate, { recursive: true, mode: 0o755 });
|
|
146
|
+
const probe = join(candidate, `.vierrataleai-write-test-${process.pid}`);
|
|
147
|
+
writeFileSync(probe, '');
|
|
148
|
+
rmSync(probe, { force: true });
|
|
149
|
+
return candidate;
|
|
150
|
+
} catch {}
|
|
151
|
+
}
|
|
152
|
+
return local;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Create (or refresh) a launcher link for `name` pointing at the node bin.
|
|
156
|
+
function writeLauncher(dir, name, target) {
|
|
157
|
+
const launcherPath = join(dir, name);
|
|
158
|
+
if (platform() === 'win32') {
|
|
159
|
+
if (!existsSync(launcherPath)) {
|
|
160
|
+
writeFileSync(launcherPath, `@echo off\r\nnode "${target}" %*\r\n`);
|
|
124
161
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
162
|
+
return launcherPath;
|
|
163
|
+
}
|
|
164
|
+
// Prefer a real symlink; fall back to a small shim that calls the node bin.
|
|
165
|
+
let live = false;
|
|
166
|
+
try {
|
|
167
|
+
live = lstatSync(launcherPath).isSymbolicLink() && readlinkSync(launcherPath) === target;
|
|
168
|
+
} catch {}
|
|
169
|
+
if (!live) {
|
|
170
|
+
rmSync(launcherPath, { force: true });
|
|
128
171
|
try {
|
|
129
172
|
symlinkSync(target, launcherPath);
|
|
130
173
|
chmodSync(launcherPath, 0o755);
|
|
@@ -132,6 +175,19 @@ function installAppCommand() {
|
|
|
132
175
|
writeFileSync(launcherPath, `#!/bin/sh\nnode "${target}" "$@"\n`);
|
|
133
176
|
chmodSync(launcherPath, 0o755);
|
|
134
177
|
}
|
|
178
|
+
}
|
|
179
|
+
return launcherPath;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function installAppCommand() {
|
|
183
|
+
try {
|
|
184
|
+
const dir = chooseBinDir();
|
|
185
|
+
mkdirSync(dir, { recursive: true, mode: 0o755 });
|
|
186
|
+
const defaultTarget = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'vierrataleai.js');
|
|
187
|
+
const target = process.argv[1] && existsSync(process.argv[1]) ? process.argv[1] : defaultTarget;
|
|
188
|
+
// Both spellings run the assistant.
|
|
189
|
+
const launcherPath = writeLauncher(dir, 'vierrataleai', target);
|
|
190
|
+
writeLauncher(dir, 'vierratale', target);
|
|
135
191
|
return launcherPath;
|
|
136
192
|
} catch {
|
|
137
193
|
return null;
|
|
@@ -158,6 +214,9 @@ export const Installer = {
|
|
|
158
214
|
|
|
159
215
|
chooseModel,
|
|
160
216
|
|
|
217
|
+
installAppCommand,
|
|
218
|
+
isOnPath,
|
|
219
|
+
|
|
161
220
|
async ensure() {
|
|
162
221
|
const host = Config.get('engineHost');
|
|
163
222
|
|
|
@@ -227,7 +286,15 @@ export const Installer = {
|
|
|
227
286
|
}
|
|
228
287
|
|
|
229
288
|
let command = null;
|
|
230
|
-
|
|
231
|
-
|
|
289
|
+
let commandAlias = null;
|
|
290
|
+
let commandOnPath = false;
|
|
291
|
+
if (includeCommand) {
|
|
292
|
+
command = installAppCommand();
|
|
293
|
+
if (command) {
|
|
294
|
+
commandAlias = join(dirname(command), 'vierratale');
|
|
295
|
+
commandOnPath = isOnPath(dirname(command));
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return { installed: await isEngineInstalled(), running: await isEngineRunning(host), command, commandAlias, commandOnPath };
|
|
232
299
|
},
|
|
233
300
|
};
|
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
|
+
}
|