vipcode 0.2.0 → 0.2.2
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 +1 -1
- package/README.en.md +7 -1
- package/README.md +37 -9
- package/dist/agent.js +20 -1
- package/dist/agents.js +6 -3
- package/dist/app.js +27 -15
- package/dist/auth.js +2 -2
- package/dist/commands.js +4 -2
- package/dist/config.js +10 -5
- package/dist/debug.js +2 -2
- package/dist/discovery.js +2 -2
- package/dist/index.js +56 -3
- package/dist/labels.js +20 -0
- package/dist/markdown.js +5 -2
- package/dist/paths.js +26 -0
- package/dist/permissions.js +26 -2
- package/dist/ranking.js +2 -2
- package/dist/sanitize.js +38 -0
- package/dist/session.js +123 -53
- package/dist/skills.js +2 -1
- package/dist/tools.js +72 -8
- package/dist/trust.js +171 -0
- package/dist/tui/app.js +57 -36
- package/dist/tui/dialogs.js +3 -2
- package/dist/ui.js +44 -11
- package/dist/update.js +6 -4
- package/package.json +2 -2
package/dist/labels.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Cómo se cuenta cada herramienta en pantalla y en las conversaciones guardadas
|
|
2
|
+
const TOOL_LABEL = {
|
|
3
|
+
read_file: "Lee",
|
|
4
|
+
write_file: "Escribe",
|
|
5
|
+
edit_file: "Edita",
|
|
6
|
+
list_dir: "Lista",
|
|
7
|
+
glob: "Busca ficheros",
|
|
8
|
+
grep: "Busca",
|
|
9
|
+
bash: "Ejecuta",
|
|
10
|
+
webfetch: "Abre",
|
|
11
|
+
websearch: "Busca en internet",
|
|
12
|
+
todowrite: "Tareas",
|
|
13
|
+
todoread: "Tareas",
|
|
14
|
+
diagnostics: "Diagnósticos",
|
|
15
|
+
task: "Subagente",
|
|
16
|
+
skill: "Skill",
|
|
17
|
+
};
|
|
18
|
+
export function toolLabel(name) {
|
|
19
|
+
return TOOL_LABEL[name] || name;
|
|
20
|
+
}
|
package/dist/markdown.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import hljs from "highlight.js/lib/common";
|
|
2
2
|
import { marked } from "marked";
|
|
3
3
|
import pc from "picocolors";
|
|
4
|
+
import { stripControl } from "./sanitize.js";
|
|
4
5
|
// Colores de resaltado de código por clase de highlight.js
|
|
5
6
|
const CODE_COLORS = {
|
|
6
7
|
keyword: pc.magenta,
|
|
@@ -249,11 +250,13 @@ function blocks(tokens, width, depth = 0) {
|
|
|
249
250
|
}
|
|
250
251
|
// Markdown → texto con colores para la terminal
|
|
251
252
|
export function renderMarkdown(text, width = 80) {
|
|
253
|
+
// El texto del modelo no puede llevar secuencias de la terminal: los colores los ponemos nosotros
|
|
254
|
+
const clean = stripControl(text);
|
|
252
255
|
try {
|
|
253
|
-
const tokens = marked.lexer(
|
|
256
|
+
const tokens = marked.lexer(clean, { gfm: true });
|
|
254
257
|
return blocks(tokens, width).replace(/\n{3,}/g, "\n\n");
|
|
255
258
|
}
|
|
256
259
|
catch {
|
|
257
|
-
return
|
|
260
|
+
return clean;
|
|
258
261
|
}
|
|
259
262
|
}
|
package/dist/paths.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
1
2
|
import os from "node:os";
|
|
2
3
|
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
3
5
|
// Carpeta de datos de vipcode (VIPCODE_HOME permite usar otra, por ejemplo en pruebas)
|
|
4
6
|
export const HOME_DIR = process.env.VIPCODE_HOME || path.join(os.homedir(), ".vipcode");
|
|
5
7
|
export const CONFIG_PATH = path.join(HOME_DIR, "config.json");
|
|
@@ -15,6 +17,8 @@ export const HISTORY_PATH = path.join(HOME_DIR, "history.jsonl");
|
|
|
15
17
|
export const NOTICES_PATH = path.join(HOME_DIR, "notices.json");
|
|
16
18
|
// Última comprobación de versiones nuevas en GitHub
|
|
17
19
|
export const UPDATE_PATH = path.join(HOME_DIR, "update.json");
|
|
20
|
+
// Carpetas en las que confías para usar la configuración que traen
|
|
21
|
+
export const TRUST_PATH = path.join(HOME_DIR, "trusted.json");
|
|
18
22
|
export const SESSIONS_DIR = path.join(HOME_DIR, "sessions");
|
|
19
23
|
export const SNAPSHOTS_DIR = path.join(HOME_DIR, "snapshots");
|
|
20
24
|
export const EXPORTS_DIR = path.join(HOME_DIR, "exports");
|
|
@@ -25,3 +29,25 @@ export const GLOBAL_SKILLS_DIR = path.join(HOME_DIR, "skills");
|
|
|
25
29
|
// Instrucciones que se aplican a todos tus proyectos
|
|
26
30
|
export const GLOBAL_INSTRUCTIONS_PATH = path.join(HOME_DIR, "AGENTS.md");
|
|
27
31
|
export const DEBUG_LOG_PATH = path.join(HOME_DIR, "debug.log");
|
|
32
|
+
// La carpeta de datos guarda claves y conversaciones: solo la puede abrir tu usuario
|
|
33
|
+
export function ensureHomeDir() {
|
|
34
|
+
fs.mkdirSync(HOME_DIR, { recursive: true, mode: 0o700 });
|
|
35
|
+
try {
|
|
36
|
+
fs.chmodSync(HOME_DIR, 0o700);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// en Windows o en carpetas que no son tuyas no se puede: no pasa nada
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// Carpeta donde está instalado vipcode (la que contiene dist/)
|
|
43
|
+
export const INSTALL_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
44
|
+
// Lo que el agente no puede tocar: los datos de vipcode (claves, configuración, carpetas de confianza) ni leerlos,
|
|
45
|
+
// y su propio código no modificarlo. Quien desarrolle vipcode con vipcode puede permitir lo segundo con
|
|
46
|
+
// VIPCODE_ALLOW_SELF_EDIT=1.
|
|
47
|
+
export function protectedDirs() {
|
|
48
|
+
const dirs = [{ dir: HOME_DIR, read: true }];
|
|
49
|
+
if (process.env.VIPCODE_ALLOW_SELF_EDIT !== "1") {
|
|
50
|
+
dirs.push({ dir: INSTALL_DIR, read: false });
|
|
51
|
+
}
|
|
52
|
+
return dirs;
|
|
53
|
+
}
|
package/dist/permissions.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
1
3
|
import { deepMerge } from "./config.js";
|
|
2
4
|
import { wildcardMatch } from "./match.js";
|
|
5
|
+
import { HOME_DIR, INSTALL_DIR } from "./paths.js";
|
|
3
6
|
const STRICTNESS = { allow: 0, ask: 1, deny: 2 };
|
|
4
7
|
function strictest(a, b) {
|
|
5
8
|
if (STRICTNESS[b] > STRICTNESS[a]) {
|
|
@@ -27,9 +30,13 @@ export function matchRules(rules, subject, fallback) {
|
|
|
27
30
|
return fallback;
|
|
28
31
|
}
|
|
29
32
|
// Parte "a && b | c; d" en comandos sueltos para que "git status && rm -rf x" no pase como "git status"
|
|
33
|
+
// Redirecciones que no escriben ficheros (2>&1, >/dev/null): no cuentan como separadores ni como escritura
|
|
34
|
+
const HARMLESS_REDIRECTS = /\d*>&\d+|&>\s*\/dev\/null|\d*>>?\s*\/dev\/null/g;
|
|
30
35
|
export function splitCommand(command) {
|
|
36
|
+
// Un & suelto (segundo plano) también separa: «ls & rm -rf ~» son dos comandos
|
|
31
37
|
return command
|
|
32
|
-
.
|
|
38
|
+
.replace(HARMLESS_REDIRECTS, " ")
|
|
39
|
+
.split(/&&|\|\||;|\||\n|&/)
|
|
33
40
|
.map((part) => part.trim())
|
|
34
41
|
.filter((part) => part.length > 0);
|
|
35
42
|
}
|
|
@@ -55,7 +62,10 @@ export function evaluate(config, kind, subject) {
|
|
|
55
62
|
result = strictest(result, matchRules(config.bash, part, "ask"));
|
|
56
63
|
}
|
|
57
64
|
// Una sustitución $(...) o `...` puede esconder cualquier cosa
|
|
58
|
-
|
|
65
|
+
// Sustituciones, escribir en ficheros (> o --output) y saltos de línea raros siempre se preguntan,
|
|
66
|
+
// aunque el comando esté permitido (si no, «ls > ~/.zshrc» pasaría por «ls*»)
|
|
67
|
+
const writes = /[<>]\(|>/.test(subject.replace(HARMLESS_REDIRECTS, " ")) || /--output\b|\r/.test(subject);
|
|
68
|
+
if (/\$\(|`/.test(subject) || writes) {
|
|
59
69
|
result = strictest(result, "ask");
|
|
60
70
|
}
|
|
61
71
|
return result;
|
|
@@ -106,12 +116,26 @@ export class SessionGrants {
|
|
|
106
116
|
}
|
|
107
117
|
}
|
|
108
118
|
// Decide si una acción se permite sola, se bloquea o hay que preguntar
|
|
119
|
+
// Un comando que nombra la carpeta de datos de vipcode o su instalación (para leer tus claves, cambiar la
|
|
120
|
+
// configuración o tocar su propio código) se pregunta siempre: no vale ninguna regla allow ni «sí a todo»
|
|
121
|
+
export function touchesVipcode(command) {
|
|
122
|
+
const home = os.homedir();
|
|
123
|
+
const names = [HOME_DIR, INSTALL_DIR, "lib/node_modules/vipcode"];
|
|
124
|
+
if (HOME_DIR.startsWith(home + path.sep)) {
|
|
125
|
+
const inHome = HOME_DIR.slice(home.length + 1);
|
|
126
|
+
names.push(`~/${inHome}`, `$HOME/${inHome}`, `\${HOME}/${inHome}`);
|
|
127
|
+
}
|
|
128
|
+
return names.some((name) => command.includes(name));
|
|
129
|
+
}
|
|
109
130
|
export function createAsker(getConfig, grants, prompt) {
|
|
110
131
|
return async (request) => {
|
|
111
132
|
const action = evaluate(getConfig(), request.kind, request.subject);
|
|
112
133
|
if (action === "deny") {
|
|
113
134
|
return { allow: false, feedback: "bloqueado por la configuración de permisos (deny)" };
|
|
114
135
|
}
|
|
136
|
+
if (request.kind === "bash" && touchesVipcode(request.subject)) {
|
|
137
|
+
return prompt({ ...request, title: `${request.title} (toca la carpeta de vipcode)` });
|
|
138
|
+
}
|
|
115
139
|
if (action === "allow" || grants.allows(request.kind, request.subject)) {
|
|
116
140
|
return { allow: true };
|
|
117
141
|
}
|
package/dist/ranking.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import { readAuth } from "./auth.js";
|
|
3
3
|
import { debugLog } from "./debug.js";
|
|
4
|
-
import {
|
|
4
|
+
import { RANKING_PATH, ensureHomeDir } from "./paths.js";
|
|
5
5
|
const WEIGHTS = {
|
|
6
6
|
coding: { code: 0.4, agents: 0.3, tools: 0.15, general: 0.15 },
|
|
7
7
|
general: { general: 1 },
|
|
@@ -223,7 +223,7 @@ export async function refreshRanking(apiKey, signal) {
|
|
|
223
223
|
throw new Error("la API no devolvió puntuaciones que vipcode sepa leer");
|
|
224
224
|
}
|
|
225
225
|
const data = { updated: new Date().toISOString().slice(0, 10), source: "https://llm-stats.com (API)", models: rated };
|
|
226
|
-
|
|
226
|
+
ensureHomeDir();
|
|
227
227
|
fs.writeFileSync(RANKING_PATH, JSON.stringify(data));
|
|
228
228
|
debugLog("ranking actualizado", rated.length);
|
|
229
229
|
return { count: rated.length };
|
package/dist/sanitize.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Lo que llega del modelo, de las herramientas o de los proveedores no puede mover el cursor, borrar la pantalla,
|
|
2
|
+
// cambiar el portapapeles (OSC 52) ni disfrazar un comando en el diálogo de permiso
|
|
3
|
+
// Secuencias completas (CSI, OSC, DCS…) y escapes sueltos
|
|
4
|
+
const SEQUENCES = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?|\x1b[PX^_][^\x1b]*(?:\x1b\\)?|\x1b[@-Z\\-_]?/g;
|
|
5
|
+
// Caracteres de control salvo el tabulador y el salto de línea (incluye \r y los C1, como el CSI de un byte)
|
|
6
|
+
const CONTROLS = /[\x00-\x08\x0b-\x1f\x7f-\x9f]/g;
|
|
7
|
+
// Colores y estilos (SGR): son inofensivos salvo «oculto» (8), que escondería texto
|
|
8
|
+
const SGR = /\x1b\[([0-9;]*)m/g;
|
|
9
|
+
// Para enseñar texto que no es nuestro
|
|
10
|
+
export function stripControl(text) {
|
|
11
|
+
return String(text).replace(SEQUENCES, "").replace(CONTROLS, "");
|
|
12
|
+
}
|
|
13
|
+
// Para nuestros propios bloques (tablas con colores) que llevan dentro datos de fuera: deja solo los colores
|
|
14
|
+
export function stripUnsafe(text) {
|
|
15
|
+
const input = String(text);
|
|
16
|
+
let result = "";
|
|
17
|
+
let last = 0;
|
|
18
|
+
for (const match of input.matchAll(SGR)) {
|
|
19
|
+
result += stripControl(input.slice(last, match.index));
|
|
20
|
+
if (!match[1].split(";").includes("8")) {
|
|
21
|
+
result += match[0];
|
|
22
|
+
}
|
|
23
|
+
last = (match.index || 0) + match[0].length;
|
|
24
|
+
}
|
|
25
|
+
return result + stripControl(input.slice(last));
|
|
26
|
+
}
|
|
27
|
+
// Para lo que vas a aprobar (comandos, rutas, diffs): los caracteres de control se ven en vez de actuar
|
|
28
|
+
export function showControl(text) {
|
|
29
|
+
return String(text).replace(CONTROLS, (char) => {
|
|
30
|
+
if (char === "\x1b") {
|
|
31
|
+
return "␛";
|
|
32
|
+
}
|
|
33
|
+
if (char === "\r") {
|
|
34
|
+
return "␍";
|
|
35
|
+
}
|
|
36
|
+
return `\\x${char.charCodeAt(0).toString(16).padStart(2, "0")}`;
|
|
37
|
+
});
|
|
38
|
+
}
|
package/dist/session.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { toolLabel } from "./labels.js";
|
|
4
|
+
import { EXPORTS_DIR, HISTORY_PATH, SESSIONS_DIR, ensureHomeDir } from "./paths.js";
|
|
5
|
+
import { describeCall } from "./tools.js";
|
|
4
6
|
export function newSession(cwd) {
|
|
5
7
|
const now = new Date();
|
|
6
8
|
// id legible y ordenable: 20261029-174512-k3x
|
|
@@ -18,26 +20,98 @@ export function newSession(cwd) {
|
|
|
18
20
|
redo: [],
|
|
19
21
|
epoch: 0,
|
|
20
22
|
usage: { prompt: 0, completion: 0 },
|
|
23
|
+
log: [],
|
|
21
24
|
};
|
|
22
25
|
}
|
|
26
|
+
function contentText(content) {
|
|
27
|
+
if (typeof content === "string") {
|
|
28
|
+
return content;
|
|
29
|
+
}
|
|
30
|
+
if (Array.isArray(content)) {
|
|
31
|
+
return content
|
|
32
|
+
.map((part) => {
|
|
33
|
+
if (part.type === "text") {
|
|
34
|
+
return part.text;
|
|
35
|
+
}
|
|
36
|
+
if (part.type === "image_url") {
|
|
37
|
+
return "[imagen]";
|
|
38
|
+
}
|
|
39
|
+
return "";
|
|
40
|
+
})
|
|
41
|
+
.join("\n");
|
|
42
|
+
}
|
|
43
|
+
return "";
|
|
44
|
+
}
|
|
45
|
+
// Lo que escribiste, sin los ficheros adjuntos con @ ni las notas que añade vipcode
|
|
46
|
+
function userText(text) {
|
|
47
|
+
let clean = text;
|
|
48
|
+
const attached = clean.indexOf("\n\nFicheros adjuntos con @");
|
|
49
|
+
if (attached >= 0) {
|
|
50
|
+
const names = [...clean.slice(attached).matchAll(/<(?:fichero|carpeta) ruta="([^"]+)">/g)].map((match) => match[1]);
|
|
51
|
+
clean = `${clean.slice(0, attached)}\n\n_Adjuntos: ${names.join(", ")}_`;
|
|
52
|
+
}
|
|
53
|
+
return clean.replace(/^\((?:Nota|El usuario ha ejecutado)[\s\S]*?\)\n\n/, "").replace(/^\[Mensaje nuevo del usuario mientras trabajabas\] /, "");
|
|
54
|
+
}
|
|
55
|
+
// Las sesiones de antes no tenían historial: se saca de los mensajes que quedan
|
|
56
|
+
function logFromMessages(messages, time) {
|
|
57
|
+
const log = [];
|
|
58
|
+
const results = new Map();
|
|
59
|
+
for (const message of messages) {
|
|
60
|
+
if (message.role === "tool") {
|
|
61
|
+
results.set(String(message.tool_call_id), contentText(message.content));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
for (const message of messages) {
|
|
65
|
+
if (message.role === "user") {
|
|
66
|
+
const text = contentText(message.content);
|
|
67
|
+
if (/^(Continúa con la tarea pendiente\.|Has hecho la misma llamada|Has repetido la misma llamada|Imágenes devueltas por las herramientas:)/.test(text)) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (text.startsWith("Resumen de la conversación anterior:")) {
|
|
71
|
+
log.push({ role: "info", text: "Conversación resumida para ahorrar contexto (lo anterior al resumen ya no se guardaba entonces).", time });
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
log.push({ role: "user", text: userText(text), time });
|
|
75
|
+
}
|
|
76
|
+
else if (message.role === "assistant") {
|
|
77
|
+
const text = contentText(message.content);
|
|
78
|
+
if (text.trim() !== "" && text !== "Entendido, sigo desde aquí.") {
|
|
79
|
+
log.push({ role: "assistant", text, time });
|
|
80
|
+
}
|
|
81
|
+
for (const call of (message.tool_calls || [])) {
|
|
82
|
+
const name = String(call.function?.name || "");
|
|
83
|
+
const output = results.get(String(call.id)) || "";
|
|
84
|
+
log.push({ role: "tool", text: output.slice(0, 4000), time, tool: { name, summary: describeCall(name, call.function?.arguments || ""), isError: output.startsWith("Error") } });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return log;
|
|
89
|
+
}
|
|
23
90
|
// Las sesiones de la versión anterior no tenían todos los campos
|
|
24
91
|
function normalize(data) {
|
|
92
|
+
const messages = Array.isArray(data.messages) ? data.messages : [];
|
|
25
93
|
return {
|
|
26
94
|
id: String(data.id),
|
|
27
95
|
title: data.title || "(sin título)",
|
|
28
96
|
cwd: data.cwd || "",
|
|
29
97
|
created: data.created || data.updated || Date.now(),
|
|
30
98
|
updated: data.updated || Date.now(),
|
|
31
|
-
messages
|
|
99
|
+
messages,
|
|
32
100
|
todos: Array.isArray(data.todos) ? data.todos : [],
|
|
33
101
|
turns: Array.isArray(data.turns) ? data.turns : [],
|
|
34
102
|
redo: Array.isArray(data.redo) ? data.redo : [],
|
|
35
103
|
epoch: data.epoch || 0,
|
|
36
104
|
usage: data.usage || { prompt: 0, completion: 0 },
|
|
37
105
|
agent: data.agent,
|
|
106
|
+
log: Array.isArray(data.log) ? data.log : logFromMessages(messages, data.updated || Date.now()),
|
|
38
107
|
};
|
|
39
108
|
}
|
|
40
109
|
function firstUserText(session) {
|
|
110
|
+
// Lo que escribiste (por ejemplo «/review»), mejor que el mensaje ya expandido
|
|
111
|
+
const logged = session.log.find((entry) => entry.role === "user");
|
|
112
|
+
if (logged) {
|
|
113
|
+
return logged.text;
|
|
114
|
+
}
|
|
41
115
|
const first = session.messages.find((m) => m.role === "user");
|
|
42
116
|
if (!first) {
|
|
43
117
|
return "";
|
|
@@ -65,6 +139,16 @@ export function saveSession(session) {
|
|
|
65
139
|
}
|
|
66
140
|
}
|
|
67
141
|
fs.writeFileSync(path.join(SESSIONS_DIR, `${session.id}.json`), JSON.stringify(session));
|
|
142
|
+
// Y la conversación entera en Markdown, para leerla cuando quieras
|
|
143
|
+
try {
|
|
144
|
+
fs.writeFileSync(sessionMarkdownPath(session), exportMarkdown(session));
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
// no es grave: lo importante es el .json
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
export function sessionMarkdownPath(session) {
|
|
151
|
+
return path.join(SESSIONS_DIR, `${session.id}.md`);
|
|
68
152
|
}
|
|
69
153
|
export function listSessions(cwd) {
|
|
70
154
|
if (!fs.existsSync(SESSIONS_DIR)) {
|
|
@@ -80,6 +164,10 @@ export function listSessions(cwd) {
|
|
|
80
164
|
if (!cwd || data.cwd === cwd) {
|
|
81
165
|
sessions.push(data);
|
|
82
166
|
}
|
|
167
|
+
// Las conversaciones de antes de que existiera el .md también lo tienen
|
|
168
|
+
if (data.log.length > 0 && !fs.existsSync(sessionMarkdownPath(data))) {
|
|
169
|
+
fs.writeFileSync(sessionMarkdownPath(data), exportMarkdown(data));
|
|
170
|
+
}
|
|
83
171
|
}
|
|
84
172
|
catch {
|
|
85
173
|
// sesión corrupta: la ignoramos
|
|
@@ -102,75 +190,57 @@ export function deleteSession(id) {
|
|
|
102
190
|
return false;
|
|
103
191
|
}
|
|
104
192
|
fs.rmSync(file);
|
|
193
|
+
fs.rmSync(path.join(SESSIONS_DIR, `${id}.md`), { force: true });
|
|
105
194
|
return true;
|
|
106
195
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
return content
|
|
113
|
-
.map((part) => {
|
|
114
|
-
if (part.type === "text") {
|
|
115
|
-
return part.text;
|
|
116
|
-
}
|
|
117
|
-
if (part.type === "image_url") {
|
|
118
|
-
return "[imagen]";
|
|
119
|
-
}
|
|
120
|
-
return "";
|
|
121
|
-
})
|
|
122
|
-
.join("\n");
|
|
123
|
-
}
|
|
124
|
-
return "";
|
|
196
|
+
// Un bloque de código que no se rompe aunque el texto lleve ```
|
|
197
|
+
function fence(text, lang = "") {
|
|
198
|
+
const longest = Math.max(2, ...[...text.matchAll(/`+/g)].map((match) => match[0].length));
|
|
199
|
+
const ticks = "`".repeat(longest + 1);
|
|
200
|
+
return [`${ticks}${lang}`, text, ticks];
|
|
125
201
|
}
|
|
126
|
-
//
|
|
202
|
+
// En español aunque el sistema no diga el idioma: 29/10/2026, 20:06:06
|
|
203
|
+
function when(time) {
|
|
204
|
+
return new Date(time).toLocaleString("es-ES");
|
|
205
|
+
}
|
|
206
|
+
// La conversación entera en Markdown (sale del historial, que no se resume nunca)
|
|
127
207
|
export function exportMarkdown(session) {
|
|
128
208
|
const lines = [
|
|
129
209
|
`# ${session.title}`,
|
|
130
210
|
"",
|
|
131
211
|
`- Carpeta: \`${session.cwd}\``,
|
|
132
|
-
`- Empezada: ${
|
|
133
|
-
`- Última actividad: ${new Date(session.updated).toLocaleString()}`,
|
|
212
|
+
`- Empezada: ${when(session.created)} · Última actividad: ${when(session.updated)}`,
|
|
134
213
|
`- Tokens: ${session.usage.prompt} de entrada, ${session.usage.completion} de salida`,
|
|
135
214
|
"",
|
|
215
|
+
"---",
|
|
216
|
+
"",
|
|
136
217
|
];
|
|
137
|
-
for (const
|
|
138
|
-
if (
|
|
139
|
-
|
|
218
|
+
for (const entry of session.log) {
|
|
219
|
+
if (entry.role === "user") {
|
|
220
|
+
const images = entry.images ? ` · ${entry.images} imagen${entry.images > 1 ? "es" : ""}` : "";
|
|
221
|
+
lines.push(`### Tú · ${when(entry.time)}${images}`, "", entry.text, "");
|
|
140
222
|
}
|
|
141
|
-
if (
|
|
142
|
-
|
|
143
|
-
const attached = text.indexOf("\n\nFicheros adjuntos con @");
|
|
144
|
-
if (attached >= 0) {
|
|
145
|
-
const names = [...text.slice(attached).matchAll(/<(?:fichero|carpeta) ruta="([^"]+)">/g)].map((match) => match[1]);
|
|
146
|
-
text = `${text.slice(0, attached)}\n\n_Adjuntos: ${names.join(", ")}_`;
|
|
147
|
-
}
|
|
148
|
-
lines.push("## Tú", "", text, "");
|
|
223
|
+
else if (entry.role === "assistant") {
|
|
224
|
+
lines.push(`### vipcode${entry.model ? ` · ${entry.model}` : ""}`, "", entry.text, "");
|
|
149
225
|
}
|
|
150
|
-
else if (
|
|
151
|
-
const
|
|
152
|
-
lines.push("
|
|
153
|
-
if (
|
|
154
|
-
lines.push(
|
|
226
|
+
else if (entry.role === "tool" && entry.tool) {
|
|
227
|
+
const summary = entry.tool.summary ? ` \`${entry.tool.summary.replace(/`/g, "'")}\`` : "";
|
|
228
|
+
lines.push(`- **${toolLabel(entry.tool.name)}**${summary}${entry.tool.isError ? " — ✗ falló" : ""}`);
|
|
229
|
+
if (entry.tool.display) {
|
|
230
|
+
lines.push("", ...fence(entry.tool.display, "diff"), "");
|
|
155
231
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
args = `${args.slice(0, 600)}…`;
|
|
232
|
+
else if (entry.tool.isError || entry.tool.name === "bash") {
|
|
233
|
+
const output = entry.text.split("\n").slice(0, 15).join("\n");
|
|
234
|
+
if (output.trim() !== "") {
|
|
235
|
+
lines.push("", ...fence(output), "");
|
|
161
236
|
}
|
|
162
|
-
lines.push(`> 🔧 **${call.function?.name}** \`${args.replace(/`/g, "'")}\``, "");
|
|
163
237
|
}
|
|
164
238
|
}
|
|
165
|
-
else if (
|
|
166
|
-
|
|
167
|
-
if (text.length > 2000) {
|
|
168
|
-
text = `${text.slice(0, 2000)}\n[…]`;
|
|
169
|
-
}
|
|
170
|
-
lines.push("<details><summary>Resultado</summary>", "", "```", text.replace(/```/g, "'''"), "```", "", "</details>", "");
|
|
239
|
+
else if (entry.role === "info") {
|
|
240
|
+
lines.push("", `_${entry.text}_`, "");
|
|
171
241
|
}
|
|
172
242
|
}
|
|
173
|
-
return lines.join("\n")
|
|
243
|
+
return `${lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd()}\n`;
|
|
174
244
|
}
|
|
175
245
|
export function saveExport(session) {
|
|
176
246
|
fs.mkdirSync(EXPORTS_DIR, { recursive: true });
|
|
@@ -204,7 +274,7 @@ export function appendHistory(text) {
|
|
|
204
274
|
return;
|
|
205
275
|
}
|
|
206
276
|
try {
|
|
207
|
-
|
|
277
|
+
ensureHomeDir();
|
|
208
278
|
fs.appendFileSync(HISTORY_PATH, `${JSON.stringify(text)}\n`);
|
|
209
279
|
// De vez en cuando lo recortamos para que no crezca sin fin
|
|
210
280
|
const stat = fs.statSync(HISTORY_PATH);
|
package/dist/skills.js
CHANGED
|
@@ -278,7 +278,8 @@ export function installSkills(source, root, options = {}) {
|
|
|
278
278
|
throw new Error(`No encuentro ${source}: pon la ruta de una carpeta con SKILL.md o la dirección de un repositorio git.`);
|
|
279
279
|
}
|
|
280
280
|
temp = fs.mkdtempSync(path.join(os.tmpdir(), "vipcode-skill-"));
|
|
281
|
-
|
|
281
|
+
// Con «--» un origen que empiece por - no se toma como una opción de git (por ejemplo --upload-pack)
|
|
282
|
+
const args = ["clone", "--depth", "1", ...(git.ref ? [`--branch=${git.ref}`] : []), "--", git.url, temp];
|
|
282
283
|
const clone = spawnSync("git", args, { encoding: "utf8", timeout: 120000, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
|
|
283
284
|
if (clone.error || clone.status !== 0) {
|
|
284
285
|
throw new Error(`No he podido descargar ${git.url}: ${(clone.stderr || clone.error?.message || "").trim().split("\n").pop()}`);
|
package/dist/tools.js
CHANGED
|
@@ -3,6 +3,8 @@ import path from "node:path";
|
|
|
3
3
|
import { spawn, spawnSync } from "node:child_process";
|
|
4
4
|
import fg from "fast-glob";
|
|
5
5
|
import { isImagePath, toDataUrl } from "./images.js";
|
|
6
|
+
import { stripControl } from "./sanitize.js";
|
|
7
|
+
import { protectedDirs } from "./paths.js";
|
|
6
8
|
const IGNORE = ["**/node_modules/**", "**/.git/**", "**/dist/**", "**/build/**", "**/.next/**", "**/vendor/**"];
|
|
7
9
|
const MAX_OUTPUT = 20000;
|
|
8
10
|
export function truncate(text, max = MAX_OUTPUT) {
|
|
@@ -13,7 +15,31 @@ export function truncate(text, max = MAX_OUTPUT) {
|
|
|
13
15
|
return `${text.slice(0, half)}\n\n[… ${text.length - max} caracteres omitidos …]\n\n${text.slice(-half)}`;
|
|
14
16
|
}
|
|
15
17
|
// Convierte una ruta relativa en absoluta y comprueba que no sale del proyecto
|
|
16
|
-
|
|
18
|
+
// Ruta real siguiendo los enlaces simbólicos, aunque el destino (o parte de él) todavía no exista
|
|
19
|
+
function realPathOf(file, depth = 0) {
|
|
20
|
+
try {
|
|
21
|
+
return fs.realpathSync(file);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// no existe (todavía) o es un enlace roto
|
|
25
|
+
}
|
|
26
|
+
if (depth < 40) {
|
|
27
|
+
try {
|
|
28
|
+
if (fs.lstatSync(file).isSymbolicLink()) {
|
|
29
|
+
return realPathOf(path.resolve(path.dirname(file), fs.readlinkSync(file)), depth + 1);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// no existe
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const parent = path.dirname(file);
|
|
37
|
+
if (parent === file || depth >= 40) {
|
|
38
|
+
return file;
|
|
39
|
+
}
|
|
40
|
+
return path.join(realPathOf(parent, depth + 1), path.basename(file));
|
|
41
|
+
}
|
|
42
|
+
export function resolvePath(root, p, options = {}) {
|
|
17
43
|
let input = p;
|
|
18
44
|
if (!input) {
|
|
19
45
|
input = ".";
|
|
@@ -22,8 +48,36 @@ export function resolvePath(root, p) {
|
|
|
22
48
|
if (abs !== root && !abs.startsWith(root + path.sep)) {
|
|
23
49
|
throw new Error(`La ruta ${p} está fuera del proyecto (${root})`);
|
|
24
50
|
}
|
|
51
|
+
// Un enlace simbólico dentro del proyecto tampoco puede llevar fuera (por ejemplo a ~/.ssh o a tus claves)
|
|
52
|
+
const real = realPathOf(abs);
|
|
53
|
+
const realRoot = realPathOf(root);
|
|
54
|
+
if (real !== realRoot && !real.startsWith(realRoot + path.sep)) {
|
|
55
|
+
throw new Error(`La ruta ${p} es un enlace que lleva fuera del proyecto (${real})`);
|
|
56
|
+
}
|
|
57
|
+
// vipcode no puede tocarse a sí mismo: ni sus datos (claves, configuración) ni, para escribir, su instalación
|
|
58
|
+
for (const { dir, read } of protectedDirs()) {
|
|
59
|
+
const realDir = realPathOf(dir);
|
|
60
|
+
if ((read || options.write) && (real === realDir || real.startsWith(realDir + path.sep))) {
|
|
61
|
+
if (read) {
|
|
62
|
+
throw new Error(`La ruta ${p} está en la carpeta de datos de vipcode (${dir}), que guarda tus claves y tu configuración: no se puede leer ni cambiar desde aquí.`);
|
|
63
|
+
}
|
|
64
|
+
throw new Error(`La ruta ${p} es parte de la instalación de vipcode (${dir}): vipcode no puede modificarse a sí mismo.`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
25
67
|
return abs;
|
|
26
68
|
}
|
|
69
|
+
// Patrones para que glob y grep no recorran la carpeta de datos de vipcode si está dentro del proyecto
|
|
70
|
+
function protectedIgnore(root) {
|
|
71
|
+
const realRoot = realPathOf(root);
|
|
72
|
+
const patterns = [];
|
|
73
|
+
for (const { dir, read } of protectedDirs()) {
|
|
74
|
+
const relative = path.relative(realRoot, realPathOf(dir));
|
|
75
|
+
if (read && relative && !relative.startsWith("..") && !path.isAbsolute(relative)) {
|
|
76
|
+
patterns.push(`${relative.split(path.sep).join("/")}/**`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return patterns;
|
|
80
|
+
}
|
|
27
81
|
export function rel(root, abs) {
|
|
28
82
|
const r = path.relative(root, abs);
|
|
29
83
|
if (r === "") {
|
|
@@ -284,7 +338,7 @@ const writeFile = {
|
|
|
284
338
|
permission: "write",
|
|
285
339
|
readOnly: false,
|
|
286
340
|
async run(args, ctx) {
|
|
287
|
-
const file = resolvePath(ctx.root, args.path);
|
|
341
|
+
const file = resolvePath(ctx.root, args.path, { write: true });
|
|
288
342
|
const content = String(args.content ?? "");
|
|
289
343
|
const exists = fs.existsSync(file);
|
|
290
344
|
let previous = null;
|
|
@@ -415,7 +469,7 @@ const editFile = {
|
|
|
415
469
|
permission: "write",
|
|
416
470
|
readOnly: false,
|
|
417
471
|
async run(args, ctx) {
|
|
418
|
-
const file = resolvePath(ctx.root, args.path);
|
|
472
|
+
const file = resolvePath(ctx.root, args.path, { write: true });
|
|
419
473
|
if (!fs.existsSync(file)) {
|
|
420
474
|
return { output: `Error: ${args.path} no existe. Para crear un fichero usa write_file.`, isError: true };
|
|
421
475
|
}
|
|
@@ -491,7 +545,7 @@ const globTool = {
|
|
|
491
545
|
readOnly: true,
|
|
492
546
|
async run(args, ctx) {
|
|
493
547
|
const cwd = resolvePath(ctx.root, args.path);
|
|
494
|
-
const files = await fg(String(args.pattern), { cwd, ignore: IGNORE, dot: true, onlyFiles: true });
|
|
548
|
+
const files = await fg(String(args.pattern), { cwd, ignore: [...IGNORE, ...protectedIgnore(cwd)], dot: true, onlyFiles: true, followSymbolicLinks: false });
|
|
495
549
|
if (files.length === 0) {
|
|
496
550
|
return "Sin resultados.";
|
|
497
551
|
}
|
|
@@ -563,6 +617,9 @@ const grepTool = {
|
|
|
563
617
|
if (args.include) {
|
|
564
618
|
rgArgs.push("-g", String(args.include));
|
|
565
619
|
}
|
|
620
|
+
for (const pattern of protectedIgnore(ctx.root)) {
|
|
621
|
+
rgArgs.push("-g", `!${pattern}`);
|
|
622
|
+
}
|
|
566
623
|
rgArgs.push(".");
|
|
567
624
|
const found = await ripgrep(rgArgs, ctx.root, ctx.signal);
|
|
568
625
|
if (found) {
|
|
@@ -594,7 +651,7 @@ const grepTool = {
|
|
|
594
651
|
if (args.include) {
|
|
595
652
|
include = String(args.include);
|
|
596
653
|
}
|
|
597
|
-
const files = await fg(include, { cwd: ctx.root, ignore: IGNORE, dot: false, onlyFiles: true });
|
|
654
|
+
const files = await fg(include, { cwd: ctx.root, ignore: [...IGNORE, ...protectedIgnore(ctx.root)], dot: false, onlyFiles: true, followSymbolicLinks: false });
|
|
598
655
|
const results = [];
|
|
599
656
|
for (const file of files) {
|
|
600
657
|
const abs = path.join(ctx.root, file);
|
|
@@ -628,6 +685,11 @@ const grepTool = {
|
|
|
628
685
|
return results.join("\n");
|
|
629
686
|
},
|
|
630
687
|
};
|
|
688
|
+
// Un .git/config con core.fsmonitor ejecutaría un programa en cada «git status» (que se aprueba solo)
|
|
689
|
+
function gitSafetyEnv() {
|
|
690
|
+
const count = Number(process.env.GIT_CONFIG_COUNT) || 0;
|
|
691
|
+
return { GIT_CONFIG_COUNT: String(count + 1), [`GIT_CONFIG_KEY_${count}`]: "core.fsmonitor", [`GIT_CONFIG_VALUE_${count}`]: "false" };
|
|
692
|
+
}
|
|
631
693
|
const bashTool = {
|
|
632
694
|
name: "bash",
|
|
633
695
|
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.",
|
|
@@ -665,7 +727,7 @@ const bashTool = {
|
|
|
665
727
|
}
|
|
666
728
|
const child = spawn(shell, shellArgs, {
|
|
667
729
|
cwd: ctx.root,
|
|
668
|
-
env: { ...process.env, VIPCODE: "1", GIT_TERMINAL_PROMPT: "0", GIT_PAGER: "cat", PAGER: "cat" },
|
|
730
|
+
env: { ...process.env, ...gitSafetyEnv(), VIPCODE: "1", GIT_TERMINAL_PROMPT: "0", GIT_PAGER: "cat", PAGER: "cat" },
|
|
669
731
|
stdio: ["ignore", "pipe", "pipe"],
|
|
670
732
|
// Grupo de procesos propio para poder matar también a los hijos (npm test, servidores…)
|
|
671
733
|
detached: process.platform !== "win32",
|
|
@@ -916,7 +978,7 @@ export function formatTodos(todos) {
|
|
|
916
978
|
if (todos.length === 0) {
|
|
917
979
|
return "(lista de tareas vacía)";
|
|
918
980
|
}
|
|
919
|
-
return todos.map((todo) => `${STATUS_ICON[todo.status] || "[ ]"} ${todo.content}`).join("\n");
|
|
981
|
+
return todos.map((todo) => `${STATUS_ICON[todo.status] || "[ ]"} ${stripControl(String(todo.content))}`).join("\n");
|
|
920
982
|
}
|
|
921
983
|
const todoWrite = {
|
|
922
984
|
name: "todowrite",
|
|
@@ -1184,7 +1246,9 @@ export function projectFiles(root) {
|
|
|
1184
1246
|
}
|
|
1185
1247
|
let files = [];
|
|
1186
1248
|
try {
|
|
1187
|
-
files = fg
|
|
1249
|
+
files = fg
|
|
1250
|
+
.sync("**/*", { cwd: root, ignore: [...IGNORE, ...protectedIgnore(root)], dot: true, onlyFiles: false, markDirectories: true, deep: 8, suppressErrors: true, followSymbolicLinks: false })
|
|
1251
|
+
.slice(0, 20000);
|
|
1188
1252
|
}
|
|
1189
1253
|
catch {
|
|
1190
1254
|
files = [];
|