vipcode 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.en.md +56 -0
- package/README.md +382 -2
- package/dist/agent.js +502 -0
- package/dist/agents.js +171 -0
- package/dist/app.js +1013 -0
- package/dist/auth.js +108 -0
- package/dist/catalog.js +487 -0
- package/dist/commands.js +119 -0
- package/dist/config.js +225 -0
- package/dist/debug.js +32 -0
- package/dist/discovery.js +277 -0
- package/dist/frontmatter.js +45 -0
- package/dist/images.js +81 -0
- package/dist/index.js +574 -0
- package/dist/login.js +157 -0
- package/dist/lsp.js +480 -0
- package/dist/markdown.js +259 -0
- package/dist/match.js +8 -0
- package/dist/mcp.js +225 -0
- package/dist/meta.js +7 -0
- package/dist/paths.js +27 -0
- package/dist/permissions.js +120 -0
- package/dist/prompt.js +123 -0
- package/dist/ranking.js +230 -0
- package/dist/router.js +726 -0
- package/dist/session.js +219 -0
- package/dist/skills.js +373 -0
- package/dist/snapshot.js +199 -0
- package/dist/tools.js +1247 -0
- package/dist/tui/app.js +1015 -0
- package/dist/tui/dialogs.js +196 -0
- package/dist/tui/editor.js +120 -0
- package/dist/ui.js +181 -0
- package/dist/update.js +235 -0
- package/package.json +64 -4
package/dist/tools.js
ADDED
|
@@ -0,0 +1,1247 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
4
|
+
import fg from "fast-glob";
|
|
5
|
+
import { isImagePath, toDataUrl } from "./images.js";
|
|
6
|
+
const IGNORE = ["**/node_modules/**", "**/.git/**", "**/dist/**", "**/build/**", "**/.next/**", "**/vendor/**"];
|
|
7
|
+
const MAX_OUTPUT = 20000;
|
|
8
|
+
export function truncate(text, max = MAX_OUTPUT) {
|
|
9
|
+
if (text.length <= max) {
|
|
10
|
+
return text;
|
|
11
|
+
}
|
|
12
|
+
const half = Math.floor(max / 2);
|
|
13
|
+
return `${text.slice(0, half)}\n\n[… ${text.length - max} caracteres omitidos …]\n\n${text.slice(-half)}`;
|
|
14
|
+
}
|
|
15
|
+
// Convierte una ruta relativa en absoluta y comprueba que no sale del proyecto
|
|
16
|
+
export function resolvePath(root, p) {
|
|
17
|
+
let input = p;
|
|
18
|
+
if (!input) {
|
|
19
|
+
input = ".";
|
|
20
|
+
}
|
|
21
|
+
const abs = path.resolve(root, input);
|
|
22
|
+
if (abs !== root && !abs.startsWith(root + path.sep)) {
|
|
23
|
+
throw new Error(`La ruta ${p} está fuera del proyecto (${root})`);
|
|
24
|
+
}
|
|
25
|
+
return abs;
|
|
26
|
+
}
|
|
27
|
+
export function rel(root, abs) {
|
|
28
|
+
const r = path.relative(root, abs);
|
|
29
|
+
if (r === "") {
|
|
30
|
+
return ".";
|
|
31
|
+
}
|
|
32
|
+
return r;
|
|
33
|
+
}
|
|
34
|
+
function rejected(reply, what) {
|
|
35
|
+
if (reply.feedback) {
|
|
36
|
+
return `El usuario ha rechazado ${what}. Motivo: ${reply.feedback}`;
|
|
37
|
+
}
|
|
38
|
+
return `El usuario ha rechazado ${what}.`;
|
|
39
|
+
}
|
|
40
|
+
const undoStack = [];
|
|
41
|
+
export function beginTurn() {
|
|
42
|
+
undoStack.push([]);
|
|
43
|
+
}
|
|
44
|
+
function recordChange(file, previous) {
|
|
45
|
+
if (undoStack.length === 0) {
|
|
46
|
+
undoStack.push([]);
|
|
47
|
+
}
|
|
48
|
+
const group = undoStack[undoStack.length - 1];
|
|
49
|
+
// Solo guardamos el estado anterior al turno; las siguientes ediciones del mismo fichero no cuentan
|
|
50
|
+
if (!group.some((change) => change.file === file)) {
|
|
51
|
+
group.push({ file, previous });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// Restaura los ficheros del último turno que cambió algo; devuelve sus rutas
|
|
55
|
+
export function undoLastTurn(root) {
|
|
56
|
+
while (undoStack.length > 0) {
|
|
57
|
+
const group = undoStack.pop();
|
|
58
|
+
if (group.length === 0) {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
for (const change of group) {
|
|
62
|
+
if (change.previous === null) {
|
|
63
|
+
fs.rmSync(change.file, { force: true });
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
fs.mkdirSync(path.dirname(change.file), { recursive: true });
|
|
67
|
+
fs.writeFileSync(change.file, change.previous);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return group.map((change) => rel(root, change.file));
|
|
71
|
+
}
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
// Diff por líneas al estilo de git: solo los trozos que cambian, con unas líneas de contexto (lo colorea la UI)
|
|
75
|
+
export function unifiedDiff(oldText, newText, context = 3, maxLines = 120) {
|
|
76
|
+
const a = oldText.split("\n");
|
|
77
|
+
const b = newText.split("\n");
|
|
78
|
+
// Quitamos lo común al principio y al final para que el cálculo sea pequeño
|
|
79
|
+
let start = 0;
|
|
80
|
+
while (start < a.length && start < b.length && a[start] === b[start]) {
|
|
81
|
+
start++;
|
|
82
|
+
}
|
|
83
|
+
let endA = a.length;
|
|
84
|
+
let endB = b.length;
|
|
85
|
+
while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
|
|
86
|
+
endA--;
|
|
87
|
+
endB--;
|
|
88
|
+
}
|
|
89
|
+
const ops = [];
|
|
90
|
+
let lineA = 1;
|
|
91
|
+
let lineB = 1;
|
|
92
|
+
const keep = (text) => {
|
|
93
|
+
ops.push({ type: " ", text, line: lineB });
|
|
94
|
+
lineA++;
|
|
95
|
+
lineB++;
|
|
96
|
+
};
|
|
97
|
+
const remove = (text) => {
|
|
98
|
+
ops.push({ type: "-", text, line: lineA });
|
|
99
|
+
lineA++;
|
|
100
|
+
};
|
|
101
|
+
const add = (text) => {
|
|
102
|
+
ops.push({ type: "+", text, line: lineB });
|
|
103
|
+
lineB++;
|
|
104
|
+
};
|
|
105
|
+
for (let i = 0; i < start; i++) {
|
|
106
|
+
keep(a[i]);
|
|
107
|
+
}
|
|
108
|
+
const midA = a.slice(start, endA);
|
|
109
|
+
const midB = b.slice(start, endB);
|
|
110
|
+
const n = midA.length;
|
|
111
|
+
const m = midB.length;
|
|
112
|
+
if (n * m <= 4000000) {
|
|
113
|
+
// lcs[i][j] = longitud de la subsecuencia común más larga de midA[i..] y midB[j..]
|
|
114
|
+
const lcs = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
|
|
115
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
116
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
117
|
+
if (midA[i] === midB[j]) {
|
|
118
|
+
lcs[i][j] = lcs[i + 1][j + 1] + 1;
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
lcs[i][j] = Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
let i = 0;
|
|
126
|
+
let j = 0;
|
|
127
|
+
while (i < n || j < m) {
|
|
128
|
+
if (i < n && j < m && midA[i] === midB[j]) {
|
|
129
|
+
keep(midA[i]);
|
|
130
|
+
i++;
|
|
131
|
+
j++;
|
|
132
|
+
}
|
|
133
|
+
else if (i < n && (j >= m || lcs[i + 1][j] >= lcs[i][j + 1])) {
|
|
134
|
+
remove(midA[i]);
|
|
135
|
+
i++;
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
add(midB[j]);
|
|
139
|
+
j++;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
// Demasiado grande para compararlo línea a línea: todo lo viejo fuera y todo lo nuevo dentro
|
|
145
|
+
for (const line of midA) {
|
|
146
|
+
remove(line);
|
|
147
|
+
}
|
|
148
|
+
for (const line of midB) {
|
|
149
|
+
add(line);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
for (let i = endA; i < a.length; i++) {
|
|
153
|
+
keep(a[i]);
|
|
154
|
+
}
|
|
155
|
+
// Marcamos qué líneas se muestran: los cambios y su contexto
|
|
156
|
+
const visible = new Array(ops.length).fill(false);
|
|
157
|
+
for (let k = 0; k < ops.length; k++) {
|
|
158
|
+
if (ops[k].type !== " ") {
|
|
159
|
+
for (let c = Math.max(0, k - context); c <= Math.min(ops.length - 1, k + context); c++) {
|
|
160
|
+
visible[c] = true;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const out = [];
|
|
165
|
+
let changed = 0;
|
|
166
|
+
for (let k = 0; k < ops.length; k++) {
|
|
167
|
+
if (!visible[k]) {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (k === 0 || !visible[k - 1]) {
|
|
171
|
+
out.push(`@@ línea ${ops[k].line} @@`);
|
|
172
|
+
}
|
|
173
|
+
if (ops[k].type === " ") {
|
|
174
|
+
out.push(` ${ops[k].text}`);
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
out.push(`${ops[k].type} ${ops[k].text}`);
|
|
178
|
+
changed++;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (changed === 0) {
|
|
182
|
+
return "(sin cambios)";
|
|
183
|
+
}
|
|
184
|
+
if (out.length > maxLines) {
|
|
185
|
+
return `${out.slice(0, maxLines).join("\n")}\n[… ${out.length - maxLines} líneas más del diff]`;
|
|
186
|
+
}
|
|
187
|
+
return out.join("\n");
|
|
188
|
+
}
|
|
189
|
+
// Contenido de un fichero con las líneas numeradas (lo usan read_file y los @ficheros del mensaje)
|
|
190
|
+
function readNumbered(file, label, offset, maxLines) {
|
|
191
|
+
const buffer = fs.readFileSync(file);
|
|
192
|
+
if (buffer.includes(0)) {
|
|
193
|
+
return `${label} parece un fichero binario; no se muestra.`;
|
|
194
|
+
}
|
|
195
|
+
const lines = buffer.toString("utf8").split("\n");
|
|
196
|
+
let start = 1;
|
|
197
|
+
if (offset && offset > 0) {
|
|
198
|
+
start = Math.floor(offset);
|
|
199
|
+
}
|
|
200
|
+
let limit = 1500;
|
|
201
|
+
if (maxLines && maxLines > 0) {
|
|
202
|
+
limit = Math.floor(maxLines);
|
|
203
|
+
}
|
|
204
|
+
const slice = lines.slice(start - 1, start - 1 + limit);
|
|
205
|
+
const numbered = slice.map((line, i) => `${start + i}\t${line}`).join("\n");
|
|
206
|
+
let footer = "";
|
|
207
|
+
if (start - 1 + limit < lines.length) {
|
|
208
|
+
footer = `\n[mostradas ${slice.length} de ${lines.length} líneas; usa offset para seguir]`;
|
|
209
|
+
}
|
|
210
|
+
return truncate(numbered) + footer;
|
|
211
|
+
}
|
|
212
|
+
function markRead(ctx, file) {
|
|
213
|
+
try {
|
|
214
|
+
ctx.readTimes.set(file, fs.statSync(file).mtimeMs);
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
// el fichero no existe
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
// Antes de modificar un fichero que ya existe hay que haberlo leído, y no debe haber cambiado desde entonces
|
|
221
|
+
function checkFresh(ctx, file) {
|
|
222
|
+
const readAt = ctx.readTimes.get(file);
|
|
223
|
+
const name = rel(ctx.root, file);
|
|
224
|
+
if (readAt === undefined) {
|
|
225
|
+
return `Error: lee ${name} con read_file antes de modificarlo.`;
|
|
226
|
+
}
|
|
227
|
+
if (fs.statSync(file).mtimeMs > readAt + 1) {
|
|
228
|
+
return `Error: ${name} ha cambiado desde que lo leíste; vuelve a leerlo con read_file.`;
|
|
229
|
+
}
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
// Tras escribir, preguntamos al LSP si el fichero tiene errores para que el modelo los arregle
|
|
233
|
+
async function withDiagnostics(ctx, file, text) {
|
|
234
|
+
if (!ctx.diagnostics) {
|
|
235
|
+
return text;
|
|
236
|
+
}
|
|
237
|
+
try {
|
|
238
|
+
const found = await ctx.diagnostics(file, false);
|
|
239
|
+
if (found) {
|
|
240
|
+
return `${text}\n\nEl servidor de lenguaje encontró estos errores tras el cambio (arréglalos si son tuyos):\n${found}`;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
// sin LSP no pasa nada
|
|
245
|
+
}
|
|
246
|
+
return text;
|
|
247
|
+
}
|
|
248
|
+
const readFile = {
|
|
249
|
+
name: "read_file",
|
|
250
|
+
description: "Lee un fichero de texto del proyecto. Devuelve las líneas numeradas. Usa offset/limit para ficheros grandes. Si es una imagen (png, jpg, gif, webp) te la enseña.",
|
|
251
|
+
parameters: {
|
|
252
|
+
type: "object",
|
|
253
|
+
properties: {
|
|
254
|
+
path: { type: "string", description: "Ruta relativa a la raíz del proyecto" },
|
|
255
|
+
offset: { type: "number", description: "Primera línea a leer (empieza en 1)" },
|
|
256
|
+
limit: { type: "number", description: "Número de líneas (por defecto 1500)" },
|
|
257
|
+
},
|
|
258
|
+
required: ["path"],
|
|
259
|
+
},
|
|
260
|
+
permission: "none",
|
|
261
|
+
readOnly: true,
|
|
262
|
+
async run(args, ctx) {
|
|
263
|
+
const file = resolvePath(ctx.root, args.path);
|
|
264
|
+
if (isImagePath(file)) {
|
|
265
|
+
const image = toDataUrl(file);
|
|
266
|
+
return { output: `Imagen ${rel(ctx.root, file)} adjuntada a continuación para que la veas.`, images: [image] };
|
|
267
|
+
}
|
|
268
|
+
const text = readNumbered(file, String(args.path), args.offset, args.limit);
|
|
269
|
+
markRead(ctx, file);
|
|
270
|
+
return text;
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
const writeFile = {
|
|
274
|
+
name: "write_file",
|
|
275
|
+
description: "Crea un fichero o lo sobrescribe entero. Para cambios parciales usa edit_file.",
|
|
276
|
+
parameters: {
|
|
277
|
+
type: "object",
|
|
278
|
+
properties: {
|
|
279
|
+
path: { type: "string" },
|
|
280
|
+
content: { type: "string", description: "Contenido completo del fichero" },
|
|
281
|
+
},
|
|
282
|
+
required: ["path", "content"],
|
|
283
|
+
},
|
|
284
|
+
permission: "write",
|
|
285
|
+
readOnly: false,
|
|
286
|
+
async run(args, ctx) {
|
|
287
|
+
const file = resolvePath(ctx.root, args.path);
|
|
288
|
+
const content = String(args.content ?? "");
|
|
289
|
+
const exists = fs.existsSync(file);
|
|
290
|
+
let previous = null;
|
|
291
|
+
let title = `Crear ${rel(ctx.root, file)}`;
|
|
292
|
+
let preview = content.split("\n").slice(0, 40).map((l) => `+ ${l}`).join("\n");
|
|
293
|
+
if (exists) {
|
|
294
|
+
const stale = checkFresh(ctx, file);
|
|
295
|
+
if (stale) {
|
|
296
|
+
return { output: stale, isError: true };
|
|
297
|
+
}
|
|
298
|
+
previous = fs.readFileSync(file, "utf8");
|
|
299
|
+
title = `Sobrescribir ${rel(ctx.root, file)}`;
|
|
300
|
+
preview = unifiedDiff(previous, content);
|
|
301
|
+
}
|
|
302
|
+
const reply = await ctx.ask({ kind: "write", title, preview, subject: rel(ctx.root, file) });
|
|
303
|
+
if (!reply.allow) {
|
|
304
|
+
return rejected(reply, "escribir el fichero");
|
|
305
|
+
}
|
|
306
|
+
recordChange(file, previous);
|
|
307
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
308
|
+
fs.writeFileSync(file, content);
|
|
309
|
+
markRead(ctx, file);
|
|
310
|
+
const output = await withDiagnostics(ctx, file, `Escrito ${rel(ctx.root, file)} (${content.split("\n").length} líneas).`);
|
|
311
|
+
return { output, display: preview };
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
// Busca old_string en el fichero; si no aparece exacto, prueba ignorando saltos de línea de Windows y los espacios de los bordes
|
|
315
|
+
function replaceInText(original, oldString, newString, replaceAll) {
|
|
316
|
+
const exact = (text, from, to) => {
|
|
317
|
+
const count = text.split(from).length - 1;
|
|
318
|
+
if (count === 0) {
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
if (count > 1 && !replaceAll) {
|
|
322
|
+
return { error: `Error: old_string aparece ${count} veces. Añade más contexto para que sea único o usa replace_all.` };
|
|
323
|
+
}
|
|
324
|
+
if (replaceAll) {
|
|
325
|
+
return { updated: text.split(from).join(to), count, note: "" };
|
|
326
|
+
}
|
|
327
|
+
const index = text.indexOf(from);
|
|
328
|
+
return { updated: text.slice(0, index) + to + text.slice(index + from.length), count, note: "" };
|
|
329
|
+
};
|
|
330
|
+
const direct = exact(original, oldString, newString);
|
|
331
|
+
if (direct) {
|
|
332
|
+
return direct;
|
|
333
|
+
}
|
|
334
|
+
const crlf = original.includes("\r\n");
|
|
335
|
+
if (crlf && !oldString.includes("\r\n")) {
|
|
336
|
+
const windows = exact(original, oldString.replace(/\n/g, "\r\n"), newString.replace(/\n/g, "\r\n"));
|
|
337
|
+
if (windows) {
|
|
338
|
+
return windows;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
// Coincidencia por líneas ignorando la sangría y los espacios finales
|
|
342
|
+
const fileLines = original.split("\n");
|
|
343
|
+
const oldLines = oldString.replace(/\r\n/g, "\n").split("\n");
|
|
344
|
+
while (oldLines.length > 1 && oldLines[oldLines.length - 1].trim() === "") {
|
|
345
|
+
oldLines.pop();
|
|
346
|
+
}
|
|
347
|
+
while (oldLines.length > 1 && oldLines[0].trim() === "") {
|
|
348
|
+
oldLines.shift();
|
|
349
|
+
}
|
|
350
|
+
const matches = [];
|
|
351
|
+
for (let i = 0; i + oldLines.length <= fileLines.length; i++) {
|
|
352
|
+
let ok = true;
|
|
353
|
+
for (let j = 0; j < oldLines.length; j++) {
|
|
354
|
+
if (fileLines[i + j].trim() !== oldLines[j].trim()) {
|
|
355
|
+
ok = false;
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (ok) {
|
|
360
|
+
matches.push(i);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
if (matches.length === 0 || oldLines.every((line) => line.trim() === "")) {
|
|
364
|
+
return {
|
|
365
|
+
error: "Error: old_string no aparece en el fichero. Vuelve a leerlo con read_file y copia el texto exacto (espacios incluidos, sin el número de línea).",
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
if (matches.length > 1 && !replaceAll) {
|
|
369
|
+
return { error: `Error: old_string aparece ${matches.length} veces. Añade más contexto para que sea único o usa replace_all.` };
|
|
370
|
+
}
|
|
371
|
+
const indentOf = (line) => /^[ \t]*/.exec(line)[0];
|
|
372
|
+
let lines = fileLines;
|
|
373
|
+
// De abajo arriba para que los índices no se muevan
|
|
374
|
+
for (const at of [...matches].reverse()) {
|
|
375
|
+
// Si el modelo se comió la sangría, se la devolvemos línea a línea:
|
|
376
|
+
// las que coinciden con una línea vieja usan la sangría real de esa línea; el resto, la de la línea vieja en su misma posición
|
|
377
|
+
let newLines = newString
|
|
378
|
+
.replace(/\r\n/g, "\n")
|
|
379
|
+
.split("\n")
|
|
380
|
+
.map((line, index) => {
|
|
381
|
+
if (line.trim() === "") {
|
|
382
|
+
return line;
|
|
383
|
+
}
|
|
384
|
+
let k = oldLines.findIndex((old) => old.trim() === line.trim());
|
|
385
|
+
if (k < 0) {
|
|
386
|
+
k = Math.min(index, oldLines.length - 1);
|
|
387
|
+
}
|
|
388
|
+
const fileIndent = indentOf(lines[at + k].replace(/\r$/, ""));
|
|
389
|
+
const oldIndent = indentOf(oldLines[k]);
|
|
390
|
+
if (fileIndent !== oldIndent && fileIndent.startsWith(oldIndent)) {
|
|
391
|
+
return fileIndent.slice(oldIndent.length) + line;
|
|
392
|
+
}
|
|
393
|
+
return line;
|
|
394
|
+
});
|
|
395
|
+
if (crlf) {
|
|
396
|
+
newLines = newLines.map((line) => `${line}\r`);
|
|
397
|
+
}
|
|
398
|
+
lines = [...lines.slice(0, at), ...newLines, ...lines.slice(at + oldLines.length)];
|
|
399
|
+
}
|
|
400
|
+
return { updated: lines.join("\n"), count: matches.length, note: " (coincidencia aproximada: se ignoraron los espacios de los bordes)" };
|
|
401
|
+
}
|
|
402
|
+
const editFile = {
|
|
403
|
+
name: "edit_file",
|
|
404
|
+
description: "Sustituye un texto exacto por otro en un fichero. old_string debe aparecer exactamente una vez (incluye contexto para que sea único) salvo que uses replace_all. Lee el fichero con read_file antes.",
|
|
405
|
+
parameters: {
|
|
406
|
+
type: "object",
|
|
407
|
+
properties: {
|
|
408
|
+
path: { type: "string" },
|
|
409
|
+
old_string: { type: "string", description: "Texto exacto a sustituir" },
|
|
410
|
+
new_string: { type: "string", description: "Texto nuevo" },
|
|
411
|
+
replace_all: { type: "boolean", description: "Sustituir todas las apariciones" },
|
|
412
|
+
},
|
|
413
|
+
required: ["path", "old_string", "new_string"],
|
|
414
|
+
},
|
|
415
|
+
permission: "write",
|
|
416
|
+
readOnly: false,
|
|
417
|
+
async run(args, ctx) {
|
|
418
|
+
const file = resolvePath(ctx.root, args.path);
|
|
419
|
+
if (!fs.existsSync(file)) {
|
|
420
|
+
return { output: `Error: ${args.path} no existe. Para crear un fichero usa write_file.`, isError: true };
|
|
421
|
+
}
|
|
422
|
+
const stale = checkFresh(ctx, file);
|
|
423
|
+
if (stale) {
|
|
424
|
+
return { output: stale, isError: true };
|
|
425
|
+
}
|
|
426
|
+
const original = fs.readFileSync(file, "utf8");
|
|
427
|
+
const oldString = String(args.old_string ?? "");
|
|
428
|
+
const newString = String(args.new_string ?? "");
|
|
429
|
+
if (oldString === "") {
|
|
430
|
+
return { output: "Error: old_string está vacío. Para crear un fichero usa write_file.", isError: true };
|
|
431
|
+
}
|
|
432
|
+
if (oldString === newString) {
|
|
433
|
+
return { output: "Error: old_string y new_string son iguales; no hay nada que cambiar.", isError: true };
|
|
434
|
+
}
|
|
435
|
+
const result = replaceInText(original, oldString, newString, Boolean(args.replace_all));
|
|
436
|
+
if ("error" in result) {
|
|
437
|
+
return { output: result.error, isError: true };
|
|
438
|
+
}
|
|
439
|
+
const diff = unifiedDiff(original, result.updated);
|
|
440
|
+
const reply = await ctx.ask({ kind: "write", title: `Editar ${rel(ctx.root, file)}`, preview: diff, subject: rel(ctx.root, file) });
|
|
441
|
+
if (!reply.allow) {
|
|
442
|
+
return rejected(reply, "la edición");
|
|
443
|
+
}
|
|
444
|
+
recordChange(file, original);
|
|
445
|
+
fs.writeFileSync(file, result.updated);
|
|
446
|
+
markRead(ctx, file);
|
|
447
|
+
const output = await withDiagnostics(ctx, file, `Editado ${rel(ctx.root, file)} (${result.count} sustitución/es)${result.note}.`);
|
|
448
|
+
return { output, display: diff };
|
|
449
|
+
},
|
|
450
|
+
};
|
|
451
|
+
const listDir = {
|
|
452
|
+
name: "list_dir",
|
|
453
|
+
description: "Lista el contenido de una carpeta (las carpetas terminan en /).",
|
|
454
|
+
parameters: {
|
|
455
|
+
type: "object",
|
|
456
|
+
properties: { path: { type: "string", description: "Carpeta relativa (por defecto la raíz)" } },
|
|
457
|
+
},
|
|
458
|
+
permission: "none",
|
|
459
|
+
readOnly: true,
|
|
460
|
+
async run(args, ctx) {
|
|
461
|
+
const dir = resolvePath(ctx.root, args.path);
|
|
462
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
463
|
+
const lines = entries
|
|
464
|
+
.filter((e) => e.name !== ".git" && e.name !== "node_modules")
|
|
465
|
+
.sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name))
|
|
466
|
+
.slice(0, 500)
|
|
467
|
+
.map((e) => {
|
|
468
|
+
if (e.isDirectory()) {
|
|
469
|
+
return `${e.name}/`;
|
|
470
|
+
}
|
|
471
|
+
return e.name;
|
|
472
|
+
});
|
|
473
|
+
if (lines.length === 0) {
|
|
474
|
+
return "(carpeta vacía)";
|
|
475
|
+
}
|
|
476
|
+
return lines.join("\n");
|
|
477
|
+
},
|
|
478
|
+
};
|
|
479
|
+
const globTool = {
|
|
480
|
+
name: "glob",
|
|
481
|
+
description: "Busca ficheros por patrón glob, por ejemplo src/**/*.ts",
|
|
482
|
+
parameters: {
|
|
483
|
+
type: "object",
|
|
484
|
+
properties: {
|
|
485
|
+
pattern: { type: "string" },
|
|
486
|
+
path: { type: "string", description: "Carpeta donde buscar (por defecto la raíz)" },
|
|
487
|
+
},
|
|
488
|
+
required: ["pattern"],
|
|
489
|
+
},
|
|
490
|
+
permission: "none",
|
|
491
|
+
readOnly: true,
|
|
492
|
+
async run(args, ctx) {
|
|
493
|
+
const cwd = resolvePath(ctx.root, args.path);
|
|
494
|
+
const files = await fg(String(args.pattern), { cwd, ignore: IGNORE, dot: true, onlyFiles: true });
|
|
495
|
+
if (files.length === 0) {
|
|
496
|
+
return "Sin resultados.";
|
|
497
|
+
}
|
|
498
|
+
files.sort();
|
|
499
|
+
let extra = "";
|
|
500
|
+
if (files.length > 300) {
|
|
501
|
+
extra = `\n[… y ${files.length - 300} más]`;
|
|
502
|
+
}
|
|
503
|
+
return files.slice(0, 300).join("\n") + extra;
|
|
504
|
+
},
|
|
505
|
+
};
|
|
506
|
+
let ripgrepAvailable = null;
|
|
507
|
+
function hasRipgrep() {
|
|
508
|
+
if (ripgrepAvailable === null) {
|
|
509
|
+
try {
|
|
510
|
+
ripgrepAvailable = spawnSync("rg", ["--version"], { stdio: "ignore" }).status === 0;
|
|
511
|
+
}
|
|
512
|
+
catch {
|
|
513
|
+
ripgrepAvailable = false;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
return ripgrepAvailable;
|
|
517
|
+
}
|
|
518
|
+
// ripgrep es mucho más rápido en proyectos grandes; devuelve null si falla para usar la búsqueda propia
|
|
519
|
+
function ripgrep(args, cwd, signal) {
|
|
520
|
+
return new Promise((resolve) => {
|
|
521
|
+
const child = spawn("rg", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
522
|
+
let stdout = "";
|
|
523
|
+
const onAbort = () => child.kill("SIGTERM");
|
|
524
|
+
signal?.addEventListener("abort", onAbort);
|
|
525
|
+
child.stdout.on("data", (d) => {
|
|
526
|
+
if (stdout.length < 2000000) {
|
|
527
|
+
stdout += d.toString();
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
child.on("error", () => resolve(null));
|
|
531
|
+
child.on("close", (code) => {
|
|
532
|
+
signal?.removeEventListener("abort", onAbort);
|
|
533
|
+
if (code === 0 || code === 1) {
|
|
534
|
+
resolve({ code: code ?? 1, stdout });
|
|
535
|
+
}
|
|
536
|
+
else {
|
|
537
|
+
resolve(null);
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
const grepTool = {
|
|
543
|
+
name: "grep",
|
|
544
|
+
description: "Busca una expresión regular dentro de los ficheros del proyecto. Devuelve fichero:línea: texto.",
|
|
545
|
+
parameters: {
|
|
546
|
+
type: "object",
|
|
547
|
+
properties: {
|
|
548
|
+
pattern: { type: "string", description: "Expresión regular" },
|
|
549
|
+
include: { type: "string", description: "Glob de ficheros donde buscar, por ejemplo **/*.php" },
|
|
550
|
+
ignore_case: { type: "boolean" },
|
|
551
|
+
},
|
|
552
|
+
required: ["pattern"],
|
|
553
|
+
},
|
|
554
|
+
permission: "none",
|
|
555
|
+
readOnly: true,
|
|
556
|
+
async run(args, ctx) {
|
|
557
|
+
const pattern = String(args.pattern);
|
|
558
|
+
if (hasRipgrep()) {
|
|
559
|
+
const rgArgs = ["--line-number", "--no-heading", "--color", "never", "--with-filename", "--max-columns", "300", "--max-columns-preview", "-e", pattern];
|
|
560
|
+
if (args.ignore_case) {
|
|
561
|
+
rgArgs.push("-i");
|
|
562
|
+
}
|
|
563
|
+
if (args.include) {
|
|
564
|
+
rgArgs.push("-g", String(args.include));
|
|
565
|
+
}
|
|
566
|
+
rgArgs.push(".");
|
|
567
|
+
const found = await ripgrep(rgArgs, ctx.root, ctx.signal);
|
|
568
|
+
if (found) {
|
|
569
|
+
if (found.code === 1 || found.stdout.trim() === "") {
|
|
570
|
+
return "Sin coincidencias.";
|
|
571
|
+
}
|
|
572
|
+
const lines = found.stdout
|
|
573
|
+
.split("\n")
|
|
574
|
+
.filter((line) => line.length > 0)
|
|
575
|
+
.map((line) => line.replace(/^\.\//, "").replace(/^([^:]+):(\d+):/, "$1:$2: "));
|
|
576
|
+
if (lines.length > 200) {
|
|
577
|
+
return `${lines.slice(0, 200).join("\n")}\n[… limitado a 200 resultados]`;
|
|
578
|
+
}
|
|
579
|
+
return lines.join("\n");
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
let flags = "";
|
|
583
|
+
if (args.ignore_case) {
|
|
584
|
+
flags = "i";
|
|
585
|
+
}
|
|
586
|
+
let regex;
|
|
587
|
+
try {
|
|
588
|
+
regex = new RegExp(pattern, flags);
|
|
589
|
+
}
|
|
590
|
+
catch (err) {
|
|
591
|
+
return `Expresión regular inválida: ${err.message}`;
|
|
592
|
+
}
|
|
593
|
+
let include = "**/*";
|
|
594
|
+
if (args.include) {
|
|
595
|
+
include = String(args.include);
|
|
596
|
+
}
|
|
597
|
+
const files = await fg(include, { cwd: ctx.root, ignore: IGNORE, dot: false, onlyFiles: true });
|
|
598
|
+
const results = [];
|
|
599
|
+
for (const file of files) {
|
|
600
|
+
const abs = path.join(ctx.root, file);
|
|
601
|
+
let stat;
|
|
602
|
+
try {
|
|
603
|
+
stat = fs.statSync(abs);
|
|
604
|
+
}
|
|
605
|
+
catch {
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
if (stat.size > 1024 * 1024) {
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
const buffer = fs.readFileSync(abs);
|
|
612
|
+
if (buffer.includes(0)) {
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
615
|
+
const lines = buffer.toString("utf8").split("\n");
|
|
616
|
+
for (let i = 0; i < lines.length; i++) {
|
|
617
|
+
if (regex.test(lines[i])) {
|
|
618
|
+
results.push(`${file}:${i + 1}: ${lines[i].slice(0, 300)}`);
|
|
619
|
+
if (results.length >= 200) {
|
|
620
|
+
return results.join("\n") + "\n[… limitado a 200 resultados]";
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
if (results.length === 0) {
|
|
626
|
+
return "Sin coincidencias.";
|
|
627
|
+
}
|
|
628
|
+
return results.join("\n");
|
|
629
|
+
},
|
|
630
|
+
};
|
|
631
|
+
const bashTool = {
|
|
632
|
+
name: "bash",
|
|
633
|
+
description: "Ejecuta un comando de shell en la raíz del proyecto (tests, git, instalar dependencias…). No lo uses para leer o editar ficheros: para eso están las otras herramientas. No es interactivo: evita comandos que esperen que escribas algo.",
|
|
634
|
+
parameters: {
|
|
635
|
+
type: "object",
|
|
636
|
+
properties: {
|
|
637
|
+
command: { type: "string" },
|
|
638
|
+
description: { type: "string", description: "Qué hace el comando, en 5-10 palabras" },
|
|
639
|
+
timeout_ms: { type: "number", description: "Tiempo máximo en milisegundos (máximo 600000)" },
|
|
640
|
+
},
|
|
641
|
+
required: ["command"],
|
|
642
|
+
},
|
|
643
|
+
permission: "bash",
|
|
644
|
+
readOnly: false,
|
|
645
|
+
async run(args, ctx) {
|
|
646
|
+
const command = String(args.command ?? "");
|
|
647
|
+
let title = "Ejecutar comando";
|
|
648
|
+
if (args.description) {
|
|
649
|
+
title = `Ejecutar: ${String(args.description).slice(0, 80)}`;
|
|
650
|
+
}
|
|
651
|
+
const reply = await ctx.ask({ kind: "bash", title, preview: `$ ${command}`, subject: command });
|
|
652
|
+
if (!reply.allow) {
|
|
653
|
+
return rejected(reply, "ejecutar el comando");
|
|
654
|
+
}
|
|
655
|
+
let timeout = ctx.bashTimeoutMs;
|
|
656
|
+
if (args.timeout_ms && args.timeout_ms > 0) {
|
|
657
|
+
timeout = Math.min(args.timeout_ms, 600000);
|
|
658
|
+
}
|
|
659
|
+
return await new Promise((resolve) => {
|
|
660
|
+
let shell = "bash";
|
|
661
|
+
let shellArgs = ["-lc", command];
|
|
662
|
+
if (process.platform === "win32") {
|
|
663
|
+
shell = "cmd.exe";
|
|
664
|
+
shellArgs = ["/c", command];
|
|
665
|
+
}
|
|
666
|
+
const child = spawn(shell, shellArgs, {
|
|
667
|
+
cwd: ctx.root,
|
|
668
|
+
env: { ...process.env, VIPCODE: "1", GIT_TERMINAL_PROMPT: "0", GIT_PAGER: "cat", PAGER: "cat" },
|
|
669
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
670
|
+
// Grupo de procesos propio para poder matar también a los hijos (npm test, servidores…)
|
|
671
|
+
detached: process.platform !== "win32",
|
|
672
|
+
});
|
|
673
|
+
let output = "";
|
|
674
|
+
let timedOut = false;
|
|
675
|
+
const kill = () => {
|
|
676
|
+
try {
|
|
677
|
+
if (process.platform !== "win32" && child.pid) {
|
|
678
|
+
process.kill(-child.pid, "SIGTERM");
|
|
679
|
+
}
|
|
680
|
+
else {
|
|
681
|
+
child.kill("SIGTERM");
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
catch {
|
|
685
|
+
child.kill("SIGTERM");
|
|
686
|
+
}
|
|
687
|
+
setTimeout(() => {
|
|
688
|
+
try {
|
|
689
|
+
if (process.platform !== "win32" && child.pid) {
|
|
690
|
+
process.kill(-child.pid, "SIGKILL");
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
catch {
|
|
694
|
+
// ya había terminado
|
|
695
|
+
}
|
|
696
|
+
}, 3000).unref();
|
|
697
|
+
};
|
|
698
|
+
const timer = setTimeout(() => {
|
|
699
|
+
timedOut = true;
|
|
700
|
+
kill();
|
|
701
|
+
}, timeout);
|
|
702
|
+
ctx.signal?.addEventListener("abort", kill);
|
|
703
|
+
const onData = (d) => {
|
|
704
|
+
const text = d.toString();
|
|
705
|
+
output += text;
|
|
706
|
+
if (output.length > 5000000) {
|
|
707
|
+
output = output.slice(-2000000);
|
|
708
|
+
}
|
|
709
|
+
ctx.onProgress?.(text);
|
|
710
|
+
};
|
|
711
|
+
child.stdout.on("data", onData);
|
|
712
|
+
child.stderr.on("data", onData);
|
|
713
|
+
child.on("error", (err) => {
|
|
714
|
+
clearTimeout(timer);
|
|
715
|
+
resolve(`No se pudo ejecutar: ${err.message}`);
|
|
716
|
+
});
|
|
717
|
+
child.on("close", (code) => {
|
|
718
|
+
clearTimeout(timer);
|
|
719
|
+
ctx.signal?.removeEventListener("abort", kill);
|
|
720
|
+
let header = `Código de salida: ${code}`;
|
|
721
|
+
if (timedOut) {
|
|
722
|
+
header = `Cancelado por tiempo (${timeout} ms)`;
|
|
723
|
+
}
|
|
724
|
+
else if (ctx.signal?.aborted) {
|
|
725
|
+
header = "Cancelado por el usuario";
|
|
726
|
+
}
|
|
727
|
+
let body = output.trim();
|
|
728
|
+
if (body === "") {
|
|
729
|
+
body = "(sin salida)";
|
|
730
|
+
}
|
|
731
|
+
resolve(`${header}\n${truncate(body)}`);
|
|
732
|
+
});
|
|
733
|
+
});
|
|
734
|
+
},
|
|
735
|
+
};
|
|
736
|
+
// Convierte HTML en texto legible para el modelo (sin etiquetas, scripts ni estilos)
|
|
737
|
+
export function htmlToText(html) {
|
|
738
|
+
let text = html.replace(/<(script|style|noscript|svg|head|template)\b[\s\S]*?<\/\1>/gi, "");
|
|
739
|
+
text = text.replace(/<!--[\s\S]*?-->/g, "");
|
|
740
|
+
text = text.replace(/<h([1-6])[^>]*>/gi, (_match, level) => `\n\n${"#".repeat(Number(level))} `);
|
|
741
|
+
text = text.replace(/<li[^>]*>/gi, "\n- ");
|
|
742
|
+
text = text.replace(/<br\s*\/?>/gi, "\n");
|
|
743
|
+
text = text.replace(/<\/(p|div|h[1-6]|tr|pre|section|article|header|footer|blockquote|table|ul|ol)>/gi, "\n\n");
|
|
744
|
+
text = text.replace(/<[^>]+>/g, "");
|
|
745
|
+
return decodeEntities(text)
|
|
746
|
+
.split("\n")
|
|
747
|
+
.map((line) => line.replace(/[ \t]+/g, " ").trim())
|
|
748
|
+
.join("\n")
|
|
749
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
750
|
+
.trim();
|
|
751
|
+
}
|
|
752
|
+
function decodeEntities(text) {
|
|
753
|
+
const entities = {
|
|
754
|
+
nbsp: " ", amp: "&", lt: "<", gt: ">", quot: '"', apos: "'",
|
|
755
|
+
aacute: "á", eacute: "é", iacute: "í", oacute: "ó", uacute: "ú", ntilde: "ñ", uuml: "ü",
|
|
756
|
+
Aacute: "Á", Eacute: "É", Iacute: "Í", Oacute: "Ó", Uacute: "Ú", Ntilde: "Ñ", Uuml: "Ü",
|
|
757
|
+
iexcl: "¡", iquest: "¿", laquo: "«", raquo: "»", copy: "©", reg: "®", deg: "°", middot: "·",
|
|
758
|
+
ndash: "–", mdash: "—", hellip: "…", lsquo: "‘", rsquo: "’", ldquo: "“", rdquo: "”", bull: "•", euro: "€",
|
|
759
|
+
};
|
|
760
|
+
return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (match, code) => {
|
|
761
|
+
if (entities[code] !== undefined) {
|
|
762
|
+
return entities[code];
|
|
763
|
+
}
|
|
764
|
+
const lower = code.toLowerCase();
|
|
765
|
+
if (entities[lower] !== undefined) {
|
|
766
|
+
return entities[lower];
|
|
767
|
+
}
|
|
768
|
+
try {
|
|
769
|
+
if (lower.startsWith("#x")) {
|
|
770
|
+
return String.fromCodePoint(parseInt(lower.slice(2), 16));
|
|
771
|
+
}
|
|
772
|
+
if (lower.startsWith("#")) {
|
|
773
|
+
return String.fromCodePoint(Number(lower.slice(1)));
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
catch {
|
|
777
|
+
// número fuera de rango
|
|
778
|
+
}
|
|
779
|
+
return match;
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
function webSignal(ctx) {
|
|
783
|
+
const signals = [AbortSignal.timeout(30000)];
|
|
784
|
+
if (ctx.signal) {
|
|
785
|
+
signals.push(ctx.signal);
|
|
786
|
+
}
|
|
787
|
+
return AbortSignal.any(signals);
|
|
788
|
+
}
|
|
789
|
+
const BROWSER_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15";
|
|
790
|
+
const webFetch = {
|
|
791
|
+
name: "webfetch",
|
|
792
|
+
description: "Descarga una página web (documentación, issues, APIs…) y la devuelve como texto plano. Solo http/https.",
|
|
793
|
+
parameters: {
|
|
794
|
+
type: "object",
|
|
795
|
+
properties: {
|
|
796
|
+
url: { type: "string", description: "URL completa, por ejemplo https://nodejs.org/api/fs.html" },
|
|
797
|
+
},
|
|
798
|
+
required: ["url"],
|
|
799
|
+
},
|
|
800
|
+
permission: "web",
|
|
801
|
+
readOnly: true,
|
|
802
|
+
async run(args, ctx) {
|
|
803
|
+
const url = String(args.url ?? "");
|
|
804
|
+
let parsed;
|
|
805
|
+
try {
|
|
806
|
+
parsed = new URL(url);
|
|
807
|
+
}
|
|
808
|
+
catch {
|
|
809
|
+
return `Error: URL inválida: ${url}`;
|
|
810
|
+
}
|
|
811
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
812
|
+
return "Error: solo se admiten URLs http o https.";
|
|
813
|
+
}
|
|
814
|
+
const reply = await ctx.ask({ kind: "web", title: "Descargar página web", preview: `GET ${parsed.href}`, subject: parsed.href });
|
|
815
|
+
if (!reply.allow) {
|
|
816
|
+
return rejected(reply, "descargar la página");
|
|
817
|
+
}
|
|
818
|
+
const response = await fetch(parsed, {
|
|
819
|
+
signal: webSignal(ctx),
|
|
820
|
+
redirect: "follow",
|
|
821
|
+
headers: { "user-agent": "vipcode", accept: "text/html,text/plain,application/json;q=0.9,*/*;q=0.5" },
|
|
822
|
+
});
|
|
823
|
+
if (!response.ok) {
|
|
824
|
+
return `Error: HTTP ${response.status} ${response.statusText}`;
|
|
825
|
+
}
|
|
826
|
+
const type = response.headers.get("content-type") || "";
|
|
827
|
+
if (type !== "" && !/text|json|xml|javascript/.test(type)) {
|
|
828
|
+
return `Error: tipo de contenido no soportado (${type})`;
|
|
829
|
+
}
|
|
830
|
+
let body = await response.text();
|
|
831
|
+
if (/html/.test(type)) {
|
|
832
|
+
body = htmlToText(body);
|
|
833
|
+
}
|
|
834
|
+
if (body === "") {
|
|
835
|
+
body = "(página vacía)";
|
|
836
|
+
}
|
|
837
|
+
return `${response.url}\n\n${truncate(body)}`;
|
|
838
|
+
},
|
|
839
|
+
};
|
|
840
|
+
// Resultados de DuckDuckGo (versión HTML, sin clave)
|
|
841
|
+
export function parseSearchResults(html, max) {
|
|
842
|
+
const results = [];
|
|
843
|
+
const blocks = html.split(/class="result__a"/).slice(1);
|
|
844
|
+
for (const block of blocks) {
|
|
845
|
+
const link = /href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/.exec(block);
|
|
846
|
+
if (!link) {
|
|
847
|
+
continue;
|
|
848
|
+
}
|
|
849
|
+
let url = decodeEntities(link[1]);
|
|
850
|
+
const redirect = /[?&]uddg=([^&]+)/.exec(url);
|
|
851
|
+
if (redirect) {
|
|
852
|
+
url = decodeURIComponent(redirect[1]);
|
|
853
|
+
}
|
|
854
|
+
if (url.startsWith("//")) {
|
|
855
|
+
url = `https:${url}`;
|
|
856
|
+
}
|
|
857
|
+
if (url.includes("duckduckgo.com/y.js")) {
|
|
858
|
+
continue; // anuncios
|
|
859
|
+
}
|
|
860
|
+
const snippet = /class="result__snippet"[^>]*>([\s\S]*?)<\/a>/.exec(block);
|
|
861
|
+
results.push({
|
|
862
|
+
title: decodeEntities(link[2].replace(/<[^>]+>/g, "")).trim(),
|
|
863
|
+
url,
|
|
864
|
+
snippet: snippet ? decodeEntities(snippet[1].replace(/<[^>]+>/g, "")).replace(/\s+/g, " ").trim() : "",
|
|
865
|
+
});
|
|
866
|
+
if (results.length >= max) {
|
|
867
|
+
break;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
return results;
|
|
871
|
+
}
|
|
872
|
+
const webSearch = {
|
|
873
|
+
name: "websearch",
|
|
874
|
+
description: "Busca en internet (DuckDuckGo) y devuelve títulos, enlaces y un resumen de cada resultado. Úsalo para encontrar documentación o errores; después lee la página que te interese con webfetch.",
|
|
875
|
+
parameters: {
|
|
876
|
+
type: "object",
|
|
877
|
+
properties: {
|
|
878
|
+
query: { type: "string", description: "Lo que quieres buscar" },
|
|
879
|
+
max_results: { type: "number", description: "Número de resultados (por defecto 8)" },
|
|
880
|
+
},
|
|
881
|
+
required: ["query"],
|
|
882
|
+
},
|
|
883
|
+
permission: "web",
|
|
884
|
+
readOnly: true,
|
|
885
|
+
async run(args, ctx) {
|
|
886
|
+
const query = String(args.query ?? "").trim();
|
|
887
|
+
if (query === "") {
|
|
888
|
+
return "Error: la búsqueda está vacía.";
|
|
889
|
+
}
|
|
890
|
+
const reply = await ctx.ask({ kind: "web", title: "Buscar en internet", preview: `Buscar: ${query}`, subject: `search:${query}` });
|
|
891
|
+
if (!reply.allow) {
|
|
892
|
+
return rejected(reply, "buscar en internet");
|
|
893
|
+
}
|
|
894
|
+
const response = await fetch("https://html.duckduckgo.com/html/", {
|
|
895
|
+
method: "POST",
|
|
896
|
+
signal: webSignal(ctx),
|
|
897
|
+
headers: { "user-agent": BROWSER_UA, "content-type": "application/x-www-form-urlencoded" },
|
|
898
|
+
body: new URLSearchParams({ q: query }).toString(),
|
|
899
|
+
});
|
|
900
|
+
if (!response.ok) {
|
|
901
|
+
return `Error: el buscador respondió HTTP ${response.status}`;
|
|
902
|
+
}
|
|
903
|
+
let max = 8;
|
|
904
|
+
if (args.max_results && args.max_results > 0) {
|
|
905
|
+
max = Math.min(20, Math.floor(args.max_results));
|
|
906
|
+
}
|
|
907
|
+
const results = parseSearchResults(await response.text(), max);
|
|
908
|
+
if (results.length === 0) {
|
|
909
|
+
return "Sin resultados (o el buscador ha bloqueado la petición).";
|
|
910
|
+
}
|
|
911
|
+
return results.map((r, i) => `${i + 1}. ${r.title}\n ${r.url}\n ${r.snippet}`).join("\n\n");
|
|
912
|
+
},
|
|
913
|
+
};
|
|
914
|
+
const STATUS_ICON = { pending: "[ ]", in_progress: "[~]", completed: "[x]", cancelled: "[-]" };
|
|
915
|
+
export function formatTodos(todos) {
|
|
916
|
+
if (todos.length === 0) {
|
|
917
|
+
return "(lista de tareas vacía)";
|
|
918
|
+
}
|
|
919
|
+
return todos.map((todo) => `${STATUS_ICON[todo.status] || "[ ]"} ${todo.content}`).join("\n");
|
|
920
|
+
}
|
|
921
|
+
const todoWrite = {
|
|
922
|
+
name: "todowrite",
|
|
923
|
+
description: "Crea o actualiza la lista de tareas de esta conversación (sustituye la lista entera). Úsala en tareas de 3 pasos o más: apunta los pasos, marca uno como in_progress mientras trabajas en él y como completed nada más acabarlo.",
|
|
924
|
+
parameters: {
|
|
925
|
+
type: "object",
|
|
926
|
+
properties: {
|
|
927
|
+
todos: {
|
|
928
|
+
type: "array",
|
|
929
|
+
items: {
|
|
930
|
+
type: "object",
|
|
931
|
+
properties: {
|
|
932
|
+
content: { type: "string", description: "Qué hay que hacer" },
|
|
933
|
+
status: { type: "string", enum: ["pending", "in_progress", "completed", "cancelled"] },
|
|
934
|
+
priority: { type: "string", enum: ["high", "medium", "low"] },
|
|
935
|
+
},
|
|
936
|
+
required: ["content", "status"],
|
|
937
|
+
},
|
|
938
|
+
},
|
|
939
|
+
},
|
|
940
|
+
required: ["todos"],
|
|
941
|
+
},
|
|
942
|
+
permission: "none",
|
|
943
|
+
readOnly: true,
|
|
944
|
+
async run(args, ctx) {
|
|
945
|
+
if (!ctx.todos) {
|
|
946
|
+
return "Error: la lista de tareas no está disponible aquí.";
|
|
947
|
+
}
|
|
948
|
+
const valid = ["pending", "in_progress", "completed", "cancelled"];
|
|
949
|
+
const list = (Array.isArray(args.todos) ? args.todos : [])
|
|
950
|
+
.filter((item) => item && typeof item.content === "string" && item.content.trim() !== "")
|
|
951
|
+
.map((item) => {
|
|
952
|
+
const todo = { content: item.content.trim(), status: "pending" };
|
|
953
|
+
if (valid.includes(item.status)) {
|
|
954
|
+
todo.status = item.status;
|
|
955
|
+
}
|
|
956
|
+
if (["high", "medium", "low"].includes(item.priority)) {
|
|
957
|
+
todo.priority = item.priority;
|
|
958
|
+
}
|
|
959
|
+
return todo;
|
|
960
|
+
});
|
|
961
|
+
ctx.todos.set(list);
|
|
962
|
+
const pending = list.filter((todo) => todo.status === "pending" || todo.status === "in_progress").length;
|
|
963
|
+
return `Lista actualizada (${pending} pendientes):\n${formatTodos(list)}`;
|
|
964
|
+
},
|
|
965
|
+
};
|
|
966
|
+
const todoRead = {
|
|
967
|
+
name: "todoread",
|
|
968
|
+
description: "Lee la lista de tareas actual de esta conversación.",
|
|
969
|
+
parameters: { type: "object", properties: {} },
|
|
970
|
+
permission: "none",
|
|
971
|
+
readOnly: true,
|
|
972
|
+
async run(_args, ctx) {
|
|
973
|
+
if (!ctx.todos) {
|
|
974
|
+
return "Error: la lista de tareas no está disponible aquí.";
|
|
975
|
+
}
|
|
976
|
+
return formatTodos(ctx.todos.get());
|
|
977
|
+
},
|
|
978
|
+
};
|
|
979
|
+
const diagnosticsTool = {
|
|
980
|
+
name: "diagnostics",
|
|
981
|
+
description: "Pide al servidor de lenguaje (LSP) los errores y avisos de un fichero: tipos, sintaxis, imports… Más rápido que compilar todo el proyecto.",
|
|
982
|
+
parameters: {
|
|
983
|
+
type: "object",
|
|
984
|
+
properties: { path: { type: "string", description: "Fichero a revisar" } },
|
|
985
|
+
required: ["path"],
|
|
986
|
+
},
|
|
987
|
+
permission: "none",
|
|
988
|
+
readOnly: true,
|
|
989
|
+
async run(args, ctx) {
|
|
990
|
+
const file = resolvePath(ctx.root, args.path);
|
|
991
|
+
if (!ctx.diagnostics) {
|
|
992
|
+
return "No hay servidor de lenguaje disponible para este fichero.";
|
|
993
|
+
}
|
|
994
|
+
const found = await ctx.diagnostics(file, true);
|
|
995
|
+
if (!found) {
|
|
996
|
+
return `Sin errores en ${rel(ctx.root, file)} (o no hay servidor de lenguaje para este tipo de fichero).`;
|
|
997
|
+
}
|
|
998
|
+
return found;
|
|
999
|
+
},
|
|
1000
|
+
};
|
|
1001
|
+
// La herramienta "task" lista los subagentes que hay, así que se crea cuando se conocen
|
|
1002
|
+
export function taskTool(subagents) {
|
|
1003
|
+
return {
|
|
1004
|
+
name: "task",
|
|
1005
|
+
description: `Lanza un subagente que trabaja por su cuenta, con su propio contexto, y te devuelve un informe. Úsalo para investigaciones amplias o tareas independientes que llenarían tu contexto. Lanza varios a la vez si son independientes. Tipos:\n${subagents
|
|
1006
|
+
.map((agent) => `- ${agent.name}: ${agent.description}`)
|
|
1007
|
+
.join("\n")}`,
|
|
1008
|
+
parameters: {
|
|
1009
|
+
type: "object",
|
|
1010
|
+
properties: {
|
|
1011
|
+
description: { type: "string", description: "Resumen de la tarea en 3-5 palabras" },
|
|
1012
|
+
prompt: { type: "string", description: "Instrucciones completas para el subagente: qué hacer y qué debe devolverte" },
|
|
1013
|
+
subagent_type: { type: "string", enum: subagents.map((agent) => agent.name) },
|
|
1014
|
+
},
|
|
1015
|
+
required: ["description", "prompt", "subagent_type"],
|
|
1016
|
+
},
|
|
1017
|
+
permission: "none",
|
|
1018
|
+
readOnly: true,
|
|
1019
|
+
async run(args, ctx) {
|
|
1020
|
+
if (!ctx.subagent) {
|
|
1021
|
+
return "Error: los subagentes no están disponibles aquí.";
|
|
1022
|
+
}
|
|
1023
|
+
return await ctx.subagent(String(args.subagent_type || "general"), String(args.prompt || ""), String(args.description || ""));
|
|
1024
|
+
},
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
// Herramientas propias de vipcode (las de MCP y "task" se añaden aparte)
|
|
1028
|
+
export const CORE_TOOLS = [
|
|
1029
|
+
readFile,
|
|
1030
|
+
writeFile,
|
|
1031
|
+
editFile,
|
|
1032
|
+
listDir,
|
|
1033
|
+
globTool,
|
|
1034
|
+
grepTool,
|
|
1035
|
+
bashTool,
|
|
1036
|
+
webFetch,
|
|
1037
|
+
webSearch,
|
|
1038
|
+
todoWrite,
|
|
1039
|
+
todoRead,
|
|
1040
|
+
diagnosticsTool,
|
|
1041
|
+
];
|
|
1042
|
+
// Definiciones que se mandan al modelo
|
|
1043
|
+
export function toolDefinitions(tools) {
|
|
1044
|
+
return tools.map((tool) => ({
|
|
1045
|
+
type: "function",
|
|
1046
|
+
function: { name: tool.name, description: tool.description, parameters: tool.parameters },
|
|
1047
|
+
}));
|
|
1048
|
+
}
|
|
1049
|
+
export async function runTool(tools, name, rawArgs, ctx, readOnlyMode) {
|
|
1050
|
+
const tool = tools.find((t) => t.name === name);
|
|
1051
|
+
if (!tool) {
|
|
1052
|
+
return { output: `Error: la herramienta ${name} no existe. Usa solo las de la lista.`, isError: true };
|
|
1053
|
+
}
|
|
1054
|
+
if (readOnlyMode && !tool.readOnly) {
|
|
1055
|
+
return { output: "Error: estás en modo de solo lectura. Propón los cambios en texto.", isError: true };
|
|
1056
|
+
}
|
|
1057
|
+
let args = {};
|
|
1058
|
+
try {
|
|
1059
|
+
args = JSON.parse(rawArgs);
|
|
1060
|
+
}
|
|
1061
|
+
catch {
|
|
1062
|
+
return { output: `Error: argumentos JSON inválidos: ${rawArgs.slice(0, 200)}`, isError: true };
|
|
1063
|
+
}
|
|
1064
|
+
if (args === null || typeof args !== "object") {
|
|
1065
|
+
args = {};
|
|
1066
|
+
}
|
|
1067
|
+
// Los modelos flojos a veces llaman sin algún argumento: se lo decimos claro para que lo corrijan
|
|
1068
|
+
const required = Array.isArray(tool.parameters.required) ? tool.parameters.required : [];
|
|
1069
|
+
const missing = required.filter((key) => args[key] === undefined || args[key] === null || (key === "path" && String(args[key]).trim() === ""));
|
|
1070
|
+
if (missing.length > 0) {
|
|
1071
|
+
return {
|
|
1072
|
+
output: `Error: a ${name} le faltan argumentos obligatorios: ${missing.join(", ")}. Vuelve a llamarla con todos ellos en el JSON de argumentos.`,
|
|
1073
|
+
isError: true,
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
try {
|
|
1077
|
+
const result = await tool.run(args, ctx);
|
|
1078
|
+
if (typeof result === "string") {
|
|
1079
|
+
return { output: result, isError: result.startsWith("Error") };
|
|
1080
|
+
}
|
|
1081
|
+
return { ...result, isError: result.isError ?? result.output.startsWith("Error") };
|
|
1082
|
+
}
|
|
1083
|
+
catch (err) {
|
|
1084
|
+
return { output: `Error: ${err.message}`, isError: true };
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
// Resumen corto de una llamada para mostrarlo en pantalla
|
|
1088
|
+
export function describeCall(name, rawArgs) {
|
|
1089
|
+
try {
|
|
1090
|
+
const args = JSON.parse(rawArgs);
|
|
1091
|
+
if (name === "task") {
|
|
1092
|
+
return `${args.subagent_type || "general"}: ${args.description || ""}`.trim();
|
|
1093
|
+
}
|
|
1094
|
+
if (name === "todowrite" && Array.isArray(args.todos)) {
|
|
1095
|
+
return `${args.todos.length} tareas`;
|
|
1096
|
+
}
|
|
1097
|
+
if (name === "skill") {
|
|
1098
|
+
return `${args.name || ""}${args.file ? ` · ${args.file}` : ""}`;
|
|
1099
|
+
}
|
|
1100
|
+
if (args.path && args.pattern) {
|
|
1101
|
+
return `${args.pattern} en ${args.path}`;
|
|
1102
|
+
}
|
|
1103
|
+
if (args.path) {
|
|
1104
|
+
return String(args.path);
|
|
1105
|
+
}
|
|
1106
|
+
if (args.pattern) {
|
|
1107
|
+
return String(args.pattern);
|
|
1108
|
+
}
|
|
1109
|
+
if (args.command) {
|
|
1110
|
+
return String(args.command).slice(0, 120);
|
|
1111
|
+
}
|
|
1112
|
+
if (args.url) {
|
|
1113
|
+
return String(args.url).slice(0, 120);
|
|
1114
|
+
}
|
|
1115
|
+
if (args.query) {
|
|
1116
|
+
return String(args.query).slice(0, 120);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
catch {
|
|
1120
|
+
// ignorar
|
|
1121
|
+
}
|
|
1122
|
+
return "";
|
|
1123
|
+
}
|
|
1124
|
+
// Adjunta al mensaje el contenido de cada @ruta que exista en el proyecto (las imágenes van aparte)
|
|
1125
|
+
export function expandMentions(text, root, readTimes) {
|
|
1126
|
+
const files = [];
|
|
1127
|
+
const images = [];
|
|
1128
|
+
const blocks = [];
|
|
1129
|
+
const pattern = /(^|\s)@([^\s@]+)/g;
|
|
1130
|
+
let match;
|
|
1131
|
+
while ((match = pattern.exec(text)) !== null && files.length < 10) {
|
|
1132
|
+
// Probamos tal cual y sin la puntuación final ("mira @src/a.ts.")
|
|
1133
|
+
const candidates = [match[2], match[2].replace(/[.,;:!?)\]'"]+$/, "")];
|
|
1134
|
+
for (const candidate of candidates) {
|
|
1135
|
+
let abs;
|
|
1136
|
+
try {
|
|
1137
|
+
abs = resolvePath(root, candidate);
|
|
1138
|
+
}
|
|
1139
|
+
catch {
|
|
1140
|
+
continue;
|
|
1141
|
+
}
|
|
1142
|
+
if (!fs.existsSync(abs)) {
|
|
1143
|
+
continue;
|
|
1144
|
+
}
|
|
1145
|
+
const label = rel(root, abs);
|
|
1146
|
+
if (!files.includes(label)) {
|
|
1147
|
+
files.push(label);
|
|
1148
|
+
if (fs.statSync(abs).isDirectory()) {
|
|
1149
|
+
const entries = fs
|
|
1150
|
+
.readdirSync(abs, { withFileTypes: true })
|
|
1151
|
+
.filter((e) => e.name !== ".git" && e.name !== "node_modules")
|
|
1152
|
+
.slice(0, 300)
|
|
1153
|
+
.map((e) => {
|
|
1154
|
+
if (e.isDirectory()) {
|
|
1155
|
+
return `${e.name}/`;
|
|
1156
|
+
}
|
|
1157
|
+
return e.name;
|
|
1158
|
+
});
|
|
1159
|
+
blocks.push(`<carpeta ruta="${label}">\n${entries.join("\n")}\n</carpeta>`);
|
|
1160
|
+
}
|
|
1161
|
+
else if (isImagePath(abs)) {
|
|
1162
|
+
images.push(toDataUrl(abs));
|
|
1163
|
+
}
|
|
1164
|
+
else {
|
|
1165
|
+
blocks.push(`<fichero ruta="${label}">\n${readNumbered(abs, label)}\n</fichero>`);
|
|
1166
|
+
if (readTimes) {
|
|
1167
|
+
readTimes.set(abs, fs.statSync(abs).mtimeMs);
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
break;
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
if (blocks.length === 0) {
|
|
1175
|
+
return { text, files, images };
|
|
1176
|
+
}
|
|
1177
|
+
return { text: `${text}\n\nFicheros adjuntos con @ (ya leídos):\n\n${blocks.join("\n\n")}`, files, images };
|
|
1178
|
+
}
|
|
1179
|
+
// Lista de ficheros del proyecto para autocompletar @rutas (se guarda un rato para ir rápido)
|
|
1180
|
+
let fileCache = null;
|
|
1181
|
+
export function projectFiles(root) {
|
|
1182
|
+
if (fileCache && fileCache.root === root && Date.now() - fileCache.at < 15000) {
|
|
1183
|
+
return fileCache.files;
|
|
1184
|
+
}
|
|
1185
|
+
let files = [];
|
|
1186
|
+
try {
|
|
1187
|
+
files = fg.sync("**/*", { cwd: root, ignore: IGNORE, dot: true, onlyFiles: false, markDirectories: true, deep: 8, suppressErrors: true }).slice(0, 20000);
|
|
1188
|
+
}
|
|
1189
|
+
catch {
|
|
1190
|
+
files = [];
|
|
1191
|
+
}
|
|
1192
|
+
fileCache = { root, at: Date.now(), files };
|
|
1193
|
+
return files;
|
|
1194
|
+
}
|
|
1195
|
+
// Sugerencias para @ruta: primero lo que empieza igual, luego lo que contiene el texto
|
|
1196
|
+
export function suggestFiles(root, typed, max = 8) {
|
|
1197
|
+
const query = typed.toLowerCase();
|
|
1198
|
+
const files = projectFiles(root);
|
|
1199
|
+
const scored = [];
|
|
1200
|
+
for (const file of files) {
|
|
1201
|
+
const lower = file.toLowerCase();
|
|
1202
|
+
const base = lower.slice(lower.replace(/\/$/, "").lastIndexOf("/") + 1);
|
|
1203
|
+
let score = -1;
|
|
1204
|
+
if (query === "") {
|
|
1205
|
+
score = file.split("/").length * 10 + file.length / 100;
|
|
1206
|
+
}
|
|
1207
|
+
else if (lower.startsWith(query)) {
|
|
1208
|
+
score = file.length / 100;
|
|
1209
|
+
}
|
|
1210
|
+
else if (base.startsWith(query)) {
|
|
1211
|
+
score = 10 + file.length / 100;
|
|
1212
|
+
}
|
|
1213
|
+
else if (lower.includes(query)) {
|
|
1214
|
+
score = 20 + file.length / 100;
|
|
1215
|
+
}
|
|
1216
|
+
if (score >= 0) {
|
|
1217
|
+
scored.push({ file, score });
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
scored.sort((a, b) => a.score - b.score);
|
|
1221
|
+
return scored.slice(0, max).map((entry) => entry.file);
|
|
1222
|
+
}
|
|
1223
|
+
// Autocompletado con Tab de @rutas en el modo de texto simple
|
|
1224
|
+
export function completeMention(root, typed) {
|
|
1225
|
+
let dirPart = "";
|
|
1226
|
+
const slash = typed.lastIndexOf("/");
|
|
1227
|
+
if (slash >= 0) {
|
|
1228
|
+
dirPart = typed.slice(0, slash + 1);
|
|
1229
|
+
}
|
|
1230
|
+
const base = typed.slice(dirPart.length);
|
|
1231
|
+
try {
|
|
1232
|
+
const dir = resolvePath(root, dirPart || ".");
|
|
1233
|
+
return fs
|
|
1234
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
1235
|
+
.filter((e) => e.name.startsWith(base) && e.name !== ".git" && e.name !== "node_modules")
|
|
1236
|
+
.map((e) => {
|
|
1237
|
+
if (e.isDirectory()) {
|
|
1238
|
+
return `@${dirPart}${e.name}/`;
|
|
1239
|
+
}
|
|
1240
|
+
return `@${dirPart}${e.name}`;
|
|
1241
|
+
})
|
|
1242
|
+
.sort();
|
|
1243
|
+
}
|
|
1244
|
+
catch {
|
|
1245
|
+
return [];
|
|
1246
|
+
}
|
|
1247
|
+
}
|