vexi-cli 0.5.5 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +221 -32
- package/dist/agent.js +330 -42
- package/dist/agent.test.js +66 -0
- package/dist/cli.js +147 -11
- package/dist/config.js +18 -4
- package/dist/endpoint.js +174 -0
- package/dist/endpoint.test.js +146 -0
- package/dist/explain/index.js +1 -7
- package/dist/git/index.js +154 -0
- package/dist/git/index.test.js +178 -0
- package/dist/graph/html.js +2 -8
- package/dist/i18n/index.js +44 -12
- package/dist/mcp/client.js +12 -2
- package/dist/mcp/server.js +4 -3
- package/dist/memory/compress.test.js +52 -0
- package/dist/memory/index.js +10 -5
- package/dist/onboarding.js +105 -0
- package/dist/onboarding.test.js +76 -0
- package/dist/providers/anthropic.js +174 -35
- package/dist/providers/detect.js +18 -3
- package/dist/providers/index.js +60 -7
- package/dist/providers/manifest.js +96 -0
- package/dist/providers/manifest.test.js +71 -0
- package/dist/providers/openai-compat.js +202 -31
- package/dist/providers/openai-compat.test.js +66 -0
- package/dist/providers/types.js +12 -3
- package/dist/replay/export.js +1 -7
- package/dist/skills/index.js +12 -4
- package/dist/snapshots/index.js +278 -0
- package/dist/snapshots/index.test.js +105 -0
- package/dist/tools/index.js +280 -0
- package/dist/tools/index.test.js +131 -0
- package/dist/update/index.js +190 -0
- package/dist/usage/index.js +95 -0
- package/dist/usage/index.test.js +51 -0
- package/dist/utils/html.js +8 -0
- package/dist/version.js +4 -0
- package/package.json +11 -2
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snapshot engine for undo/redo of AI file edits.
|
|
3
|
+
*
|
|
4
|
+
* Before executing any shell command that looks like a file write, Vexi
|
|
5
|
+
* saves a copy of only the affected files into:
|
|
6
|
+
*
|
|
7
|
+
* .vexi/snapshots/<session-id>/<entry-id>/files/<encoded-path>
|
|
8
|
+
*
|
|
9
|
+
* State (undo/redo stacks) is persisted in:
|
|
10
|
+
* .vexi/snapshots/<session-id>/state.json
|
|
11
|
+
*
|
|
12
|
+
* The active session is tracked in:
|
|
13
|
+
* .vexi/snapshots/current-session
|
|
14
|
+
*
|
|
15
|
+
* Design choices:
|
|
16
|
+
* - Only snapshots files it is about to change (NOT the whole project tree).
|
|
17
|
+
* - Max 50 snapshots per session; oldest pruned automatically.
|
|
18
|
+
* - Undo restores the pre-command file state; redo re-applies the reverted state.
|
|
19
|
+
* - New files created by a command are not deleted on undo (limitation v1).
|
|
20
|
+
*/
|
|
21
|
+
import { promises as fs } from 'node:fs';
|
|
22
|
+
import { existsSync } from 'node:fs';
|
|
23
|
+
import { join, isAbsolute, relative, dirname, resolve, sep } from 'node:path';
|
|
24
|
+
import { writeJsonAtomic, readJson } from '../utils/fs-atomic.js';
|
|
25
|
+
/**
|
|
26
|
+
* Resolve `rel` against `root` and verify the result stays inside `root`.
|
|
27
|
+
* Blocks path traversal (e.g. a relative path containing `..` segments)
|
|
28
|
+
* from letting snapshot save/restore read or overwrite files outside the
|
|
29
|
+
* project — snapshots are only ever meant to cover project files.
|
|
30
|
+
*/
|
|
31
|
+
function resolveWithinRoot(root, rel) {
|
|
32
|
+
const absRoot = resolve(root);
|
|
33
|
+
const abs = isAbsolute(rel) ? resolve(rel) : resolve(absRoot, rel);
|
|
34
|
+
if (abs !== absRoot && !abs.startsWith(absRoot + sep))
|
|
35
|
+
return null;
|
|
36
|
+
return abs;
|
|
37
|
+
}
|
|
38
|
+
const MAX_SNAPSHOTS = 50;
|
|
39
|
+
function makeId() {
|
|
40
|
+
return Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
|
|
41
|
+
}
|
|
42
|
+
function encodeRelPath(relPath) {
|
|
43
|
+
return relPath.replace(/[\\/]/g, '__').replace(/:/g, '_C_');
|
|
44
|
+
}
|
|
45
|
+
export class SnapshotManager {
|
|
46
|
+
root;
|
|
47
|
+
sessionId;
|
|
48
|
+
sessionDir;
|
|
49
|
+
statePath;
|
|
50
|
+
_state = null;
|
|
51
|
+
constructor(root, sessionId) {
|
|
52
|
+
this.root = root;
|
|
53
|
+
this.sessionId = sessionId;
|
|
54
|
+
this.sessionDir = join(root, '.vexi', 'snapshots', sessionId);
|
|
55
|
+
this.statePath = join(this.sessionDir, 'state.json');
|
|
56
|
+
}
|
|
57
|
+
/** Create a manager pointing at the most-recently registered session. */
|
|
58
|
+
static async forCurrentSession(root) {
|
|
59
|
+
const markerPath = join(root, '.vexi', 'snapshots', 'current-session');
|
|
60
|
+
try {
|
|
61
|
+
const id = (await fs.readFile(markerPath, 'utf-8')).trim();
|
|
62
|
+
if (id)
|
|
63
|
+
return new SnapshotManager(root, id);
|
|
64
|
+
}
|
|
65
|
+
catch { }
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
/** Write the "current-session" marker so CLI commands can find this session. */
|
|
69
|
+
async registerAsCurrentSession() {
|
|
70
|
+
const markerPath = join(this.root, '.vexi', 'snapshots', 'current-session');
|
|
71
|
+
await fs.mkdir(dirname(markerPath), { recursive: true });
|
|
72
|
+
await fs.writeFile(markerPath, this.sessionId, 'utf-8');
|
|
73
|
+
}
|
|
74
|
+
async getState() {
|
|
75
|
+
if (this._state)
|
|
76
|
+
return this._state;
|
|
77
|
+
const loaded = await readJson(this.statePath);
|
|
78
|
+
this._state = loaded ?? {
|
|
79
|
+
sessionId: this.sessionId,
|
|
80
|
+
undoStack: [],
|
|
81
|
+
redoStack: [],
|
|
82
|
+
entries: {},
|
|
83
|
+
};
|
|
84
|
+
return this._state;
|
|
85
|
+
}
|
|
86
|
+
async persistState() {
|
|
87
|
+
if (this._state)
|
|
88
|
+
await writeJsonAtomic(this.statePath, this._state);
|
|
89
|
+
}
|
|
90
|
+
async saveFiles(id, relPaths) {
|
|
91
|
+
const dir = join(this.sessionDir, id, 'files');
|
|
92
|
+
await fs.mkdir(dir, { recursive: true });
|
|
93
|
+
const saved = [];
|
|
94
|
+
for (const rel of relPaths) {
|
|
95
|
+
const abs = resolveWithinRoot(this.root, rel);
|
|
96
|
+
if (!abs || !existsSync(abs))
|
|
97
|
+
continue;
|
|
98
|
+
await fs.copyFile(abs, join(dir, encodeRelPath(rel)));
|
|
99
|
+
saved.push(rel);
|
|
100
|
+
}
|
|
101
|
+
return saved;
|
|
102
|
+
}
|
|
103
|
+
async restoreFiles(id, relPaths) {
|
|
104
|
+
const dir = join(this.sessionDir, id, 'files');
|
|
105
|
+
for (const rel of relPaths) {
|
|
106
|
+
const dest = resolveWithinRoot(this.root, rel);
|
|
107
|
+
if (!dest)
|
|
108
|
+
continue;
|
|
109
|
+
try {
|
|
110
|
+
await fs.copyFile(join(dir, encodeRelPath(rel)), dest);
|
|
111
|
+
}
|
|
112
|
+
catch { }
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
async deleteEntry(state, id) {
|
|
116
|
+
delete state.entries[id];
|
|
117
|
+
await fs.rm(join(this.sessionDir, id), { recursive: true, force: true }).catch(() => { });
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Save the current state of `files` before applying an edit.
|
|
121
|
+
* Call this right before running the shell command.
|
|
122
|
+
* Returns true if at least one file was snapshotted.
|
|
123
|
+
*/
|
|
124
|
+
async takeSnapshot(files, label) {
|
|
125
|
+
if (files.length === 0)
|
|
126
|
+
return false;
|
|
127
|
+
const state = await this.getState();
|
|
128
|
+
const id = makeId();
|
|
129
|
+
const saved = await this.saveFiles(id, files);
|
|
130
|
+
if (saved.length === 0)
|
|
131
|
+
return false;
|
|
132
|
+
state.entries[id] = { id, at: Date.now(), label, files: saved };
|
|
133
|
+
state.undoStack.push(id);
|
|
134
|
+
state.redoStack = []; // new edit invalidates redo history
|
|
135
|
+
while (state.undoStack.length > MAX_SNAPSHOTS) {
|
|
136
|
+
await this.deleteEntry(state, state.undoStack.shift());
|
|
137
|
+
}
|
|
138
|
+
await this.persistState();
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
/** Restore the last snapshotted file state. Returns the reverted entry, or null if nothing to undo. */
|
|
142
|
+
async undo() {
|
|
143
|
+
const state = await this.getState();
|
|
144
|
+
if (state.undoStack.length === 0)
|
|
145
|
+
return null;
|
|
146
|
+
const id = state.undoStack[state.undoStack.length - 1];
|
|
147
|
+
const entry = state.entries[id];
|
|
148
|
+
if (!entry)
|
|
149
|
+
return null;
|
|
150
|
+
// Save current (post-edit) state as a redo point covering the same files
|
|
151
|
+
const redoId = makeId();
|
|
152
|
+
const redoSaved = await this.saveFiles(redoId, entry.files);
|
|
153
|
+
if (redoSaved.length > 0) {
|
|
154
|
+
state.entries[redoId] = { id: redoId, at: Date.now(), label: entry.label, files: redoSaved };
|
|
155
|
+
state.redoStack.push(redoId);
|
|
156
|
+
}
|
|
157
|
+
await this.restoreFiles(id, entry.files);
|
|
158
|
+
state.undoStack.pop();
|
|
159
|
+
await this.deleteEntry(state, id);
|
|
160
|
+
await this.persistState();
|
|
161
|
+
return entry;
|
|
162
|
+
}
|
|
163
|
+
/** Re-apply the last undone change. Returns the re-applied entry, or null if nothing to redo. */
|
|
164
|
+
async redo() {
|
|
165
|
+
const state = await this.getState();
|
|
166
|
+
if (state.redoStack.length === 0)
|
|
167
|
+
return null;
|
|
168
|
+
const id = state.redoStack[state.redoStack.length - 1];
|
|
169
|
+
const entry = state.entries[id];
|
|
170
|
+
if (!entry)
|
|
171
|
+
return null;
|
|
172
|
+
// Save current (post-undo) state back onto the undo stack
|
|
173
|
+
const undoId = makeId();
|
|
174
|
+
const undoSaved = await this.saveFiles(undoId, entry.files);
|
|
175
|
+
if (undoSaved.length > 0) {
|
|
176
|
+
state.entries[undoId] = { id: undoId, at: Date.now(), label: entry.label, files: undoSaved };
|
|
177
|
+
state.undoStack.push(undoId);
|
|
178
|
+
}
|
|
179
|
+
await this.restoreFiles(id, entry.files);
|
|
180
|
+
state.redoStack.pop();
|
|
181
|
+
await this.deleteEntry(state, id);
|
|
182
|
+
await this.persistState();
|
|
183
|
+
return entry;
|
|
184
|
+
}
|
|
185
|
+
/** List all available undo entries, newest first. */
|
|
186
|
+
async list() {
|
|
187
|
+
const state = await this.getState();
|
|
188
|
+
return [...state.undoStack]
|
|
189
|
+
.reverse()
|
|
190
|
+
.map((id) => state.entries[id])
|
|
191
|
+
.filter(Boolean);
|
|
192
|
+
}
|
|
193
|
+
/** Remove all snapshot sessions except this one (and the current-session marker). */
|
|
194
|
+
async clean() {
|
|
195
|
+
return SnapshotManager.cleanAll(this.root, this.sessionId);
|
|
196
|
+
}
|
|
197
|
+
/** Remove all snapshot session directories, optionally keeping one by ID. */
|
|
198
|
+
static async cleanAll(root, keepSessionId) {
|
|
199
|
+
const snapshotsDir = join(root, '.vexi', 'snapshots');
|
|
200
|
+
let count = 0;
|
|
201
|
+
try {
|
|
202
|
+
const entries = await fs.readdir(snapshotsDir, { withFileTypes: true });
|
|
203
|
+
for (const entry of entries) {
|
|
204
|
+
if (!entry.isDirectory())
|
|
205
|
+
continue;
|
|
206
|
+
if (keepSessionId && entry.name === keepSessionId)
|
|
207
|
+
continue;
|
|
208
|
+
try {
|
|
209
|
+
await fs.rm(join(snapshotsDir, entry.name), { recursive: true, force: true });
|
|
210
|
+
count++;
|
|
211
|
+
}
|
|
212
|
+
catch { }
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
catch { }
|
|
216
|
+
return count;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Extract file paths that a shell command is likely to write.
|
|
220
|
+
* Returns relative paths (relative to cwd) for files that currently exist on disk.
|
|
221
|
+
* This is best-effort: covers common patterns (cat >, sed -i, mv, cp, tee, etc.)
|
|
222
|
+
* and files with known extensions that appear as bare tokens in the command.
|
|
223
|
+
*/
|
|
224
|
+
static extractFilePaths(command, cwd) {
|
|
225
|
+
const found = new Set();
|
|
226
|
+
const tryAdd = (raw) => {
|
|
227
|
+
if (!raw)
|
|
228
|
+
return;
|
|
229
|
+
const p = raw.trim().replace(/^["']|["']$/g, '');
|
|
230
|
+
if (!p || p.startsWith('-') || p.startsWith('http') || p === '/dev/null')
|
|
231
|
+
return;
|
|
232
|
+
const abs = isAbsolute(p) ? p : join(cwd, p);
|
|
233
|
+
if (!resolveWithinRoot(cwd, abs))
|
|
234
|
+
return; // never track files outside the project root
|
|
235
|
+
if (existsSync(abs)) {
|
|
236
|
+
found.add(relative(cwd, abs).replace(/\\/g, '/'));
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
// cat > file / cat >> file
|
|
240
|
+
for (const m of command.matchAll(/\bcat\s+>>?\s+(\S+)/gi))
|
|
241
|
+
tryAdd(m[1]);
|
|
242
|
+
// echo ... > file / >> file
|
|
243
|
+
for (const m of command.matchAll(/\becho\b.*?>>?\s+(\S+)/gi))
|
|
244
|
+
tryAdd(m[1]);
|
|
245
|
+
// sed -i[suffix] 'expr' file
|
|
246
|
+
for (const m of command.matchAll(/\bsed\b.*?-i\S*\s+(?:'[^']*'|"[^"]*"|\S+)\s+(\S+)/gi))
|
|
247
|
+
tryAdd(m[1]);
|
|
248
|
+
// patch [options] file
|
|
249
|
+
for (const m of command.matchAll(/\bpatch\b(?:\s+-[a-zA-Z0-9]+)*\s+(\S+)/gi))
|
|
250
|
+
tryAdd(m[1]);
|
|
251
|
+
// tee file
|
|
252
|
+
for (const m of command.matchAll(/\btee\s+(\S+)/gi))
|
|
253
|
+
tryAdd(m[1]);
|
|
254
|
+
// cp src dst
|
|
255
|
+
for (const m of command.matchAll(/\bcp\b(?:\s+-[a-zA-Z]+)*\s+(\S+)\s+(\S+)/gi)) {
|
|
256
|
+
tryAdd(m[1]);
|
|
257
|
+
tryAdd(m[2]);
|
|
258
|
+
}
|
|
259
|
+
// mv src dst
|
|
260
|
+
for (const m of command.matchAll(/\bmv\b(?:\s+-[a-zA-Z]+)*\s+(\S+)\s+(\S+)/gi)) {
|
|
261
|
+
tryAdd(m[1]);
|
|
262
|
+
tryAdd(m[2]);
|
|
263
|
+
}
|
|
264
|
+
// node/deno: fs.writeFileSync('file', ...)
|
|
265
|
+
for (const m of command.matchAll(/writeFileSync\s*\(\s*["']([^"']+)["']/gi))
|
|
266
|
+
tryAdd(m[1]);
|
|
267
|
+
// python: open('file', 'w') or open('file', 'a')
|
|
268
|
+
for (const m of command.matchAll(/\bopen\s*\(\s*["']([^"']+)["']\s*,\s*["'][wa]/gi))
|
|
269
|
+
tryAdd(m[1]);
|
|
270
|
+
// PowerShell: Set-Content / Out-File / Add-Content
|
|
271
|
+
for (const m of command.matchAll(/\b(?:Set-Content|Out-File|Add-Content)\b.*?-(?:Path|Value|FilePath)\s+["']?(\S+?)["']?(?:\s|$)/gi))
|
|
272
|
+
tryAdd(m[1]);
|
|
273
|
+
// Generic: bare token with a known source-file extension
|
|
274
|
+
for (const m of command.matchAll(/(?:^|[\s;|&>])["']?([\w./\\-]+\.(?:ts|tsx|js|jsx|mjs|cjs|json|md|py|rs|go|java|c|cpp|h|hpp|css|scss|html|yaml|yml|toml|sh|env|txt|xml|sql|graphql|vue|svelte|rb|php|swift|kt|cs))["']?(?=\s|$|[;|&])/gim))
|
|
275
|
+
tryAdd(m[1]);
|
|
276
|
+
return [...found];
|
|
277
|
+
}
|
|
278
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { SnapshotManager } from './index.js';
|
|
6
|
+
async function mkTmp() {
|
|
7
|
+
return fs.mkdtemp(join(tmpdir(), 'vexi-snap-test-'));
|
|
8
|
+
}
|
|
9
|
+
async function writeFile(dir, name, content) {
|
|
10
|
+
const p = join(dir, name);
|
|
11
|
+
await fs.writeFile(p, content, 'utf8');
|
|
12
|
+
return p;
|
|
13
|
+
}
|
|
14
|
+
describe('SnapshotManager', () => {
|
|
15
|
+
let root;
|
|
16
|
+
let manager;
|
|
17
|
+
beforeEach(async () => {
|
|
18
|
+
root = await mkTmp();
|
|
19
|
+
manager = new SnapshotManager(root, 'test-session');
|
|
20
|
+
});
|
|
21
|
+
afterEach(async () => {
|
|
22
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
it('round-trip: takeSnapshot → undo restores file content', async () => {
|
|
25
|
+
const filePath = await writeFile(root, 'hello.txt', 'original');
|
|
26
|
+
const snapped = await manager.takeSnapshot([filePath], 'initial write');
|
|
27
|
+
expect(snapped).toBe(true);
|
|
28
|
+
await fs.writeFile(filePath, 'modified', 'utf8');
|
|
29
|
+
const entry = await manager.undo();
|
|
30
|
+
expect(entry).not.toBeNull();
|
|
31
|
+
expect(entry.files.some((f) => f.endsWith('hello.txt') || f === filePath)).toBe(true);
|
|
32
|
+
const restored = await fs.readFile(filePath, 'utf8');
|
|
33
|
+
expect(restored).toBe('original');
|
|
34
|
+
});
|
|
35
|
+
it('round-trip: undo → redo restores modified content', async () => {
|
|
36
|
+
const filePath = await writeFile(root, 'hello.txt', 'original');
|
|
37
|
+
await manager.takeSnapshot([filePath], 'initial write');
|
|
38
|
+
await fs.writeFile(filePath, 'modified', 'utf8');
|
|
39
|
+
await manager.undo();
|
|
40
|
+
const afterUndo = await fs.readFile(filePath, 'utf8');
|
|
41
|
+
expect(afterUndo).toBe('original');
|
|
42
|
+
const redoEntry = await manager.redo();
|
|
43
|
+
expect(redoEntry).not.toBeNull();
|
|
44
|
+
const afterRedo = await fs.readFile(filePath, 'utf8');
|
|
45
|
+
expect(afterRedo).toBe('modified');
|
|
46
|
+
});
|
|
47
|
+
it('list() returns newest snapshot first', async () => {
|
|
48
|
+
const filePath = await writeFile(root, 'a.txt', 'v1');
|
|
49
|
+
await manager.takeSnapshot([filePath], 'first');
|
|
50
|
+
await fs.writeFile(filePath, 'v2', 'utf8');
|
|
51
|
+
await manager.takeSnapshot([filePath], 'second');
|
|
52
|
+
const list = await manager.list();
|
|
53
|
+
expect(list.length).toBe(2);
|
|
54
|
+
expect(list[0].label).toBe('second');
|
|
55
|
+
expect(list[1].label).toBe('first');
|
|
56
|
+
});
|
|
57
|
+
it('undo() returns null when stack is empty', async () => {
|
|
58
|
+
const result = await manager.undo();
|
|
59
|
+
expect(result).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
it('redo() returns null when stack is empty', async () => {
|
|
62
|
+
const result = await manager.redo();
|
|
63
|
+
expect(result).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
it('takeSnapshot clears redo stack', async () => {
|
|
66
|
+
const filePath = await writeFile(root, 'b.txt', 'v1');
|
|
67
|
+
await manager.takeSnapshot([filePath], 'snap1');
|
|
68
|
+
await fs.writeFile(filePath, 'v2', 'utf8');
|
|
69
|
+
await manager.undo();
|
|
70
|
+
// new snapshot after undo — redo stack should be cleared
|
|
71
|
+
await manager.takeSnapshot([filePath], 'snap2');
|
|
72
|
+
const redo = await manager.redo();
|
|
73
|
+
expect(redo).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
it('takeSnapshot returns false when no files exist', async () => {
|
|
76
|
+
const result = await manager.takeSnapshot([join(root, 'ghost.txt')], 'missing');
|
|
77
|
+
expect(result).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
describe('SnapshotManager.extractFilePaths', () => {
|
|
81
|
+
let cwd;
|
|
82
|
+
beforeEach(async () => {
|
|
83
|
+
cwd = await fs.mkdtemp(join(tmpdir(), 'vexi-ext-test-'));
|
|
84
|
+
// create the files so existsSync passes
|
|
85
|
+
await fs.mkdir(join(cwd, 'src'), { recursive: true });
|
|
86
|
+
await fs.writeFile(join(cwd, 'src', 'app.ts'), '', 'utf8');
|
|
87
|
+
await fs.writeFile(join(cwd, 'src', 'utils.js'), '', 'utf8');
|
|
88
|
+
});
|
|
89
|
+
afterEach(async () => {
|
|
90
|
+
await fs.rm(cwd, { recursive: true, force: true });
|
|
91
|
+
});
|
|
92
|
+
it('extracts path from cat > redirect', () => {
|
|
93
|
+
const paths = SnapshotManager.extractFilePaths('cat > src/app.ts', cwd);
|
|
94
|
+
expect(paths.length).toBeGreaterThan(0);
|
|
95
|
+
expect(paths.some((p) => p.endsWith('app.ts'))).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
it('extracts path from sed -i command', () => {
|
|
98
|
+
const paths = SnapshotManager.extractFilePaths("sed -i 's/foo/bar/' src/utils.js", cwd);
|
|
99
|
+
expect(paths.some((p) => p.endsWith('utils.js'))).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
it('returns empty array for non-file commands', () => {
|
|
102
|
+
const paths = SnapshotManager.extractFilePaths('npm install', cwd);
|
|
103
|
+
expect(paths).toEqual([]);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -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
|
+
}
|