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/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 { createProvider, detectProvider, sanitizeKey, PROVIDER_INFO, ProviderError, } from './providers/index.js';
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, { cwd, shell: process.platform === 'win32' ? 'powershell.exe' : '/bin/sh', maxBuffer: 1024 * 1024 * 4 }, (err, stdout, stderr) => {
49
- resolve({ stdout: stdout ?? '', stderr: stderr ?? '', code: err?.code ?? 0 });
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 = createProvider(config.provider, config.apiKey, config.model);
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: PROVIDER_INFO[config.provider].label,
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: PROVIDER_INFO[config.provider].label,
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
- const archived = history.splice(0, history.length - KEEP_RECENT);
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(() => { }) // compression must never break the chat
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 = createProvider(config.provider, config.apiKey, model);
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, then a final plain answer.
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 reply = await provider.stream([buildSystem(), ...history], (chunk) => {
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
- // ── Auto-run shell commands suggested by the AI ──────────────
283
- const shellBlocks = extractShellBlocks(reply);
284
- for (const cmd of shellBlocks) {
285
- console.log(accent('▶ run? ') + dim(cmd.slice(0, 120) + (cmd.length > 120 ? '…' : '')));
286
- const yes = await confirm({ message: 'Execute', default: true }).catch(() => false);
287
- if (!yes) {
288
- history.push({ role: 'user', content: `COMMAND SKIPPED: ${cmd}` });
289
- continue;
290
- }
291
- const runSpinner = ora({ text: dim('running…'), spinner: 'dots' }).start();
292
- const { stdout, stderr, code } = await runCommand(cmd, root);
293
- runSpinner.stop();
294
- const output = [
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 = createProvider(config.provider, config.apiKey, config.model);
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
- if (mcpText)
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
+ });