vexi-cli 0.8.0 → 0.9.1
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/detect.js +11 -3
- package/dist/providers/detect.test.js +29 -0
- 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
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in file tools — Feature #1 (structured, deterministic file editing).
|
|
3
|
+
*
|
|
4
|
+
* The original design edited files by asking the model to emit shell commands
|
|
5
|
+
* (`cat >`, `sed -i`, `tee`…) which Vexi then ran, and the snapshot engine
|
|
6
|
+
* *regex-guessed* which files were touched. That is fragile: a mis-escaped
|
|
7
|
+
* `sed` corrupts files, and a missed write leaves nothing to undo.
|
|
8
|
+
*
|
|
9
|
+
* These built-in tools replace that path for editing. They use the same
|
|
10
|
+
* provider-agnostic text protocol as MCP (a fenced ```vexi-tool``` JSON block,
|
|
11
|
+
* WITHOUT a `server` field), so they work with every provider — no native
|
|
12
|
+
* function-calling API required. Because each write goes through here, the
|
|
13
|
+
* snapshot is taken on the *exact* file about to change, making undo precise.
|
|
14
|
+
*/
|
|
15
|
+
import { promises as fs } from 'node:fs';
|
|
16
|
+
import { existsSync } from 'node:fs';
|
|
17
|
+
import { isAbsolute, resolve, relative, sep, dirname } from 'node:path';
|
|
18
|
+
/** Names of the built-in tools the model may call. */
|
|
19
|
+
export const BUILTIN_TOOL_NAMES = ['read_file', 'write_file', 'edit_file'];
|
|
20
|
+
/** Cap on how much file content is returned to the model in one read. */
|
|
21
|
+
const MAX_READ_BYTES = 64 * 1024;
|
|
22
|
+
/**
|
|
23
|
+
* Parse a built-in file-tool call from a model reply, or null.
|
|
24
|
+
* Matches a ```vexi-tool``` block whose `tool` is a known built-in name and
|
|
25
|
+
* which has NO `server` field (that is how MCP tool calls are told apart).
|
|
26
|
+
*/
|
|
27
|
+
export function parseBuiltinToolCall(reply) {
|
|
28
|
+
const match = reply.match(/```vexi-tool\s*\n([\s\S]*?)```/);
|
|
29
|
+
if (!match)
|
|
30
|
+
return null;
|
|
31
|
+
try {
|
|
32
|
+
const json = JSON.parse(match[1]);
|
|
33
|
+
if (typeof json.server === 'string')
|
|
34
|
+
return null; // that's an MCP call
|
|
35
|
+
if (typeof json.tool !== 'string')
|
|
36
|
+
return null;
|
|
37
|
+
if (!BUILTIN_TOOL_NAMES.includes(json.tool))
|
|
38
|
+
return null;
|
|
39
|
+
const { tool, ...args } = json;
|
|
40
|
+
return { tool, args };
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Resolve `rel` against `root`, rejecting paths that escape the project root. */
|
|
47
|
+
function resolveWithinRoot(root, rel) {
|
|
48
|
+
const absRoot = resolve(root);
|
|
49
|
+
const abs = isAbsolute(rel) ? resolve(rel) : resolve(absRoot, rel);
|
|
50
|
+
if (abs !== absRoot && !abs.startsWith(absRoot + sep))
|
|
51
|
+
return null;
|
|
52
|
+
return abs;
|
|
53
|
+
}
|
|
54
|
+
function asString(v) {
|
|
55
|
+
return typeof v === 'string' ? v : null;
|
|
56
|
+
}
|
|
57
|
+
/** Count non-overlapping occurrences of `needle` in `haystack`. */
|
|
58
|
+
function countOccurrences(haystack, needle) {
|
|
59
|
+
if (!needle)
|
|
60
|
+
return 0;
|
|
61
|
+
let count = 0;
|
|
62
|
+
let i = haystack.indexOf(needle);
|
|
63
|
+
while (i !== -1) {
|
|
64
|
+
count++;
|
|
65
|
+
i = haystack.indexOf(needle, i + needle.length);
|
|
66
|
+
}
|
|
67
|
+
return count;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Execute a built-in file tool. Snapshots any file about to be written
|
|
71
|
+
* (via the provided SnapshotManager) so the edit is undoable exactly.
|
|
72
|
+
*/
|
|
73
|
+
export async function executeBuiltinTool(call, root, snapshots) {
|
|
74
|
+
const rawPath = asString(call.args.path);
|
|
75
|
+
if (!rawPath)
|
|
76
|
+
return { result: 'TOOL ERROR: missing "path".', files: [] };
|
|
77
|
+
const abs = resolveWithinRoot(root, rawPath);
|
|
78
|
+
if (!abs)
|
|
79
|
+
return { result: `TOOL ERROR: path "${rawPath}" is outside the project root.`, files: [] };
|
|
80
|
+
const rel = relative(root, abs).replace(/\\/g, '/');
|
|
81
|
+
switch (call.tool) {
|
|
82
|
+
// ── read_file {path, [start], [end]} ────────────────────────────────
|
|
83
|
+
case 'read_file': {
|
|
84
|
+
if (!existsSync(abs))
|
|
85
|
+
return { result: `TOOL ERROR: file not found: ${rel}`, files: [] };
|
|
86
|
+
let content;
|
|
87
|
+
try {
|
|
88
|
+
content = await fs.readFile(abs, 'utf-8');
|
|
89
|
+
}
|
|
90
|
+
catch (e) {
|
|
91
|
+
return { result: `TOOL ERROR: cannot read ${rel}: ${e instanceof Error ? e.message : String(e)}`, files: [] };
|
|
92
|
+
}
|
|
93
|
+
const lines = content.split('\n');
|
|
94
|
+
const start = Number.isInteger(call.args.start) ? Math.max(1, call.args.start) : 1;
|
|
95
|
+
const end = Number.isInteger(call.args.end) ? Math.min(lines.length, call.args.end) : lines.length;
|
|
96
|
+
const slice = lines.slice(start - 1, end);
|
|
97
|
+
let numbered = slice.map((l, i) => `${start + i}\t${l}`).join('\n');
|
|
98
|
+
if (numbered.length > MAX_READ_BYTES) {
|
|
99
|
+
numbered = numbered.slice(0, MAX_READ_BYTES) + '\n… [truncated — read a smaller line range]';
|
|
100
|
+
}
|
|
101
|
+
return { result: `FILE ${rel} (lines ${start}-${end} of ${lines.length}):\n${numbered}`, files: [] };
|
|
102
|
+
}
|
|
103
|
+
// ── write_file {path, content} — create or overwrite ────────────────
|
|
104
|
+
case 'write_file': {
|
|
105
|
+
const content = asString(call.args.content);
|
|
106
|
+
if (content === null)
|
|
107
|
+
return { result: 'TOOL ERROR: missing "content".', files: [] };
|
|
108
|
+
const existed = existsSync(abs);
|
|
109
|
+
if (existed) {
|
|
110
|
+
// Snapshot the current version BEFORE overwriting so undo restores it.
|
|
111
|
+
await snapshots.takeSnapshot([rel], `write_file ${rel}`).catch(() => { });
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
await fs.mkdir(dirname(abs), { recursive: true });
|
|
115
|
+
await fs.writeFile(abs, content, 'utf-8');
|
|
116
|
+
}
|
|
117
|
+
catch (e) {
|
|
118
|
+
return { result: `TOOL ERROR: cannot write ${rel}: ${e instanceof Error ? e.message : String(e)}`, files: [] };
|
|
119
|
+
}
|
|
120
|
+
const lineCount = content.split('\n').length;
|
|
121
|
+
return {
|
|
122
|
+
result: `OK: ${existed ? 'overwrote' : 'created'} ${rel} (${lineCount} lines).`,
|
|
123
|
+
files: [rel],
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
// ── edit_file {path, old, new, [all]} — exact substring replace ──────
|
|
127
|
+
case 'edit_file': {
|
|
128
|
+
const oldStr = asString(call.args.old);
|
|
129
|
+
const newStr = asString(call.args.new);
|
|
130
|
+
if (oldStr === null || newStr === null) {
|
|
131
|
+
return { result: 'TOOL ERROR: edit_file requires string "old" and "new".', files: [] };
|
|
132
|
+
}
|
|
133
|
+
if (oldStr === newStr) {
|
|
134
|
+
return { result: 'TOOL ERROR: "old" and "new" are identical — nothing to change.', files: [] };
|
|
135
|
+
}
|
|
136
|
+
if (!existsSync(abs))
|
|
137
|
+
return { result: `TOOL ERROR: file not found: ${rel}`, files: [] };
|
|
138
|
+
let content;
|
|
139
|
+
try {
|
|
140
|
+
content = await fs.readFile(abs, 'utf-8');
|
|
141
|
+
}
|
|
142
|
+
catch (e) {
|
|
143
|
+
return { result: `TOOL ERROR: cannot read ${rel}: ${e instanceof Error ? e.message : String(e)}`, files: [] };
|
|
144
|
+
}
|
|
145
|
+
const occurrences = countOccurrences(content, oldStr);
|
|
146
|
+
if (occurrences === 0) {
|
|
147
|
+
return { result: `TOOL ERROR: "old" string not found in ${rel}. Read the file and copy the exact text (including whitespace).`, files: [] };
|
|
148
|
+
}
|
|
149
|
+
const all = call.args.all === true;
|
|
150
|
+
if (occurrences > 1 && !all) {
|
|
151
|
+
return {
|
|
152
|
+
result: `TOOL ERROR: "old" matches ${occurrences} places in ${rel}. Add surrounding context to make it unique, or set "all": true to replace every occurrence.`,
|
|
153
|
+
files: [],
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
// Snapshot BEFORE editing so undo restores the pre-edit version exactly.
|
|
157
|
+
await snapshots.takeSnapshot([rel], `edit_file ${rel}`).catch(() => { });
|
|
158
|
+
const updated = all
|
|
159
|
+
? content.split(oldStr).join(newStr)
|
|
160
|
+
: content.replace(oldStr, newStr);
|
|
161
|
+
try {
|
|
162
|
+
await fs.writeFile(abs, updated, 'utf-8');
|
|
163
|
+
}
|
|
164
|
+
catch (e) {
|
|
165
|
+
return { result: `TOOL ERROR: cannot write ${rel}: ${e instanceof Error ? e.message : String(e)}`, files: [] };
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
result: `OK: edited ${rel} (${all ? occurrences : 1} replacement${all && occurrences > 1 ? 's' : ''}).`,
|
|
169
|
+
files: [rel],
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
default:
|
|
173
|
+
return { result: `TOOL ERROR: unknown built-in tool "${call.tool}".`, files: [] };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// ── Native function-calling support ───────────────────────────────────────────
|
|
177
|
+
/** JSON-Schema definitions of the built-in file tools, for native tool calling. */
|
|
178
|
+
export const BUILTIN_TOOL_DEFS = [
|
|
179
|
+
{
|
|
180
|
+
name: 'read_file',
|
|
181
|
+
description: 'Read a file from the project, optionally a 1-based line range. Read before editing.',
|
|
182
|
+
inputSchema: {
|
|
183
|
+
type: 'object',
|
|
184
|
+
properties: {
|
|
185
|
+
path: { type: 'string', description: 'Project-relative file path.' },
|
|
186
|
+
start: { type: 'integer', description: 'First line to read (1-based).' },
|
|
187
|
+
end: { type: 'integer', description: 'Last line to read (inclusive).' },
|
|
188
|
+
},
|
|
189
|
+
required: ['path'],
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
name: 'write_file',
|
|
194
|
+
description: 'Create a new file or fully overwrite an existing one with exact content.',
|
|
195
|
+
inputSchema: {
|
|
196
|
+
type: 'object',
|
|
197
|
+
properties: {
|
|
198
|
+
path: { type: 'string', description: 'Project-relative file path.' },
|
|
199
|
+
content: { type: 'string', description: 'Full file content to write.' },
|
|
200
|
+
},
|
|
201
|
+
required: ['path', 'content'],
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
name: 'edit_file',
|
|
206
|
+
description: 'Replace the exact substring "old" with "new" in a file. "old" must appear verbatim and be unique unless "all" is true.',
|
|
207
|
+
inputSchema: {
|
|
208
|
+
type: 'object',
|
|
209
|
+
properties: {
|
|
210
|
+
path: { type: 'string', description: 'Project-relative file path.' },
|
|
211
|
+
old: { type: 'string', description: 'Exact existing text to replace (include surrounding context to be unique).' },
|
|
212
|
+
new: { type: 'string', description: 'Replacement text.' },
|
|
213
|
+
all: { type: 'boolean', description: 'Replace every occurrence instead of requiring uniqueness.' },
|
|
214
|
+
},
|
|
215
|
+
required: ['path', 'old', 'new'],
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
];
|
|
219
|
+
/** Separator between an MCP server name and tool name in a native tool call. */
|
|
220
|
+
const MCP_SEP = '__';
|
|
221
|
+
/**
|
|
222
|
+
* Build the full native tool list (built-in file tools + connected MCP tools)
|
|
223
|
+
* and a router that maps each tool name back to how it should be executed.
|
|
224
|
+
*/
|
|
225
|
+
export function buildNativeTools(mcp) {
|
|
226
|
+
const route = new Map();
|
|
227
|
+
const defs = [];
|
|
228
|
+
for (const def of BUILTIN_TOOL_DEFS) {
|
|
229
|
+
defs.push(def);
|
|
230
|
+
route.set(def.name, { kind: 'builtin' });
|
|
231
|
+
}
|
|
232
|
+
for (const t of mcp.tools) {
|
|
233
|
+
// Function names must match ^[a-zA-Z0-9_-]+$ — encode "server/tool" safely.
|
|
234
|
+
const name = `${t.server}${MCP_SEP}${t.name}`.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
235
|
+
const schema = (t.inputSchema && typeof t.inputSchema === 'object')
|
|
236
|
+
? t.inputSchema
|
|
237
|
+
: { type: 'object', properties: {} };
|
|
238
|
+
defs.push({ name, description: t.description.split('\n')[0], inputSchema: schema });
|
|
239
|
+
route.set(name, { kind: 'mcp', server: t.server, tool: t.name });
|
|
240
|
+
}
|
|
241
|
+
return { defs, route };
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Execute a native tool call by name, routing to a built-in file tool or an
|
|
245
|
+
* MCP server. Returns the text result plus any project files that changed.
|
|
246
|
+
*/
|
|
247
|
+
export async function dispatchNativeTool(name, args, route, root, snapshots, mcp) {
|
|
248
|
+
const target = route.get(name);
|
|
249
|
+
if (!target)
|
|
250
|
+
return { result: `TOOL ERROR: unknown tool "${name}".`, files: [] };
|
|
251
|
+
if (target.kind === 'builtin') {
|
|
252
|
+
return executeBuiltinTool({ tool: name, args }, root, snapshots);
|
|
253
|
+
}
|
|
254
|
+
try {
|
|
255
|
+
const result = await mcp.callTool(target.server, target.tool, args);
|
|
256
|
+
return { result, files: [] };
|
|
257
|
+
}
|
|
258
|
+
catch (e) {
|
|
259
|
+
return { result: `TOOL ERROR: ${e instanceof Error ? e.message : String(e)}`, files: [] };
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/** System-prompt block describing the built-in file tools. */
|
|
263
|
+
export function builtinToolsBlock() {
|
|
264
|
+
return [
|
|
265
|
+
'## File tools (built-in — prefer these over shell for reading/editing files)',
|
|
266
|
+
'Read and edit files directly and reliably with these tools instead of shell commands',
|
|
267
|
+
'(cat/sed/tee). To call one, reply with ONLY a fenced block and nothing else:',
|
|
268
|
+
'```vexi-tool',
|
|
269
|
+
'{"tool": "edit_file", "path": "src/foo.ts", "old": "<exact text>", "new": "<replacement>"}',
|
|
270
|
+
'```',
|
|
271
|
+
'Available tools:',
|
|
272
|
+
'- read_file {path, [start], [end]} — return file contents, optionally a 1-based line range. Read before editing.',
|
|
273
|
+
'- write_file {path, content} — create a new file or fully overwrite an existing one with exact content.',
|
|
274
|
+
'- edit_file {path, old, new, [all]} — replace the exact substring "old" with "new". "old" must appear verbatim '
|
|
275
|
+
+ 'and be unique unless you set "all": true. Include enough surrounding context to make "old" unique.',
|
|
276
|
+
'The JSON must be valid: escape newlines as \\n and quotes as \\" inside string values. Do NOT include a "server" field '
|
|
277
|
+
+ '(that is reserved for MCP tools). You will receive the tool result in the next message; then continue.',
|
|
278
|
+
'Still use `bash` shell blocks for non-editing work: installing deps, building, running tests, starting servers.',
|
|
279
|
+
].join('\n');
|
|
280
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { parseBuiltinToolCall, executeBuiltinTool, buildNativeTools, dispatchNativeTool } from './index.js';
|
|
6
|
+
import { SnapshotManager } from '../snapshots/index.js';
|
|
7
|
+
// A snapshot manager that writes into a throwaway session dir under root.
|
|
8
|
+
function makeSnapshots(root) {
|
|
9
|
+
return new SnapshotManager(root, 'test-session');
|
|
10
|
+
}
|
|
11
|
+
let root;
|
|
12
|
+
beforeEach(async () => {
|
|
13
|
+
root = await fs.mkdtemp(join(tmpdir(), 'vexi-tools-'));
|
|
14
|
+
});
|
|
15
|
+
afterEach(async () => {
|
|
16
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
17
|
+
});
|
|
18
|
+
// ── parseBuiltinToolCall ──────────────────────────────────────────────────────
|
|
19
|
+
describe('parseBuiltinToolCall', () => {
|
|
20
|
+
it('parses a read_file call', () => {
|
|
21
|
+
const call = parseBuiltinToolCall('```vexi-tool\n{"tool": "read_file", "path": "a.ts"}\n```');
|
|
22
|
+
expect(call).toEqual({ tool: 'read_file', args: { path: 'a.ts' } });
|
|
23
|
+
});
|
|
24
|
+
it('ignores MCP calls (those have a server field)', () => {
|
|
25
|
+
const call = parseBuiltinToolCall('```vexi-tool\n{"server": "github", "tool": "read_file", "arguments": {}}\n```');
|
|
26
|
+
expect(call).toBeNull();
|
|
27
|
+
});
|
|
28
|
+
it('ignores unknown tool names', () => {
|
|
29
|
+
expect(parseBuiltinToolCall('```vexi-tool\n{"tool": "rm_rf", "path": "/"}\n```')).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
it('returns null when no block present', () => {
|
|
32
|
+
expect(parseBuiltinToolCall('just some prose')).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
// ── executeBuiltinTool ────────────────────────────────────────────────────────
|
|
36
|
+
describe('executeBuiltinTool: write_file', () => {
|
|
37
|
+
it('creates a new file', async () => {
|
|
38
|
+
const r = await executeBuiltinTool({ tool: 'write_file', args: { path: 'x.txt', content: 'hello\nworld' } }, root, makeSnapshots(root));
|
|
39
|
+
expect(r.result).toContain('created x.txt');
|
|
40
|
+
expect(await fs.readFile(join(root, 'x.txt'), 'utf-8')).toBe('hello\nworld');
|
|
41
|
+
});
|
|
42
|
+
it('rejects paths outside the project root', async () => {
|
|
43
|
+
const r = await executeBuiltinTool({ tool: 'write_file', args: { path: '../escape.txt', content: 'x' } }, root, makeSnapshots(root));
|
|
44
|
+
expect(r.result).toContain('outside the project root');
|
|
45
|
+
expect(r.files).toEqual([]);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
describe('executeBuiltinTool: edit_file', () => {
|
|
49
|
+
it('replaces a unique substring', async () => {
|
|
50
|
+
await fs.writeFile(join(root, 'a.ts'), 'const x = 1;\nconst y = 2;\n');
|
|
51
|
+
const r = await executeBuiltinTool({ tool: 'edit_file', args: { path: 'a.ts', old: 'const y = 2;', new: 'const y = 3;' } }, root, makeSnapshots(root));
|
|
52
|
+
expect(r.result).toContain('edited a.ts');
|
|
53
|
+
expect(await fs.readFile(join(root, 'a.ts'), 'utf-8')).toBe('const x = 1;\nconst y = 3;\n');
|
|
54
|
+
});
|
|
55
|
+
it('errors when old string is not found', async () => {
|
|
56
|
+
await fs.writeFile(join(root, 'a.ts'), 'const x = 1;\n');
|
|
57
|
+
const r = await executeBuiltinTool({ tool: 'edit_file', args: { path: 'a.ts', old: 'nope', new: 'x' } }, root, makeSnapshots(root));
|
|
58
|
+
expect(r.result).toContain('not found');
|
|
59
|
+
expect(await fs.readFile(join(root, 'a.ts'), 'utf-8')).toBe('const x = 1;\n');
|
|
60
|
+
});
|
|
61
|
+
it('errors on a non-unique match unless all=true', async () => {
|
|
62
|
+
await fs.writeFile(join(root, 'a.ts'), 'a\na\na\n');
|
|
63
|
+
const ambiguous = await executeBuiltinTool({ tool: 'edit_file', args: { path: 'a.ts', old: 'a', new: 'b' } }, root, makeSnapshots(root));
|
|
64
|
+
expect(ambiguous.result).toContain('matches 3 places');
|
|
65
|
+
const all = await executeBuiltinTool({ tool: 'edit_file', args: { path: 'a.ts', old: 'a', new: 'b', all: true } }, root, makeSnapshots(root));
|
|
66
|
+
expect(all.result).toContain('3 replacements');
|
|
67
|
+
expect(await fs.readFile(join(root, 'a.ts'), 'utf-8')).toBe('b\nb\nb\n');
|
|
68
|
+
});
|
|
69
|
+
it('snapshots the pre-edit file so undo restores it exactly', async () => {
|
|
70
|
+
await fs.writeFile(join(root, 'a.ts'), 'original\n');
|
|
71
|
+
const snaps = makeSnapshots(root);
|
|
72
|
+
await executeBuiltinTool({ tool: 'edit_file', args: { path: 'a.ts', old: 'original', new: 'changed' } }, root, snaps);
|
|
73
|
+
expect(await fs.readFile(join(root, 'a.ts'), 'utf-8')).toBe('changed\n');
|
|
74
|
+
const entry = await snaps.undo();
|
|
75
|
+
expect(entry).not.toBeNull();
|
|
76
|
+
expect(await fs.readFile(join(root, 'a.ts'), 'utf-8')).toBe('original\n');
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
// ── native tool routing (Feature #2) ──────────────────────────────────────────
|
|
80
|
+
describe('buildNativeTools', () => {
|
|
81
|
+
it('exposes the three built-in tools plus namespaced MCP tools', () => {
|
|
82
|
+
const mcp = { tools: [{ server: 'github', name: 'search', description: 'Search repos', inputSchema: { type: 'object' } }] };
|
|
83
|
+
const { defs, route } = buildNativeTools(mcp);
|
|
84
|
+
const names = defs.map((d) => d.name);
|
|
85
|
+
expect(names).toContain('read_file');
|
|
86
|
+
expect(names).toContain('write_file');
|
|
87
|
+
expect(names).toContain('edit_file');
|
|
88
|
+
expect(names).toContain('github__search');
|
|
89
|
+
expect(route.get('read_file')).toEqual({ kind: 'builtin' });
|
|
90
|
+
expect(route.get('github__search')).toEqual({ kind: 'mcp', server: 'github', tool: 'search' });
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
describe('dispatchNativeTool', () => {
|
|
94
|
+
it('routes built-in names to the file engine', async () => {
|
|
95
|
+
await fs.writeFile(join(root, 'a.ts'), 'x');
|
|
96
|
+
const mcp = { tools: [] };
|
|
97
|
+
const { route } = buildNativeTools(mcp);
|
|
98
|
+
const r = await dispatchNativeTool('read_file', { path: 'a.ts' }, route, root, makeSnapshots(root), mcp);
|
|
99
|
+
expect(r.result).toContain('FILE a.ts');
|
|
100
|
+
});
|
|
101
|
+
it('routes namespaced names to the MCP server', async () => {
|
|
102
|
+
let called = null;
|
|
103
|
+
const mcp = {
|
|
104
|
+
tools: [{ server: 'github', name: 'search', description: 'd', inputSchema: {} }],
|
|
105
|
+
callTool: async (server, tool) => { called = { server, tool }; return 'mcp-result'; },
|
|
106
|
+
};
|
|
107
|
+
const { route } = buildNativeTools(mcp);
|
|
108
|
+
const r = await dispatchNativeTool('github__search', { q: 'x' }, route, root, makeSnapshots(root), mcp);
|
|
109
|
+
expect(r.result).toBe('mcp-result');
|
|
110
|
+
expect(called).toEqual({ server: 'github', tool: 'search' });
|
|
111
|
+
});
|
|
112
|
+
it('errors on an unknown tool name', async () => {
|
|
113
|
+
const mcp = { tools: [] };
|
|
114
|
+
const { route } = buildNativeTools(mcp);
|
|
115
|
+
const r = await dispatchNativeTool('bogus', {}, route, root, makeSnapshots(root), mcp);
|
|
116
|
+
expect(r.result).toContain('unknown tool');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
describe('executeBuiltinTool: read_file', () => {
|
|
120
|
+
it('returns numbered lines within a range', async () => {
|
|
121
|
+
await fs.writeFile(join(root, 'a.ts'), 'l1\nl2\nl3\nl4\n');
|
|
122
|
+
const r = await executeBuiltinTool({ tool: 'read_file', args: { path: 'a.ts', start: 2, end: 3 } }, root, makeSnapshots(root));
|
|
123
|
+
expect(r.result).toContain('2\tl2');
|
|
124
|
+
expect(r.result).toContain('3\tl3');
|
|
125
|
+
expect(r.result).not.toContain('l4');
|
|
126
|
+
});
|
|
127
|
+
it('errors on a missing file', async () => {
|
|
128
|
+
const r = await executeBuiltinTool({ tool: 'read_file', args: { path: 'nope.ts' } }, root, makeSnapshots(root));
|
|
129
|
+
expect(r.result).toContain('file not found');
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token & cost tracking — Feature #3.
|
|
3
|
+
*
|
|
4
|
+
* Vexi is bring-your-own-key, so the user pays the provider directly. This
|
|
5
|
+
* module accumulates the token usage reported by the provider APIs over a
|
|
6
|
+
* session and estimates the dollar cost from a small built-in price table.
|
|
7
|
+
*
|
|
8
|
+
* Cost is best-effort: when a model isn't in the table (or a provider doesn't
|
|
9
|
+
* report usage) we still show token counts and simply omit the dollar figure.
|
|
10
|
+
*/
|
|
11
|
+
const PRICES = [
|
|
12
|
+
// ── Anthropic ──
|
|
13
|
+
{ match: 'claude-opus', price: { in: 15, out: 75 } },
|
|
14
|
+
{ match: 'claude-3-opus', price: { in: 15, out: 75 } },
|
|
15
|
+
{ match: 'claude-sonnet', price: { in: 3, out: 15 } },
|
|
16
|
+
{ match: 'claude-3-5-sonnet', price: { in: 3, out: 15 } },
|
|
17
|
+
{ match: 'claude-3-7-sonnet', price: { in: 3, out: 15 } },
|
|
18
|
+
{ match: 'claude-haiku', price: { in: 0.8, out: 4 } },
|
|
19
|
+
{ match: 'claude-3-5-haiku', price: { in: 0.8, out: 4 } },
|
|
20
|
+
{ match: 'claude-3-haiku', price: { in: 0.25, out: 1.25 } },
|
|
21
|
+
// ── OpenAI ──
|
|
22
|
+
{ match: 'gpt-4o-mini', price: { in: 0.15, out: 0.6 } },
|
|
23
|
+
{ match: 'gpt-4o', price: { in: 2.5, out: 10 } },
|
|
24
|
+
{ match: 'gpt-4.1-mini', price: { in: 0.4, out: 1.6 } },
|
|
25
|
+
{ match: 'gpt-4.1-nano', price: { in: 0.1, out: 0.4 } },
|
|
26
|
+
{ match: 'gpt-4.1', price: { in: 2, out: 8 } },
|
|
27
|
+
{ match: 'o4-mini', price: { in: 1.1, out: 4.4 } },
|
|
28
|
+
{ match: 'o3-mini', price: { in: 1.1, out: 4.4 } },
|
|
29
|
+
// ── DeepSeek ──
|
|
30
|
+
{ match: 'deepseek-chat', price: { in: 0.27, out: 1.1 } },
|
|
31
|
+
{ match: 'deepseek-reasoner', price: { in: 0.55, out: 2.19 } },
|
|
32
|
+
// ── Mistral ──
|
|
33
|
+
{ match: 'mistral-small', price: { in: 0.2, out: 0.6 } },
|
|
34
|
+
{ match: 'mistral-large', price: { in: 2, out: 6 } },
|
|
35
|
+
// ── Known free tiers (default models) — explicitly zero-cost ──
|
|
36
|
+
{ match: 'llama-3.3-70b', price: { in: 0, out: 0 } },
|
|
37
|
+
{ match: 'gemini-2.5-flash', price: { in: 0, out: 0 } },
|
|
38
|
+
{ match: 'gemini-1.5-flash', price: { in: 0, out: 0 } },
|
|
39
|
+
{ match: 'glm-4-flash', price: { in: 0, out: 0 } },
|
|
40
|
+
{ match: 'qwen-turbo', price: { in: 0, out: 0 } },
|
|
41
|
+
{ match: 'moonshot-v1', price: { in: 0, out: 0 } },
|
|
42
|
+
{ match: 'minimax', price: { in: 0, out: 0 } },
|
|
43
|
+
];
|
|
44
|
+
/** Look up the price for a model id, or null when unknown. */
|
|
45
|
+
export function priceForModel(model) {
|
|
46
|
+
const id = model.toLowerCase();
|
|
47
|
+
let best = null;
|
|
48
|
+
for (const entry of PRICES) {
|
|
49
|
+
if (id.includes(entry.match) && (!best || entry.match.length > best.match.length)) {
|
|
50
|
+
best = entry;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return best?.price ?? null;
|
|
54
|
+
}
|
|
55
|
+
/** Estimate the USD cost of a usage total for a model, or null when unpriced. */
|
|
56
|
+
export function estimateCost(model, usage) {
|
|
57
|
+
const price = priceForModel(model);
|
|
58
|
+
if (!price)
|
|
59
|
+
return null;
|
|
60
|
+
return (usage.inputTokens * price.in + usage.outputTokens * price.out) / 1_000_000;
|
|
61
|
+
}
|
|
62
|
+
/** Accumulates token usage across a session. */
|
|
63
|
+
export class UsageTracker {
|
|
64
|
+
input = 0;
|
|
65
|
+
output = 0;
|
|
66
|
+
turns = 0;
|
|
67
|
+
add(usage) {
|
|
68
|
+
if (usage.inputTokens > 0 || usage.outputTokens > 0) {
|
|
69
|
+
this.input += usage.inputTokens;
|
|
70
|
+
this.output += usage.outputTokens;
|
|
71
|
+
this.turns++;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
get totals() {
|
|
75
|
+
return { inputTokens: this.input, outputTokens: this.output };
|
|
76
|
+
}
|
|
77
|
+
get hasData() {
|
|
78
|
+
return this.turns > 0;
|
|
79
|
+
}
|
|
80
|
+
/** A one-line human summary, e.g. "12,340 tokens (8.1k in / 4.2k out) · ~$0.0123". */
|
|
81
|
+
summary(model) {
|
|
82
|
+
if (!this.hasData)
|
|
83
|
+
return 'No usage reported yet.';
|
|
84
|
+
const total = this.input + this.output;
|
|
85
|
+
const cost = estimateCost(model, this.totals);
|
|
86
|
+
const costPart = cost === null ? '' : ` · ~$${cost.toFixed(4)}`;
|
|
87
|
+
return `${fmt(total)} tokens (${fmt(this.input)} in / ${fmt(this.output)} out)${costPart}`;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** Compact number formatting: 1234 -> "1,234", 12340 -> "12.3k". */
|
|
91
|
+
function fmt(n) {
|
|
92
|
+
if (n < 10_000)
|
|
93
|
+
return n.toLocaleString('en-US');
|
|
94
|
+
return `${(n / 1000).toFixed(1)}k`;
|
|
95
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { UsageTracker, priceForModel, estimateCost } from './index.js';
|
|
3
|
+
describe('priceForModel', () => {
|
|
4
|
+
it('matches the most specific prefix (mini before base)', () => {
|
|
5
|
+
expect(priceForModel('gpt-4o-mini')).toEqual({ in: 0.15, out: 0.6 });
|
|
6
|
+
expect(priceForModel('gpt-4o-2024-08-06')).toEqual({ in: 2.5, out: 10 });
|
|
7
|
+
});
|
|
8
|
+
it('prices known free-tier models at zero', () => {
|
|
9
|
+
expect(priceForModel('gemini-2.5-flash')).toEqual({ in: 0, out: 0 });
|
|
10
|
+
expect(priceForModel('glm-4-flash')).toEqual({ in: 0, out: 0 });
|
|
11
|
+
});
|
|
12
|
+
it('returns null for unknown models', () => {
|
|
13
|
+
expect(priceForModel('some-random-model')).toBeNull();
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
describe('estimateCost', () => {
|
|
17
|
+
it('computes cost from per-million pricing', () => {
|
|
18
|
+
// 1M input @ $3, 1M output @ $15 => $18
|
|
19
|
+
expect(estimateCost('claude-sonnet-5', { inputTokens: 1_000_000, outputTokens: 1_000_000 })).toBeCloseTo(18);
|
|
20
|
+
});
|
|
21
|
+
it('returns null when the model is unpriced', () => {
|
|
22
|
+
expect(estimateCost('mystery', { inputTokens: 100, outputTokens: 100 })).toBeNull();
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
describe('UsageTracker', () => {
|
|
26
|
+
it('accumulates across turns and ignores empty usage', () => {
|
|
27
|
+
const t = new UsageTracker();
|
|
28
|
+
t.add({ inputTokens: 100, outputTokens: 50 });
|
|
29
|
+
t.add({ inputTokens: 0, outputTokens: 0 }); // ignored
|
|
30
|
+
t.add({ inputTokens: 200, outputTokens: 80 });
|
|
31
|
+
expect(t.totals).toEqual({ inputTokens: 300, outputTokens: 130 });
|
|
32
|
+
expect(t.hasData).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
it('summarizes with a cost estimate for priced models', () => {
|
|
35
|
+
const t = new UsageTracker();
|
|
36
|
+
t.add({ inputTokens: 1000, outputTokens: 500 });
|
|
37
|
+
const summary = t.summary('gpt-4o-mini');
|
|
38
|
+
expect(summary).toContain('1,500 tokens');
|
|
39
|
+
expect(summary).toContain('~$');
|
|
40
|
+
});
|
|
41
|
+
it('omits cost for unpriced models but still shows tokens', () => {
|
|
42
|
+
const t = new UsageTracker();
|
|
43
|
+
t.add({ inputTokens: 1000, outputTokens: 500 });
|
|
44
|
+
const summary = t.summary('mystery-model');
|
|
45
|
+
expect(summary).toContain('1,500 tokens');
|
|
46
|
+
expect(summary).not.toContain('$');
|
|
47
|
+
});
|
|
48
|
+
it('reports no data before any usage', () => {
|
|
49
|
+
expect(new UsageTracker().hasData).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
});
|