vexi-cli 0.5.5 → 0.9.0
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 +221 -32
- package/dist/agent.js +330 -42
- package/dist/agent.test.js +66 -0
- package/dist/cli.js +147 -11
- package/dist/config.js +18 -4
- package/dist/endpoint.js +174 -0
- package/dist/endpoint.test.js +146 -0
- package/dist/explain/index.js +1 -7
- package/dist/git/index.js +154 -0
- package/dist/git/index.test.js +178 -0
- package/dist/graph/html.js +2 -8
- package/dist/i18n/index.js +44 -12
- package/dist/mcp/client.js +12 -2
- package/dist/mcp/server.js +4 -3
- package/dist/memory/compress.test.js +52 -0
- package/dist/memory/index.js +10 -5
- package/dist/onboarding.js +105 -0
- package/dist/onboarding.test.js +76 -0
- package/dist/providers/anthropic.js +174 -35
- package/dist/providers/detect.js +18 -3
- package/dist/providers/index.js +60 -7
- package/dist/providers/manifest.js +96 -0
- package/dist/providers/manifest.test.js +71 -0
- package/dist/providers/openai-compat.js +202 -31
- package/dist/providers/openai-compat.test.js +66 -0
- package/dist/providers/types.js +12 -3
- package/dist/replay/export.js +1 -7
- package/dist/skills/index.js +12 -4
- package/dist/snapshots/index.js +278 -0
- package/dist/snapshots/index.test.js +105 -0
- package/dist/tools/index.js +280 -0
- package/dist/tools/index.test.js +131 -0
- package/dist/update/index.js +190 -0
- package/dist/usage/index.js +95 -0
- package/dist/usage/index.test.js +51 -0
- package/dist/utils/html.js +8 -0
- package/dist/version.js +4 -0
- package/package.json +11 -2
package/dist/agent.js
CHANGED
|
@@ -21,17 +21,21 @@ import { input, select, confirm } from '@inquirer/prompts';
|
|
|
21
21
|
import * as nodeRl from 'node:readline';
|
|
22
22
|
import ora from 'ora';
|
|
23
23
|
import { loadConfig, saveConfig, CONFIG_PATH } from './config.js';
|
|
24
|
-
import {
|
|
24
|
+
import { SnapshotManager } from './snapshots/index.js';
|
|
25
|
+
import { createProviderFromConfig, detectProvider, sanitizeKey, refreshManifest, PROVIDER_INFO, ProviderError, } from './providers/index.js';
|
|
25
26
|
import { scanProject, projectSummary } from './scanner/index.js';
|
|
26
27
|
import { loadMemory, saveMemory, memoryBlock, compressIntoMemory, KEEP_RECENT, COMPRESS_INTERVAL, } from './memory/index.js';
|
|
27
28
|
import { loadSkills, skillsBlock } from './skills/index.js';
|
|
28
29
|
import { SessionRecorder } from './replay/recorder.js';
|
|
29
30
|
import { McpManager, parseToolCall } from './mcp/client.js';
|
|
30
31
|
import { loadMcpConfig } from './mcp/config.js';
|
|
32
|
+
import { parseBuiltinToolCall, executeBuiltinTool, builtinToolsBlock, buildNativeTools, dispatchNativeTool } from './tools/index.js';
|
|
33
|
+
import { UsageTracker } from './usage/index.js';
|
|
31
34
|
import { ARABIC_RTL_NOTE, getStrings, t } from './i18n/index.js';
|
|
35
|
+
import { gitPush } from './git/index.js';
|
|
32
36
|
import { accent, dim, err, ok, printBanner, printStatusLine, userPrompt, vexiLabel, warn } from './ui/index.js';
|
|
33
37
|
/** Extract shell commands from fenced code blocks in an AI reply. */
|
|
34
|
-
function extractShellBlocks(reply) {
|
|
38
|
+
export function extractShellBlocks(reply) {
|
|
35
39
|
const blocks = [];
|
|
36
40
|
const re = /```(?:bash|sh|shell|cmd|powershell|ps1)\n([\s\S]*?)```/gi;
|
|
37
41
|
let m;
|
|
@@ -42,11 +46,24 @@ function extractShellBlocks(reply) {
|
|
|
42
46
|
}
|
|
43
47
|
return blocks;
|
|
44
48
|
}
|
|
49
|
+
/** Hard cap on how long an AI-suggested command may run before it's killed. */
|
|
50
|
+
export const COMMAND_TIMEOUT_MS = 2 * 60 * 1000;
|
|
45
51
|
/** Run a shell command and return { stdout, stderr, code }. */
|
|
46
|
-
function runCommand(cmd, cwd) {
|
|
52
|
+
export function runCommand(cmd, cwd, timeoutMs = COMMAND_TIMEOUT_MS) {
|
|
47
53
|
return new Promise((resolve) => {
|
|
48
|
-
cpExec(cmd, {
|
|
49
|
-
|
|
54
|
+
cpExec(cmd, {
|
|
55
|
+
cwd,
|
|
56
|
+
shell: process.platform === 'win32' ? 'powershell.exe' : '/bin/sh',
|
|
57
|
+
maxBuffer: 1024 * 1024 * 4,
|
|
58
|
+
timeout: timeoutMs,
|
|
59
|
+
killSignal: 'SIGKILL',
|
|
60
|
+
}, (err, stdout, stderr) => {
|
|
61
|
+
const timedOut = Boolean(err?.killed && err.signal === 'SIGKILL');
|
|
62
|
+
const extraNote = timedOut ? `\n[vexi] command killed after exceeding ${timeoutMs / 1000}s timeout` : '';
|
|
63
|
+
// A killed process has no real exit code (err.code is undefined) — falling back to
|
|
64
|
+
// 0 would look like success to callers, so use the conventional timeout code (124).
|
|
65
|
+
const code = timedOut ? 124 : (err?.code ?? 0);
|
|
66
|
+
resolve({ stdout: stdout ?? '', stderr: (stderr ?? '') + extraNote, code });
|
|
50
67
|
});
|
|
51
68
|
});
|
|
52
69
|
}
|
|
@@ -116,8 +133,10 @@ export async function runAgent(opts) {
|
|
|
116
133
|
config.lang = opts.lang;
|
|
117
134
|
await saveConfig(config);
|
|
118
135
|
}
|
|
119
|
-
let provider =
|
|
136
|
+
let provider = createProviderFromConfig(config);
|
|
120
137
|
const root = process.cwd();
|
|
138
|
+
// Best-effort: refresh the remote model-default manifest for the next run.
|
|
139
|
+
void refreshManifest();
|
|
121
140
|
// ── Full project understanding: scan + load memory + load skills ──────
|
|
122
141
|
const scanSpinner = ora({ text: dim(s.scanning), spinner: 'dots' }).start();
|
|
123
142
|
let project = null;
|
|
@@ -143,10 +162,15 @@ export async function runAgent(opts) {
|
|
|
143
162
|
for (const f of failed)
|
|
144
163
|
console.log(warn(t(s.mcpFailed, { name: f.name })));
|
|
145
164
|
}
|
|
165
|
+
// ── Snapshot engine: register this session for undo/redo ─────────────
|
|
166
|
+
const snapshotSessionId = Date.now().toString(36);
|
|
167
|
+
const snapshots = new SnapshotManager(root, snapshotSessionId);
|
|
168
|
+
await snapshots.registerAsCurrentSession().catch(() => { });
|
|
146
169
|
// ── Session recording (Vexi Replay) — saved after every turn ─────────
|
|
170
|
+
const providerLabel = config.displayName ?? PROVIDER_INFO[config.provider]?.label ?? config.provider;
|
|
147
171
|
const recorder = new SessionRecorder(root, {
|
|
148
172
|
project: basename(root),
|
|
149
|
-
provider:
|
|
173
|
+
provider: providerLabel,
|
|
150
174
|
model: provider.model,
|
|
151
175
|
lang: opts.lang,
|
|
152
176
|
});
|
|
@@ -154,7 +178,7 @@ export async function runAgent(opts) {
|
|
|
154
178
|
project: project?.stack.length
|
|
155
179
|
? `${basename(root)} (${project.stack.slice(0, 4).join(', ')})`
|
|
156
180
|
: basename(root),
|
|
157
|
-
provider:
|
|
181
|
+
provider: providerLabel,
|
|
158
182
|
model: provider.model,
|
|
159
183
|
lang: opts.lang,
|
|
160
184
|
});
|
|
@@ -164,19 +188,32 @@ export async function runAgent(opts) {
|
|
|
164
188
|
if (skills.length > 0) {
|
|
165
189
|
console.log(dim(t(s.skillsLoaded, { names: skills.map((sk) => sk.name).join(', ') })));
|
|
166
190
|
}
|
|
191
|
+
// Peek at the update check (only picks up already-resolved results; never blocks)
|
|
192
|
+
const latestVersion = opts.updateCheckPromise
|
|
193
|
+
? await Promise.race([
|
|
194
|
+
opts.updateCheckPromise,
|
|
195
|
+
new Promise((r) => setTimeout(() => r(null), 0)),
|
|
196
|
+
])
|
|
197
|
+
: null;
|
|
198
|
+
if (latestVersion) {
|
|
199
|
+
console.log(dim(`Update available: ${opts.version} -> ${latestVersion} run: npm install -g vexi-cli@latest`));
|
|
200
|
+
}
|
|
167
201
|
console.log(dim(s.chatHint) + '\n');
|
|
168
202
|
// ── Chat loop ────────────────────────────────────────────────────────
|
|
169
203
|
const history = [];
|
|
170
204
|
const projectBlock = project ? projectSummary(project) : '';
|
|
171
205
|
const skillsText = skillsBlock(skills);
|
|
172
206
|
let compressing = false;
|
|
207
|
+
const usage = new UsageTracker();
|
|
208
|
+
const trackUsage = (u) => usage.add(u);
|
|
173
209
|
/**
|
|
174
210
|
* The system prompt is rebuilt every turn because the memory block
|
|
175
211
|
* changes as the Context Compression Engine folds in old messages.
|
|
176
212
|
*/
|
|
213
|
+
const nativeToolsEnabled = Boolean(provider.supportsTools && provider.streamTools);
|
|
177
214
|
const buildSystem = () => ({
|
|
178
215
|
role: 'system',
|
|
179
|
-
content: buildSystemPrompt(opts.lang, projectBlock, skillsText, memoryBlock(memory), mcp.promptBlock()),
|
|
216
|
+
content: buildSystemPrompt(opts.lang, projectBlock, skillsText, memoryBlock(memory), mcp.promptBlock(), nativeToolsEnabled),
|
|
180
217
|
});
|
|
181
218
|
/**
|
|
182
219
|
* Running-summary compression: when enough messages have accumulated
|
|
@@ -186,18 +223,59 @@ export async function runAgent(opts) {
|
|
|
186
223
|
const maybeCompress = () => {
|
|
187
224
|
if (compressing || history.length < KEEP_RECENT + COMPRESS_INTERVAL)
|
|
188
225
|
return;
|
|
189
|
-
|
|
226
|
+
// Slice (not splice) — do NOT mutate history before we know compression succeeded.
|
|
227
|
+
const archiveCount = history.length - KEEP_RECENT;
|
|
228
|
+
const archived = history.slice(0, archiveCount);
|
|
190
229
|
compressing = true;
|
|
191
230
|
compressIntoMemory(provider, memory, archived)
|
|
192
231
|
.then(async (updated) => {
|
|
232
|
+
// Only remove from history after a successful archive write.
|
|
233
|
+
history.splice(0, archiveCount);
|
|
193
234
|
memory = updated;
|
|
194
235
|
await saveMemory(root, updated);
|
|
195
236
|
})
|
|
196
|
-
.catch(() => { }) //
|
|
237
|
+
.catch(() => { }) // history intact on failure — retries next turn
|
|
197
238
|
.finally(() => {
|
|
198
239
|
compressing = false;
|
|
199
240
|
});
|
|
200
241
|
};
|
|
242
|
+
// Native function-calling: used when the provider reliably supports it,
|
|
243
|
+
// otherwise the text-based `vexi-tool` protocol below is used instead.
|
|
244
|
+
const nativeTools = provider.supportsTools && provider.streamTools ? buildNativeTools(mcp) : null;
|
|
245
|
+
/**
|
|
246
|
+
* Run any ```bash``` shell blocks in an AI reply (confirm → snapshot → run
|
|
247
|
+
* → feed output back). Shared by the native and text tool paths, since shell
|
|
248
|
+
* commands are proposed as fenced blocks regardless of tool mode.
|
|
249
|
+
*/
|
|
250
|
+
const runShellBlocks = async (reply) => {
|
|
251
|
+
for (const cmd of extractShellBlocks(reply)) {
|
|
252
|
+
console.log(accent('▶ run? ') + dim(cmd.slice(0, 120) + (cmd.length > 120 ? '…' : '')));
|
|
253
|
+
const yes = await confirm({ message: 'Execute', default: true }).catch(() => false);
|
|
254
|
+
if (!yes) {
|
|
255
|
+
history.push({ role: 'user', content: `COMMAND SKIPPED: ${cmd}` });
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
const filesToSnap = SnapshotManager.extractFilePaths(cmd, root);
|
|
259
|
+
if (filesToSnap.length > 0) {
|
|
260
|
+
await snapshots.takeSnapshot(filesToSnap, cmd.slice(0, 80)).catch(() => { });
|
|
261
|
+
}
|
|
262
|
+
const runSpinner = ora({ text: dim('running…'), spinner: 'dots' }).start();
|
|
263
|
+
const { stdout, stderr, code } = await runCommand(cmd, root);
|
|
264
|
+
runSpinner.stop();
|
|
265
|
+
const output = [
|
|
266
|
+
stdout.trim() ? `STDOUT:\n${stdout.trim()}` : '',
|
|
267
|
+
stderr.trim() ? `STDERR:\n${stderr.trim()}` : '',
|
|
268
|
+
`EXIT CODE: ${code}`,
|
|
269
|
+
].filter(Boolean).join('\n');
|
|
270
|
+
console.log(code === 0 ? ok('✓ done') : err(`✗ exit ${code}`));
|
|
271
|
+
if (stdout.trim())
|
|
272
|
+
console.log(dim(stdout.trim().slice(0, 800)));
|
|
273
|
+
if (stderr.trim())
|
|
274
|
+
console.log(warn(stderr.trim().slice(0, 400)));
|
|
275
|
+
history.push({ role: 'user', content: `COMMAND RESULT (${cmd.slice(0, 60)}):\n${output.slice(0, 6000)}` });
|
|
276
|
+
recorder.add('user', `COMMAND RESULT:\n${output.slice(0, 6000)}`);
|
|
277
|
+
}
|
|
278
|
+
};
|
|
201
279
|
while (true) {
|
|
202
280
|
let line;
|
|
203
281
|
try {
|
|
@@ -205,6 +283,8 @@ export async function runAgent(opts) {
|
|
|
205
283
|
}
|
|
206
284
|
catch {
|
|
207
285
|
// Ctrl+C / closed stdin
|
|
286
|
+
if (usage.hasData)
|
|
287
|
+
console.log('\n' + dim(`session usage: ${usage.summary(provider.model)}`));
|
|
208
288
|
console.log('\n' + ok(s.goodbye));
|
|
209
289
|
return;
|
|
210
290
|
}
|
|
@@ -217,9 +297,14 @@ export async function runAgent(opts) {
|
|
|
217
297
|
switch (cmd) {
|
|
218
298
|
case '/exit':
|
|
219
299
|
case '/quit':
|
|
300
|
+
if (usage.hasData)
|
|
301
|
+
console.log(dim(`session usage: ${usage.summary(provider.model)}`));
|
|
220
302
|
console.log(ok(s.goodbye));
|
|
221
303
|
await mcp.close();
|
|
222
304
|
return;
|
|
305
|
+
case '/usage':
|
|
306
|
+
console.log(dim(usage.summary(provider.model)) + '\n');
|
|
307
|
+
continue;
|
|
223
308
|
case '/help':
|
|
224
309
|
console.log(dim(s.helpText) + '\n');
|
|
225
310
|
continue;
|
|
@@ -245,7 +330,7 @@ export async function runAgent(opts) {
|
|
|
245
330
|
if (model) {
|
|
246
331
|
config.model = model;
|
|
247
332
|
await saveConfig(config);
|
|
248
|
-
provider =
|
|
333
|
+
provider = createProviderFromConfig(config);
|
|
249
334
|
console.log(ok(t(s.modelSwitched, { model })) + '\n');
|
|
250
335
|
}
|
|
251
336
|
else {
|
|
@@ -253,6 +338,56 @@ export async function runAgent(opts) {
|
|
|
253
338
|
}
|
|
254
339
|
continue;
|
|
255
340
|
}
|
|
341
|
+
case '/undo': {
|
|
342
|
+
const entry = await snapshots.undo().catch(() => null);
|
|
343
|
+
if (!entry) {
|
|
344
|
+
console.log(dim(s.undoNone) + '\n');
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
console.log(ok(t(s.undoDone, { files: entry.files.join(', ') })) + '\n');
|
|
348
|
+
}
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
case '/redo': {
|
|
352
|
+
const entry = await snapshots.redo().catch(() => null);
|
|
353
|
+
if (!entry) {
|
|
354
|
+
console.log(dim(s.redoNone) + '\n');
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
console.log(ok(t(s.redoDone, { files: entry.files.join(', ') })) + '\n');
|
|
358
|
+
}
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
case '/history': {
|
|
362
|
+
const entries = await snapshots.list().catch(() => []);
|
|
363
|
+
if (entries.length === 0) {
|
|
364
|
+
console.log(dim(s.historyNone) + '\n');
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
console.log(dim(s.historyHeader));
|
|
368
|
+
for (const e of entries) {
|
|
369
|
+
const time = new Date(e.at).toLocaleTimeString();
|
|
370
|
+
console.log(accent(` ${time}`) + dim(` ${e.files.join(', ')}`) + dim(` — ${e.label.slice(0, 60)}`));
|
|
371
|
+
}
|
|
372
|
+
console.log();
|
|
373
|
+
}
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
case '/push': {
|
|
377
|
+
const firstArg = rest[0] ?? '';
|
|
378
|
+
const pushOnly = firstArg === '--only';
|
|
379
|
+
const message = pushOnly || rest.length === 0 ? undefined : rest.join(' ').trim() || undefined;
|
|
380
|
+
await gitPush({
|
|
381
|
+
cwd: root,
|
|
382
|
+
run: runCommand,
|
|
383
|
+
confirm: (m) => confirm({ message: m, default: true }).catch(() => false),
|
|
384
|
+
provider,
|
|
385
|
+
message,
|
|
386
|
+
pushOnly,
|
|
387
|
+
});
|
|
388
|
+
console.log();
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
256
391
|
default:
|
|
257
392
|
console.log(warn(`Unknown command: ${cmd}`) + ' ' + dim('(/help)') + '\n');
|
|
258
393
|
continue;
|
|
@@ -264,45 +399,64 @@ export async function runAgent(opts) {
|
|
|
264
399
|
const spinner = ora({ text: dim(s.thinking), spinner: 'dots' }).start();
|
|
265
400
|
let started = false;
|
|
266
401
|
try {
|
|
267
|
-
// Up to 5 tool-call rounds per user turn
|
|
402
|
+
// Up to 5 tool-call rounds per user turn (round < 5 below), plus one
|
|
403
|
+
// extra final round where a ```vexi-tool``` reply is no longer honored
|
|
404
|
+
// — so the model is forced to give a plain answer. 6 stream() calls total.
|
|
268
405
|
for (let round = 0; round < 6; round++) {
|
|
269
|
-
const
|
|
406
|
+
const onChunk = (chunk) => {
|
|
270
407
|
if (!started) {
|
|
271
408
|
spinner.stop();
|
|
272
409
|
process.stdout.write(vexiLabel + ' ');
|
|
273
410
|
started = true;
|
|
274
411
|
}
|
|
275
412
|
process.stdout.write(chunk);
|
|
276
|
-
}
|
|
413
|
+
};
|
|
414
|
+
// ── Native function-calling path ─────────────────────────────
|
|
415
|
+
if (nativeTools) {
|
|
416
|
+
const { text: reply, toolCalls } = await provider.streamTools([buildSystem(), ...history], nativeTools.defs, onChunk, trackUsage);
|
|
417
|
+
if (!started)
|
|
418
|
+
spinner.stop();
|
|
419
|
+
process.stdout.write('\n\n');
|
|
420
|
+
history.push({
|
|
421
|
+
role: 'assistant',
|
|
422
|
+
content: reply,
|
|
423
|
+
toolCalls: toolCalls.length ? toolCalls : undefined,
|
|
424
|
+
});
|
|
425
|
+
recorder.add('assistant', reply);
|
|
426
|
+
await runShellBlocks(reply);
|
|
427
|
+
if (toolCalls.length === 0 || round === 5)
|
|
428
|
+
break;
|
|
429
|
+
for (const tc of toolCalls) {
|
|
430
|
+
const toolSpinner = ora({ text: dim(`${tc.name}…`), spinner: 'dots' }).start();
|
|
431
|
+
const { result } = await dispatchNativeTool(tc.name, tc.arguments, nativeTools.route, root, snapshots, mcp);
|
|
432
|
+
toolSpinner.stop();
|
|
433
|
+
console.log(result.startsWith('TOOL ERROR') ? err(`${tc.name}: ${result}`) : ok(`✓ ${tc.name}`));
|
|
434
|
+
history.push({ role: 'tool', content: result.slice(0, 8000), toolCallId: tc.id, toolName: tc.name });
|
|
435
|
+
recorder.add('user', `TOOL RESULT (${tc.name}):\n${result.slice(0, 8000)}`);
|
|
436
|
+
}
|
|
437
|
+
started = false; // next round streams with a fresh label
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
// ── Text-based `vexi-tool` path (providers without native tools) ─
|
|
441
|
+
const reply = await provider.stream([buildSystem(), ...history], onChunk, trackUsage);
|
|
277
442
|
if (!started)
|
|
278
443
|
spinner.stop(); // empty reply edge case
|
|
279
444
|
process.stdout.write('\n\n');
|
|
280
445
|
history.push({ role: 'assistant', content: reply });
|
|
281
446
|
recorder.add('assistant', reply);
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
stdout.trim() ? `STDOUT:\n${stdout.trim()}` : '',
|
|
296
|
-
stderr.trim() ? `STDERR:\n${stderr.trim()}` : '',
|
|
297
|
-
`EXIT CODE: ${code}`,
|
|
298
|
-
].filter(Boolean).join('\n');
|
|
299
|
-
console.log(code === 0 ? ok('✓ done') : err(`✗ exit ${code}`));
|
|
300
|
-
if (stdout.trim())
|
|
301
|
-
console.log(dim(stdout.trim().slice(0, 800)));
|
|
302
|
-
if (stderr.trim())
|
|
303
|
-
console.log(warn(stderr.trim().slice(0, 400)));
|
|
304
|
-
history.push({ role: 'user', content: `COMMAND RESULT (${cmd.slice(0, 60)}):\n${output.slice(0, 6000)}` });
|
|
305
|
-
recorder.add('user', `COMMAND RESULT:\n${output.slice(0, 6000)}`);
|
|
447
|
+
await runShellBlocks(reply);
|
|
448
|
+
// Built-in file tool requested by the model? (read/write/edit)
|
|
449
|
+
const builtinCall = round < 5 ? parseBuiltinToolCall(reply) : null;
|
|
450
|
+
if (builtinCall) {
|
|
451
|
+
const fileSpinner = ora({ text: dim(`${builtinCall.tool}…`), spinner: 'dots' }).start();
|
|
452
|
+
const { result } = await executeBuiltinTool(builtinCall, root, snapshots);
|
|
453
|
+
fileSpinner.stop();
|
|
454
|
+
console.log(result.startsWith('TOOL ERROR') ? err(result) : ok(result));
|
|
455
|
+
const toolMessage = `TOOL RESULT (${builtinCall.tool}):\n${result.slice(0, 8000)}`;
|
|
456
|
+
history.push({ role: 'user', content: toolMessage });
|
|
457
|
+
recorder.add('user', toolMessage);
|
|
458
|
+
started = false; // next round streams with a fresh label
|
|
459
|
+
continue;
|
|
306
460
|
}
|
|
307
461
|
// MCP tool call requested by the model?
|
|
308
462
|
const call = mcp.tools.length > 0 && round < 5 ? parseToolCall(reply) : null;
|
|
@@ -338,7 +492,7 @@ export async function runAgent(opts) {
|
|
|
338
492
|
const retry = await confirm({ message: s.reenterKey, default: true }).catch(() => false);
|
|
339
493
|
if (retry) {
|
|
340
494
|
config = await firstRunSetup(s, opts.lang);
|
|
341
|
-
provider =
|
|
495
|
+
provider = createProviderFromConfig(config);
|
|
342
496
|
}
|
|
343
497
|
}
|
|
344
498
|
else {
|
|
@@ -348,6 +502,130 @@ export async function runAgent(opts) {
|
|
|
348
502
|
}
|
|
349
503
|
}
|
|
350
504
|
}
|
|
505
|
+
/**
|
|
506
|
+
* Non-interactive, single-turn mode for scripting/CI (`vexi -p "<prompt>"`).
|
|
507
|
+
* Same system prompt (project scan + memory + skills + MCP tools) and the
|
|
508
|
+
* same tool-call loop as the interactive session, but no readline, no
|
|
509
|
+
* spinners, and no session recording. Requires an existing config — it
|
|
510
|
+
* never runs the interactive BYOK onboarding.
|
|
511
|
+
*/
|
|
512
|
+
export async function runPrint(opts) {
|
|
513
|
+
const config = await loadConfig();
|
|
514
|
+
if (!config) {
|
|
515
|
+
console.error(err('No API key configured yet. Run `vexi` once interactively to set one up, then retry with -p.'));
|
|
516
|
+
process.exitCode = 1;
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
const provider = createProviderFromConfig(config);
|
|
520
|
+
const root = process.cwd();
|
|
521
|
+
let project = null;
|
|
522
|
+
try {
|
|
523
|
+
project = await scanProject(root);
|
|
524
|
+
}
|
|
525
|
+
catch {
|
|
526
|
+
// scanning is best-effort — the prompt still works without it
|
|
527
|
+
}
|
|
528
|
+
const memory = await loadMemory(root);
|
|
529
|
+
const skills = await loadSkills(root);
|
|
530
|
+
const mcp = new McpManager();
|
|
531
|
+
const mcpConfig = await loadMcpConfig();
|
|
532
|
+
if (Object.keys(mcpConfig.mcpServers).length > 0) {
|
|
533
|
+
await mcp.connect().catch(() => { });
|
|
534
|
+
}
|
|
535
|
+
const snapshots = new SnapshotManager(root, Date.now().toString(36));
|
|
536
|
+
await snapshots.registerAsCurrentSession().catch(() => { });
|
|
537
|
+
const projectBlock = project ? projectSummary(project) : '';
|
|
538
|
+
const skillsText = skillsBlock(skills);
|
|
539
|
+
const nativeToolsEnabled = Boolean(provider.supportsTools && provider.streamTools);
|
|
540
|
+
const buildSystem = () => ({
|
|
541
|
+
role: 'system',
|
|
542
|
+
content: buildSystemPrompt(opts.lang, projectBlock, skillsText, memoryBlock(memory), mcp.promptBlock(), nativeToolsEnabled),
|
|
543
|
+
});
|
|
544
|
+
const history = [{ role: 'user', content: opts.prompt }];
|
|
545
|
+
const usage = new UsageTracker();
|
|
546
|
+
const trackUsage = (u) => usage.add(u);
|
|
547
|
+
const nativeTools = provider.supportsTools && provider.streamTools ? buildNativeTools(mcp) : null;
|
|
548
|
+
const runShellBlocks = async (reply) => {
|
|
549
|
+
for (const cmd of extractShellBlocks(reply)) {
|
|
550
|
+
if (!opts.autoYes) {
|
|
551
|
+
console.error(dim(`[skipped — pass --yes to auto-run] $ ${cmd.slice(0, 120)}`));
|
|
552
|
+
history.push({ role: 'user', content: `COMMAND SKIPPED (non-interactive, no --yes flag): ${cmd}` });
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
console.error(accent('$ ') + cmd.slice(0, 120));
|
|
556
|
+
const filesToSnap = SnapshotManager.extractFilePaths(cmd, root);
|
|
557
|
+
if (filesToSnap.length > 0) {
|
|
558
|
+
await snapshots.takeSnapshot(filesToSnap, cmd.slice(0, 80)).catch(() => { });
|
|
559
|
+
}
|
|
560
|
+
const { stdout, stderr, code } = await runCommand(cmd, root);
|
|
561
|
+
console.error(code === 0 ? ok('✓ done') : err(`✗ exit ${code}`));
|
|
562
|
+
const output = [
|
|
563
|
+
stdout.trim() ? `STDOUT:\n${stdout.trim()}` : '',
|
|
564
|
+
stderr.trim() ? `STDERR:\n${stderr.trim()}` : '',
|
|
565
|
+
`EXIT CODE: ${code}`,
|
|
566
|
+
].filter(Boolean).join('\n');
|
|
567
|
+
history.push({ role: 'user', content: `COMMAND RESULT (${cmd.slice(0, 60)}):\n${output.slice(0, 6000)}` });
|
|
568
|
+
}
|
|
569
|
+
};
|
|
570
|
+
try {
|
|
571
|
+
for (let round = 0; round < 6; round++) {
|
|
572
|
+
// ── Native function-calling path ─────────────────────────────
|
|
573
|
+
if (nativeTools) {
|
|
574
|
+
const { text: reply, toolCalls } = await provider.streamTools([buildSystem(), ...history], nativeTools.defs, (chunk) => process.stdout.write(chunk), trackUsage);
|
|
575
|
+
process.stdout.write('\n');
|
|
576
|
+
history.push({
|
|
577
|
+
role: 'assistant',
|
|
578
|
+
content: reply,
|
|
579
|
+
toolCalls: toolCalls.length ? toolCalls : undefined,
|
|
580
|
+
});
|
|
581
|
+
await runShellBlocks(reply);
|
|
582
|
+
if (toolCalls.length === 0 || round === 5)
|
|
583
|
+
break;
|
|
584
|
+
for (const tc of toolCalls) {
|
|
585
|
+
const { result } = await dispatchNativeTool(tc.name, tc.arguments, nativeTools.route, root, snapshots, mcp);
|
|
586
|
+
console.error(result.startsWith('TOOL ERROR') ? err(`${tc.name}: ${result}`) : ok(`✓ ${tc.name}`));
|
|
587
|
+
history.push({ role: 'tool', content: result.slice(0, 8000), toolCallId: tc.id, toolName: tc.name });
|
|
588
|
+
}
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
// ── Text-based `vexi-tool` path ──────────────────────────────
|
|
592
|
+
const reply = await provider.stream([buildSystem(), ...history], (chunk) => {
|
|
593
|
+
process.stdout.write(chunk);
|
|
594
|
+
}, trackUsage);
|
|
595
|
+
process.stdout.write('\n');
|
|
596
|
+
history.push({ role: 'assistant', content: reply });
|
|
597
|
+
await runShellBlocks(reply);
|
|
598
|
+
const builtinCall = round < 5 ? parseBuiltinToolCall(reply) : null;
|
|
599
|
+
if (builtinCall) {
|
|
600
|
+
const { result } = await executeBuiltinTool(builtinCall, root, snapshots);
|
|
601
|
+
console.error(result.startsWith('TOOL ERROR') ? err(result) : ok(result));
|
|
602
|
+
history.push({ role: 'user', content: `TOOL RESULT (${builtinCall.tool}):\n${result.slice(0, 8000)}` });
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
const call = mcp.tools.length > 0 && round < 5 ? parseToolCall(reply) : null;
|
|
606
|
+
if (!call)
|
|
607
|
+
break;
|
|
608
|
+
let result;
|
|
609
|
+
try {
|
|
610
|
+
result = await mcp.callTool(call.server, call.tool, call.arguments);
|
|
611
|
+
}
|
|
612
|
+
catch (e) {
|
|
613
|
+
result = `TOOL ERROR: ${e instanceof Error ? e.message : String(e)}`;
|
|
614
|
+
}
|
|
615
|
+
history.push({ role: 'user', content: `TOOL RESULT (${call.server}/${call.tool}):\n${result.slice(0, 8000)}` });
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
catch (e) {
|
|
619
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
620
|
+
console.error(err(message));
|
|
621
|
+
process.exitCode = 1;
|
|
622
|
+
}
|
|
623
|
+
finally {
|
|
624
|
+
if (usage.hasData)
|
|
625
|
+
console.error(dim(`usage: ${usage.summary(provider.model)}`));
|
|
626
|
+
await mcp.close();
|
|
627
|
+
}
|
|
628
|
+
}
|
|
351
629
|
/** Ask for the API key, auto-detect the provider, save the config. */
|
|
352
630
|
async function firstRunSetup(s, lang) {
|
|
353
631
|
console.log(accent(s.welcome));
|
|
@@ -385,7 +663,7 @@ async function firstRunSetup(s, lang) {
|
|
|
385
663
|
return config;
|
|
386
664
|
}
|
|
387
665
|
/** System prompt for the chat session. */
|
|
388
|
-
function buildSystemPrompt(lang, projectBlock, skillsText, memoryText, mcpText) {
|
|
666
|
+
function buildSystemPrompt(lang, projectBlock, skillsText, memoryText, mcpText, nativeTools = false) {
|
|
389
667
|
const langNames = {
|
|
390
668
|
en: 'English',
|
|
391
669
|
ar: 'Arabic',
|
|
@@ -415,13 +693,23 @@ function buildSystemPrompt(lang, projectBlock, skillsText, memoryText, mcpText)
|
|
|
415
693
|
'Always use `bash` as the code block language tag for commands — never `python`, `java`, etc.',
|
|
416
694
|
'After seeing the output, continue helping based on the result.',
|
|
417
695
|
];
|
|
696
|
+
if (nativeTools) {
|
|
697
|
+
// Native function-calling: file tools (and MCP tools) are advertised
|
|
698
|
+
// through the API's tools parameter, so only a short pointer is needed.
|
|
699
|
+
parts.push('', '## File tools', 'You have native tools to read and edit files (read_file, write_file, edit_file). '
|
|
700
|
+
+ 'Use them to inspect and change files instead of shell cat/sed. Read a file before editing it.');
|
|
701
|
+
}
|
|
702
|
+
else {
|
|
703
|
+
parts.push('', builtinToolsBlock());
|
|
704
|
+
}
|
|
418
705
|
if (projectBlock)
|
|
419
706
|
parts.push('', '## Project map', projectBlock);
|
|
420
707
|
if (memoryText)
|
|
421
708
|
parts.push('', memoryText);
|
|
422
709
|
if (skillsText)
|
|
423
710
|
parts.push('', skillsText);
|
|
424
|
-
|
|
711
|
+
// In native mode, MCP tools are sent via the API — skip the text protocol block.
|
|
712
|
+
if (mcpText && !nativeTools)
|
|
425
713
|
parts.push('', mcpText);
|
|
426
714
|
return parts.join('\n');
|
|
427
715
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { extractShellBlocks, runCommand } from './agent.js';
|
|
4
|
+
describe('extractShellBlocks', () => {
|
|
5
|
+
it('extracts a single bash block', () => {
|
|
6
|
+
const reply = 'Run this:\n```bash\nnpm install\n```\nThen restart.';
|
|
7
|
+
expect(extractShellBlocks(reply)).toEqual(['npm install']);
|
|
8
|
+
});
|
|
9
|
+
it('extracts multiple blocks across different fence languages', () => {
|
|
10
|
+
const reply = [
|
|
11
|
+
'```sh\necho one\n```',
|
|
12
|
+
'some text in between',
|
|
13
|
+
'```powershell\nWrite-Output "two"\n```',
|
|
14
|
+
'```cmd\necho three\n```',
|
|
15
|
+
].join('\n');
|
|
16
|
+
expect(extractShellBlocks(reply)).toEqual(['echo one', 'Write-Output "two"', 'echo three']);
|
|
17
|
+
});
|
|
18
|
+
it('is case-insensitive on the fence language tag', () => {
|
|
19
|
+
const reply = '```BASH\nls -la\n```';
|
|
20
|
+
expect(extractShellBlocks(reply)).toEqual(['ls -la']);
|
|
21
|
+
});
|
|
22
|
+
it('trims whitespace inside the block', () => {
|
|
23
|
+
const reply = '```bash\n\n npm test \n\n```';
|
|
24
|
+
expect(extractShellBlocks(reply)).toEqual(['npm test']);
|
|
25
|
+
});
|
|
26
|
+
it('skips empty blocks', () => {
|
|
27
|
+
const reply = '```bash\n\n\n```\nSome text\n```sh\necho hi\n```';
|
|
28
|
+
expect(extractShellBlocks(reply)).toEqual(['echo hi']);
|
|
29
|
+
});
|
|
30
|
+
it('ignores non-shell fenced blocks (e.g. ```ts, ```json)', () => {
|
|
31
|
+
const reply = '```ts\nconst x = 1;\n```\n```json\n{"a": 1}\n```';
|
|
32
|
+
expect(extractShellBlocks(reply)).toEqual([]);
|
|
33
|
+
});
|
|
34
|
+
it('returns an empty array when the reply has no code fences', () => {
|
|
35
|
+
expect(extractShellBlocks('just a plain text answer, no commands here')).toEqual([]);
|
|
36
|
+
});
|
|
37
|
+
it('handles multiple blocks of the same language', () => {
|
|
38
|
+
const reply = '```bash\necho a\n```\n```bash\necho b\n```';
|
|
39
|
+
expect(extractShellBlocks(reply)).toEqual(['echo a', 'echo b']);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
// Shell-specific commands: runCommand picks powershell.exe on win32, /bin/sh elsewhere.
|
|
43
|
+
const isWin = process.platform === 'win32';
|
|
44
|
+
const echoCmd = (text) => (isWin ? `Write-Output '${text}'` : `echo '${text}'`);
|
|
45
|
+
const sleepCmd = (seconds) => (isWin ? `Start-Sleep -Seconds ${seconds}` : `sleep ${seconds}`);
|
|
46
|
+
describe('runCommand', () => {
|
|
47
|
+
it('captures stdout and a zero exit code on success', async () => {
|
|
48
|
+
const { stdout, code } = await runCommand(echoCmd('hello-vexi'), tmpdir());
|
|
49
|
+
expect(stdout).toContain('hello-vexi');
|
|
50
|
+
expect(code).toBe(0);
|
|
51
|
+
});
|
|
52
|
+
it('captures a non-zero exit code on failure', async () => {
|
|
53
|
+
const { code } = await runCommand('exit 3', tmpdir());
|
|
54
|
+
expect(code).toBe(3);
|
|
55
|
+
});
|
|
56
|
+
it('kills the process and reports a timeout note once the deadline passes', async () => {
|
|
57
|
+
const { stderr, code } = await runCommand(sleepCmd(5), tmpdir(), 300);
|
|
58
|
+
expect(stderr).toContain('timeout');
|
|
59
|
+
expect(code).not.toBe(0);
|
|
60
|
+
}, 10_000);
|
|
61
|
+
it('does not time out when the command finishes well within the deadline', async () => {
|
|
62
|
+
const { stdout, stderr } = await runCommand(echoCmd('fast'), tmpdir(), 10_000);
|
|
63
|
+
expect(stdout).toContain('fast');
|
|
64
|
+
expect(stderr).not.toContain('timeout');
|
|
65
|
+
});
|
|
66
|
+
});
|