vexi-cli 0.8.0 → 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 +105 -22
- package/dist/agent.js +252 -40
- package/dist/agent.test.js +66 -0
- package/dist/cli.js +17 -2
- package/dist/explain/index.js +1 -7
- package/dist/git/index.js +13 -1
- package/dist/graph/html.js +2 -8
- package/dist/i18n/index.js +4 -4
- package/dist/mcp/client.js +5 -1
- package/dist/providers/anthropic.js +171 -33
- package/dist/providers/index.js +19 -2
- package/dist/providers/manifest.js +96 -0
- package/dist/providers/manifest.test.js +71 -0
- package/dist/providers/openai-compat.js +202 -32
- package/dist/providers/openai-compat.test.js +66 -0
- package/dist/providers/types.js +1 -1
- package/dist/replay/export.js +1 -7
- package/dist/skills/index.js +12 -4
- package/dist/snapshots/index.js +21 -4
- package/dist/tools/index.js +280 -0
- package/dist/tools/index.test.js +131 -0
- package/dist/usage/index.js +95 -0
- package/dist/usage/index.test.js +51 -0
- package/dist/utils/html.js +8 -0
- package/package.json +1 -1
package/dist/agent.js
CHANGED
|
@@ -22,18 +22,20 @@ import * as nodeRl from 'node:readline';
|
|
|
22
22
|
import ora from 'ora';
|
|
23
23
|
import { loadConfig, saveConfig, CONFIG_PATH } from './config.js';
|
|
24
24
|
import { SnapshotManager } from './snapshots/index.js';
|
|
25
|
-
import { createProviderFromConfig, detectProvider, sanitizeKey, PROVIDER_INFO, ProviderError, } from './providers/index.js';
|
|
25
|
+
import { createProviderFromConfig, detectProvider, sanitizeKey, refreshManifest, PROVIDER_INFO, ProviderError, } from './providers/index.js';
|
|
26
26
|
import { scanProject, projectSummary } from './scanner/index.js';
|
|
27
27
|
import { loadMemory, saveMemory, memoryBlock, compressIntoMemory, KEEP_RECENT, COMPRESS_INTERVAL, } from './memory/index.js';
|
|
28
28
|
import { loadSkills, skillsBlock } from './skills/index.js';
|
|
29
29
|
import { SessionRecorder } from './replay/recorder.js';
|
|
30
30
|
import { McpManager, parseToolCall } from './mcp/client.js';
|
|
31
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';
|
|
32
34
|
import { ARABIC_RTL_NOTE, getStrings, t } from './i18n/index.js';
|
|
33
35
|
import { gitPush } from './git/index.js';
|
|
34
36
|
import { accent, dim, err, ok, printBanner, printStatusLine, userPrompt, vexiLabel, warn } from './ui/index.js';
|
|
35
37
|
/** Extract shell commands from fenced code blocks in an AI reply. */
|
|
36
|
-
function extractShellBlocks(reply) {
|
|
38
|
+
export function extractShellBlocks(reply) {
|
|
37
39
|
const blocks = [];
|
|
38
40
|
const re = /```(?:bash|sh|shell|cmd|powershell|ps1)\n([\s\S]*?)```/gi;
|
|
39
41
|
let m;
|
|
@@ -44,11 +46,24 @@ function extractShellBlocks(reply) {
|
|
|
44
46
|
}
|
|
45
47
|
return blocks;
|
|
46
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;
|
|
47
51
|
/** Run a shell command and return { stdout, stderr, code }. */
|
|
48
|
-
function runCommand(cmd, cwd) {
|
|
52
|
+
export function runCommand(cmd, cwd, timeoutMs = COMMAND_TIMEOUT_MS) {
|
|
49
53
|
return new Promise((resolve) => {
|
|
50
|
-
cpExec(cmd, {
|
|
51
|
-
|
|
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 });
|
|
52
67
|
});
|
|
53
68
|
});
|
|
54
69
|
}
|
|
@@ -120,6 +135,8 @@ export async function runAgent(opts) {
|
|
|
120
135
|
}
|
|
121
136
|
let provider = createProviderFromConfig(config);
|
|
122
137
|
const root = process.cwd();
|
|
138
|
+
// Best-effort: refresh the remote model-default manifest for the next run.
|
|
139
|
+
void refreshManifest();
|
|
123
140
|
// ── Full project understanding: scan + load memory + load skills ──────
|
|
124
141
|
const scanSpinner = ora({ text: dim(s.scanning), spinner: 'dots' }).start();
|
|
125
142
|
let project = null;
|
|
@@ -187,13 +204,16 @@ export async function runAgent(opts) {
|
|
|
187
204
|
const projectBlock = project ? projectSummary(project) : '';
|
|
188
205
|
const skillsText = skillsBlock(skills);
|
|
189
206
|
let compressing = false;
|
|
207
|
+
const usage = new UsageTracker();
|
|
208
|
+
const trackUsage = (u) => usage.add(u);
|
|
190
209
|
/**
|
|
191
210
|
* The system prompt is rebuilt every turn because the memory block
|
|
192
211
|
* changes as the Context Compression Engine folds in old messages.
|
|
193
212
|
*/
|
|
213
|
+
const nativeToolsEnabled = Boolean(provider.supportsTools && provider.streamTools);
|
|
194
214
|
const buildSystem = () => ({
|
|
195
215
|
role: 'system',
|
|
196
|
-
content: buildSystemPrompt(opts.lang, projectBlock, skillsText, memoryBlock(memory), mcp.promptBlock()),
|
|
216
|
+
content: buildSystemPrompt(opts.lang, projectBlock, skillsText, memoryBlock(memory), mcp.promptBlock(), nativeToolsEnabled),
|
|
197
217
|
});
|
|
198
218
|
/**
|
|
199
219
|
* Running-summary compression: when enough messages have accumulated
|
|
@@ -219,6 +239,43 @@ export async function runAgent(opts) {
|
|
|
219
239
|
compressing = false;
|
|
220
240
|
});
|
|
221
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
|
+
};
|
|
222
279
|
while (true) {
|
|
223
280
|
let line;
|
|
224
281
|
try {
|
|
@@ -226,6 +283,8 @@ export async function runAgent(opts) {
|
|
|
226
283
|
}
|
|
227
284
|
catch {
|
|
228
285
|
// Ctrl+C / closed stdin
|
|
286
|
+
if (usage.hasData)
|
|
287
|
+
console.log('\n' + dim(`session usage: ${usage.summary(provider.model)}`));
|
|
229
288
|
console.log('\n' + ok(s.goodbye));
|
|
230
289
|
return;
|
|
231
290
|
}
|
|
@@ -238,9 +297,14 @@ export async function runAgent(opts) {
|
|
|
238
297
|
switch (cmd) {
|
|
239
298
|
case '/exit':
|
|
240
299
|
case '/quit':
|
|
300
|
+
if (usage.hasData)
|
|
301
|
+
console.log(dim(`session usage: ${usage.summary(provider.model)}`));
|
|
241
302
|
console.log(ok(s.goodbye));
|
|
242
303
|
await mcp.close();
|
|
243
304
|
return;
|
|
305
|
+
case '/usage':
|
|
306
|
+
console.log(dim(usage.summary(provider.model)) + '\n');
|
|
307
|
+
continue;
|
|
244
308
|
case '/help':
|
|
245
309
|
console.log(dim(s.helpText) + '\n');
|
|
246
310
|
continue;
|
|
@@ -335,50 +399,64 @@ export async function runAgent(opts) {
|
|
|
335
399
|
const spinner = ora({ text: dim(s.thinking), spinner: 'dots' }).start();
|
|
336
400
|
let started = false;
|
|
337
401
|
try {
|
|
338
|
-
// 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.
|
|
339
405
|
for (let round = 0; round < 6; round++) {
|
|
340
|
-
const
|
|
406
|
+
const onChunk = (chunk) => {
|
|
341
407
|
if (!started) {
|
|
342
408
|
spinner.stop();
|
|
343
409
|
process.stdout.write(vexiLabel + ' ');
|
|
344
410
|
started = true;
|
|
345
411
|
}
|
|
346
412
|
process.stdout.write(chunk);
|
|
347
|
-
}
|
|
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);
|
|
348
442
|
if (!started)
|
|
349
443
|
spinner.stop(); // empty reply edge case
|
|
350
444
|
process.stdout.write('\n\n');
|
|
351
445
|
history.push({ role: 'assistant', content: reply });
|
|
352
446
|
recorder.add('assistant', reply);
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
const
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
}
|
|
367
|
-
const runSpinner = ora({ text: dim('running…'), spinner: 'dots' }).start();
|
|
368
|
-
const { stdout, stderr, code } = await runCommand(cmd, root);
|
|
369
|
-
runSpinner.stop();
|
|
370
|
-
const output = [
|
|
371
|
-
stdout.trim() ? `STDOUT:\n${stdout.trim()}` : '',
|
|
372
|
-
stderr.trim() ? `STDERR:\n${stderr.trim()}` : '',
|
|
373
|
-
`EXIT CODE: ${code}`,
|
|
374
|
-
].filter(Boolean).join('\n');
|
|
375
|
-
console.log(code === 0 ? ok('✓ done') : err(`✗ exit ${code}`));
|
|
376
|
-
if (stdout.trim())
|
|
377
|
-
console.log(dim(stdout.trim().slice(0, 800)));
|
|
378
|
-
if (stderr.trim())
|
|
379
|
-
console.log(warn(stderr.trim().slice(0, 400)));
|
|
380
|
-
history.push({ role: 'user', content: `COMMAND RESULT (${cmd.slice(0, 60)}):\n${output.slice(0, 6000)}` });
|
|
381
|
-
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;
|
|
382
460
|
}
|
|
383
461
|
// MCP tool call requested by the model?
|
|
384
462
|
const call = mcp.tools.length > 0 && round < 5 ? parseToolCall(reply) : null;
|
|
@@ -424,6 +502,130 @@ export async function runAgent(opts) {
|
|
|
424
502
|
}
|
|
425
503
|
}
|
|
426
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
|
+
}
|
|
427
629
|
/** Ask for the API key, auto-detect the provider, save the config. */
|
|
428
630
|
async function firstRunSetup(s, lang) {
|
|
429
631
|
console.log(accent(s.welcome));
|
|
@@ -461,7 +663,7 @@ async function firstRunSetup(s, lang) {
|
|
|
461
663
|
return config;
|
|
462
664
|
}
|
|
463
665
|
/** System prompt for the chat session. */
|
|
464
|
-
function buildSystemPrompt(lang, projectBlock, skillsText, memoryText, mcpText) {
|
|
666
|
+
function buildSystemPrompt(lang, projectBlock, skillsText, memoryText, mcpText, nativeTools = false) {
|
|
465
667
|
const langNames = {
|
|
466
668
|
en: 'English',
|
|
467
669
|
ar: 'Arabic',
|
|
@@ -491,13 +693,23 @@ function buildSystemPrompt(lang, projectBlock, skillsText, memoryText, mcpText)
|
|
|
491
693
|
'Always use `bash` as the code block language tag for commands — never `python`, `java`, etc.',
|
|
492
694
|
'After seeing the output, continue helping based on the result.',
|
|
493
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
|
+
}
|
|
494
705
|
if (projectBlock)
|
|
495
706
|
parts.push('', '## Project map', projectBlock);
|
|
496
707
|
if (memoryText)
|
|
497
708
|
parts.push('', memoryText);
|
|
498
709
|
if (skillsText)
|
|
499
710
|
parts.push('', skillsText);
|
|
500
|
-
|
|
711
|
+
// In native mode, MCP tools are sent via the API — skip the text protocol block.
|
|
712
|
+
if (mcpText && !nativeTools)
|
|
501
713
|
parts.push('', mcpText);
|
|
502
714
|
return parts.join('\n');
|
|
503
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
|
+
});
|
package/dist/cli.js
CHANGED
|
@@ -17,10 +17,11 @@
|
|
|
17
17
|
* vexi learn [--apply] learn your coding style from past sessions
|
|
18
18
|
*/
|
|
19
19
|
import { Command } from 'commander';
|
|
20
|
+
import { confirm } from '@inquirer/prompts';
|
|
20
21
|
import { VERSION } from './version.js';
|
|
21
22
|
import ora from 'ora';
|
|
22
23
|
import { checkForUpdate, runUpdate, runUninstall } from './update/index.js';
|
|
23
|
-
import { runAgent } from './agent.js';
|
|
24
|
+
import { runAgent, runPrint } from './agent.js';
|
|
24
25
|
import { loadConfig, resetConfig, CONFIG_PATH } from './config.js';
|
|
25
26
|
import { loadSkills, addSkill, removeSkill } from './skills/index.js';
|
|
26
27
|
import { listSessions } from './replay/recorder.js';
|
|
@@ -57,6 +58,8 @@ export function buildCli() {
|
|
|
57
58
|
.description('Open-source AI coding agent for your terminal. BYOK, zero config, multilingual.')
|
|
58
59
|
.version(VERSION, '-v, --version')
|
|
59
60
|
.option('-l, --lang <lang>', `UI language (${SUPPORTED_LANGS.join('/')})`)
|
|
61
|
+
.option('-p, --print <prompt>', 'non-interactive: send one prompt, print the reply, exit (for scripts/CI)')
|
|
62
|
+
.option('-y, --yes', 'with --print, auto-run any shell commands the model proposes (no confirmation)')
|
|
60
63
|
.option('--mcp-server', 'run Vexi as an MCP server over stdio (for Claude Desktop, Cursor, etc.)')
|
|
61
64
|
.option('--no-update-check', 'skip the daily background update check')
|
|
62
65
|
.action(async (options) => {
|
|
@@ -67,6 +70,10 @@ export function buildCli() {
|
|
|
67
70
|
return;
|
|
68
71
|
}
|
|
69
72
|
const lang = await resolveLang(options.lang);
|
|
73
|
+
if (options.print) {
|
|
74
|
+
await runPrint({ prompt: options.print, lang, autoYes: !!options.yes });
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
70
77
|
const noUpdateCheck = !options.updateCheck || !!process.env['VEXI_NO_UPDATE_CHECK'];
|
|
71
78
|
const updateCheckPromise = noUpdateCheck ? Promise.resolve(null) : checkForUpdate();
|
|
72
79
|
await runAgent({ lang, version: VERSION, updateCheckPromise });
|
|
@@ -123,7 +130,15 @@ export function buildCli() {
|
|
|
123
130
|
.action(async (source) => {
|
|
124
131
|
const s = getStrings(await resolveLang());
|
|
125
132
|
try {
|
|
126
|
-
const name = await addSkill(process.cwd(), source
|
|
133
|
+
const name = await addSkill(process.cwd(), source, {
|
|
134
|
+
confirmRemoteContent: async (content, skillName, sourceUrl) => {
|
|
135
|
+
console.log(dim(`\nFetched from ${sourceUrl} — this will be added to every prompt as an instruction the AI must follow:\n`));
|
|
136
|
+
console.log(dim('─'.repeat(60)));
|
|
137
|
+
console.log(content.slice(0, 2000) + (content.length > 2000 ? dim('\n… (truncated)') : ''));
|
|
138
|
+
console.log(dim('─'.repeat(60)));
|
|
139
|
+
return confirm({ message: `Save as skill "${skillName}"?`, default: false });
|
|
140
|
+
},
|
|
141
|
+
});
|
|
127
142
|
console.log(ok(t(s.skillAdded, { name })));
|
|
128
143
|
}
|
|
129
144
|
catch (e) {
|
package/dist/explain/index.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { promises as fs } from 'node:fs';
|
|
14
14
|
import { basename, join, relative, resolve } from 'node:path';
|
|
15
|
+
import { escapeHtml } from '../utils/html.js';
|
|
15
16
|
/** Limits to keep prompts within context windows. */
|
|
16
17
|
const MAX_FILE_BYTES = 100 * 1024;
|
|
17
18
|
const MAX_TOTAL_BYTES = 120 * 1024;
|
|
@@ -229,10 +230,3 @@ function inline(text) {
|
|
|
229
230
|
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
|
230
231
|
.replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
231
232
|
}
|
|
232
|
-
function escapeHtml(text) {
|
|
233
|
-
return text
|
|
234
|
-
.replaceAll('&', '&')
|
|
235
|
-
.replaceAll('<', '<')
|
|
236
|
-
.replaceAll('>', '>')
|
|
237
|
-
.replaceAll('"', '"');
|
|
238
|
-
}
|
package/dist/git/index.js
CHANGED
|
@@ -10,6 +10,18 @@
|
|
|
10
10
|
function shellQuote(s) {
|
|
11
11
|
return "'" + s.replace(/'/g, "'\\''") + "'";
|
|
12
12
|
}
|
|
13
|
+
/** Max length for a model-drafted commit message (well above conventional-commit norms). */
|
|
14
|
+
const MAX_COMMIT_MSG_LEN = 200;
|
|
15
|
+
/**
|
|
16
|
+
* Take the model's raw reply and turn it into a safe single-line commit message:
|
|
17
|
+
* first line only, control/escape characters stripped (so a malicious or broken
|
|
18
|
+
* reply can't inject terminal escape sequences when echoed back), length-capped.
|
|
19
|
+
*/
|
|
20
|
+
function sanitizeCommitMessage(raw) {
|
|
21
|
+
const firstLine = raw.replace(/`/g, '').split('\n')[0] ?? '';
|
|
22
|
+
const noControlChars = firstLine.replace(/[\x00-\x1f\x7f]/g, '');
|
|
23
|
+
return noControlChars.trim().slice(0, MAX_COMMIT_MSG_LEN);
|
|
24
|
+
}
|
|
13
25
|
const AUTH_SIGNATURES = [
|
|
14
26
|
'could not read',
|
|
15
27
|
'authentication failed',
|
|
@@ -78,7 +90,7 @@ export async function gitPush(opts) {
|
|
|
78
90
|
];
|
|
79
91
|
try {
|
|
80
92
|
const raw = await provider.stream(messages, () => { });
|
|
81
|
-
commitMsg = raw
|
|
93
|
+
commitMsg = sanitizeCommitMessage(raw);
|
|
82
94
|
}
|
|
83
95
|
catch {
|
|
84
96
|
// fall through to default below
|
package/dist/graph/html.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { promises as fs } from 'node:fs';
|
|
14
14
|
import { join } from 'node:path';
|
|
15
|
+
import { escapeHtml } from '../utils/html.js';
|
|
15
16
|
const LABELS = {
|
|
16
17
|
en: {
|
|
17
18
|
title: 'Vexi code graph',
|
|
@@ -85,7 +86,7 @@ export function buildGraphHtml(graph, lang) {
|
|
|
85
86
|
<meta charset="utf-8">
|
|
86
87
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
87
88
|
<title>${L.title} — ${escapeHtml(graph.project)}</title>
|
|
88
|
-
<script src="https://cdn.jsdelivr.net/npm/d3@7"></script>
|
|
89
|
+
<script src="https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js" integrity="sha384-CjloA8y00+1SDAUkjs099PVfnY2KmDC2BZnws9kh8D/lX1s46w6EPhpXdqMfjK6i" crossorigin="anonymous"></script>
|
|
89
90
|
<style>
|
|
90
91
|
:root { --accent: #2979FF; --bg: #0a0a0f; --panel: #12121a; --text: #e8e8f0; --dim: #8888a0; }
|
|
91
92
|
* { box-sizing: border-box; }
|
|
@@ -216,10 +217,3 @@ document.getElementById('search').addEventListener('input', (ev) => {
|
|
|
216
217
|
</html>
|
|
217
218
|
`;
|
|
218
219
|
}
|
|
219
|
-
function escapeHtml(text) {
|
|
220
|
-
return text
|
|
221
|
-
.replaceAll('&', '&')
|
|
222
|
-
.replaceAll('<', '<')
|
|
223
|
-
.replaceAll('>', '>')
|
|
224
|
-
.replaceAll('"', '"');
|
|
225
|
-
}
|
package/dist/i18n/index.js
CHANGED
|
@@ -26,7 +26,7 @@ const en = {
|
|
|
26
26
|
apiError: 'API error: {message}',
|
|
27
27
|
emptyKey: 'No key entered.',
|
|
28
28
|
historyCleared: 'Conversation history cleared.',
|
|
29
|
-
helpText: '/help show this help\n/model switch model (e.g. /model gpt-4o)\n/memory show compressed project memory\n/clear clear conversation history\n/undo revert last AI file edit\n/redo re-apply last undone edit\n/history list recent AI file edits\n/push stage, commit and push to git (/push --only to skip commit)\n/exit quit Vexi',
|
|
29
|
+
helpText: '/help show this help\n/model switch model (e.g. /model gpt-4o)\n/memory show compressed project memory\n/clear clear conversation history\n/undo revert last AI file edit\n/redo re-apply last undone edit\n/history list recent AI file edits\n/push stage, commit and push to git (/push --only to skip commit)\n/usage token & cost estimate this session\n/exit quit Vexi',
|
|
30
30
|
modelSwitched: 'Model switched to {model}',
|
|
31
31
|
configReset: 'Configuration deleted. Run `vexi` to set up again.',
|
|
32
32
|
configResetNone: 'No configuration found.',
|
|
@@ -84,7 +84,7 @@ const es = {
|
|
|
84
84
|
apiError: 'Error de API: {message}',
|
|
85
85
|
emptyKey: 'No se introdujo ninguna clave.',
|
|
86
86
|
historyCleared: 'Historial de conversación borrado.',
|
|
87
|
-
helpText: '/help mostrar esta ayuda\n/model cambiar modelo (p. ej. /model gpt-4o)\n/memory ver la memoria comprimida del proyecto\n/clear borrar historial de conversación\n/push confirmar y enviar cambios a git\n/exit salir de Vexi',
|
|
87
|
+
helpText: '/help mostrar esta ayuda\n/model cambiar modelo (p. ej. /model gpt-4o)\n/memory ver la memoria comprimida del proyecto\n/clear borrar historial de conversación\n/push confirmar y enviar cambios a git\n/usage tokens y coste estimado de la sesión\n/exit salir de Vexi',
|
|
88
88
|
modelSwitched: 'Modelo cambiado a {model}',
|
|
89
89
|
configReset: 'Configuración eliminada. Ejecuta `vexi` para configurar de nuevo.',
|
|
90
90
|
configResetNone: 'No se encontró configuración.',
|
|
@@ -142,7 +142,7 @@ const pt = {
|
|
|
142
142
|
apiError: 'Erro de API: {message}',
|
|
143
143
|
emptyKey: 'Nenhuma chave inserida.',
|
|
144
144
|
historyCleared: 'Histórico de conversa apagado.',
|
|
145
|
-
helpText: '/help mostrar esta ajuda\n/model trocar modelo (ex.: /model gpt-4o)\n/memory ver a memória comprimida do projeto\n/clear apagar histórico de conversa\n/push confirmar e enviar mudanças ao git\n/exit sair do Vexi',
|
|
145
|
+
helpText: '/help mostrar esta ajuda\n/model trocar modelo (ex.: /model gpt-4o)\n/memory ver a memória comprimida do projeto\n/clear apagar histórico de conversa\n/push confirmar e enviar mudanças ao git\n/usage tokens e custo estimado da sessão\n/exit sair do Vexi',
|
|
146
146
|
modelSwitched: 'Modelo alterado para {model}',
|
|
147
147
|
configReset: 'Configuração excluída. Execute `vexi` para configurar novamente.',
|
|
148
148
|
configResetNone: 'Nenhuma configuração encontrada.',
|
|
@@ -200,7 +200,7 @@ const fr = {
|
|
|
200
200
|
apiError: 'Erreur API : {message}',
|
|
201
201
|
emptyKey: 'Aucune clé saisie.',
|
|
202
202
|
historyCleared: 'Historique de conversation effacé.',
|
|
203
|
-
helpText: '/help afficher cette aide\n/model changer de modèle (ex. /model gpt-4o)\n/memory voir la mémoire compressée du projet\n/clear effacer l\'historique de conversation\n/push valider et pousser les modifications git\n/exit quitter Vexi',
|
|
203
|
+
helpText: '/help afficher cette aide\n/model changer de modèle (ex. /model gpt-4o)\n/memory voir la mémoire compressée du projet\n/clear effacer l\'historique de conversation\n/push valider et pousser les modifications git\n/usage tokens et coût estimé de la session\n/exit quitter Vexi',
|
|
204
204
|
modelSwitched: 'Modèle changé pour {model}',
|
|
205
205
|
configReset: 'Configuration supprimée. Lancez `vexi` pour reconfigurer.',
|
|
206
206
|
configResetNone: 'Aucune configuration trouvée.',
|
package/dist/mcp/client.js
CHANGED
|
@@ -57,7 +57,11 @@ export class McpManager {
|
|
|
57
57
|
const parsed = McpManager.ArgsSchema.safeParse(args);
|
|
58
58
|
if (!parsed.success)
|
|
59
59
|
throw new Error(`Invalid tool arguments: ${parsed.error.message}`);
|
|
60
|
-
|
|
60
|
+
// Explicit timeout (matches the SDK default) so a hung MCP server can
|
|
61
|
+
// never freeze the chat loop, regardless of future SDK default changes.
|
|
62
|
+
const result = await connection.client.callTool({ name: tool, arguments: parsed.data }, undefined, {
|
|
63
|
+
timeout: 60_000,
|
|
64
|
+
});
|
|
61
65
|
const content = Array.isArray(result.content) ? result.content : [];
|
|
62
66
|
const text = content
|
|
63
67
|
.map((c) => (c.type === 'text' ? (c.text ?? '') : `[${c.type}]`))
|