vipcode 0.2.1 → 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/README.en.md +2 -0
- package/README.md +10 -1
- package/dist/agent.js +20 -1
- package/dist/app.js +9 -9
- package/dist/index.js +5 -1
- package/dist/labels.js +20 -0
- package/dist/session.js +121 -51
- package/dist/tui/app.js +39 -22
- package/dist/ui.js +30 -0
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -41,6 +41,8 @@ vipcode skills add anthropics/skills # install skills from GitHub
|
|
|
41
41
|
vipcode update # update vipcode
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
+
Every conversation is saved automatically in `~/.vipcode/sessions/` as `.json` (to resume it) and `.md` (the whole conversation, readable and never truncated by context compaction). When you resume one (`vipcode -c`, `/sessions`), the previous conversation is shown on screen.
|
|
45
|
+
|
|
44
46
|
Inside the UI: Enter sends, Tab switches between the `build` and `plan` agents, Ctrl+P opens the command palette, `@path` attaches files, `!command` runs a shell command, Esc interrupts and `/help` lists everything.
|
|
45
47
|
|
|
46
48
|
## Security
|
package/README.md
CHANGED
|
@@ -134,6 +134,15 @@ Mientras trabaja ves qué modelo está pensando, cada herramienta con su acción
|
|
|
134
134
|
|
|
135
135
|
Antes de editar un fichero, ejecutar un comando o salir a internet, vipcode pregunta enseñando el diff o el comando. Puedes contestar que sí una vez, que sí a todo lo de ese tipo durante la sesión, que sí siempre a un comando concreto (por ejemplo `npm test*`), que no, o que no y explicarle qué hacer.
|
|
136
136
|
|
|
137
|
+
### Sesiones
|
|
138
|
+
|
|
139
|
+
Cada conversación se guarda sola en `~/.vipcode/sessions/`, en dos ficheros:
|
|
140
|
+
|
|
141
|
+
- **`.json`**, para poder seguirla.
|
|
142
|
+
- **`.md`**, con la conversación entera para leerla o compartirla: lo que escribiste, cada respuesta con el modelo que contestó, las herramientas que usó y los cambios que hizo en cada fichero. Se actualiza solo mientras hablas y no pierde nada aunque la conversación se resuma para que quepa en el modelo.
|
|
143
|
+
|
|
144
|
+
Para volver a una conversación: `vipcode -c` (la última de esta carpeta), `/sessions` (eliges de la lista) o `vipcode -s <id>`. Al volver ves en pantalla toda la conversación anterior, y el modelo tiene su contexto para seguir donde lo dejasteis. `/export` guarda además una copia en `~/.vipcode/exports/`.
|
|
145
|
+
|
|
137
146
|
### Comandos
|
|
138
147
|
|
|
139
148
|
| Comando | Qué hace |
|
|
@@ -363,7 +372,7 @@ Los planes gratuitos tienen letra pequeña: los proveedores anónimos (Kilo, OVH
|
|
|
363
372
|
Todo está en `~/.vipcode` (o en `VIPCODE_HOME` si la defines):
|
|
364
373
|
|
|
365
374
|
- `config.json`: tu configuración. `auth.json`: claves (permisos 600). `state.json`: pausas de los proveedores. `notices.json`: avisos que ya has visto. `update.json`: última comprobación de versiones nuevas. `trusted.json`: carpetas en las que confías.
|
|
366
|
-
- `models.json`: modelos descubiertos. `ranking.json`: puntuaciones de LLM Stats descargadas con tu clave. `sessions/`: conversaciones. `history.jsonl`: lo que has escrito.
|
|
375
|
+
- `models.json`: modelos descubiertos. `ranking.json`: puntuaciones de LLM Stats descargadas con tu clave. `sessions/`: conversaciones (un `.json` para seguirlas y un `.md` para leerlas). `history.jsonl`: lo que has escrito.
|
|
367
376
|
- `snapshots/`: instantáneas para `/undo` (un repositorio git aparte por proyecto; no toca tu `.git`).
|
|
368
377
|
- `exports/`: conversaciones exportadas. `agents/`, `commands/` y `skills/`: tus agentes, comandos y skills globales.
|
|
369
378
|
|
package/dist/agent.js
CHANGED
|
@@ -94,6 +94,10 @@ export class Agent {
|
|
|
94
94
|
addNote(text) {
|
|
95
95
|
this.pendingNote += text;
|
|
96
96
|
}
|
|
97
|
+
// Apunta algo en el historial de la sesión (lo que se enseña al volver y se guarda en el .md)
|
|
98
|
+
logEntry(entry) {
|
|
99
|
+
this.session.log.push({ ...entry, time: Date.now() });
|
|
100
|
+
}
|
|
97
101
|
toolContext(agent, signal, depth) {
|
|
98
102
|
const lsp = this.deps.lsp;
|
|
99
103
|
const ctx = {
|
|
@@ -195,6 +199,7 @@ export class Agent {
|
|
|
195
199
|
{ role: "assistant", content: "Entendido, sigo desde aquí." },
|
|
196
200
|
];
|
|
197
201
|
this.session.epoch++;
|
|
202
|
+
this.logEntry({ role: "info", text: "Conversación resumida para ahorrar contexto: el modelo sigue con un resumen, pero aquí sigue todo lo anterior." });
|
|
198
203
|
saveSession(this.session);
|
|
199
204
|
this.events.onInfo?.(`Conversación resumida (~${estimateTokens(this.session.messages)} tokens).`);
|
|
200
205
|
}
|
|
@@ -274,6 +279,13 @@ export class Agent {
|
|
|
274
279
|
this.events.onToolStart?.(event);
|
|
275
280
|
const result = await runTool(tools, name, args, { ...ctx, onProgress: (text) => this.events.onToolProgress?.(event, text) }, agent.readOnly);
|
|
276
281
|
this.events.onToolEnd?.({ ...event, output: result.output, display: result.display, isError: Boolean(result.isError) });
|
|
282
|
+
if (depth === 0) {
|
|
283
|
+
this.logEntry({
|
|
284
|
+
role: "tool",
|
|
285
|
+
text: truncate(result.output, 4000),
|
|
286
|
+
tool: { name, summary: event.summary, isError: Boolean(result.isError), display: result.display ? truncate(result.display, 4000) : undefined },
|
|
287
|
+
});
|
|
288
|
+
}
|
|
277
289
|
if (result.images) {
|
|
278
290
|
images.push(...result.images);
|
|
279
291
|
}
|
|
@@ -332,11 +344,12 @@ export class Agent {
|
|
|
332
344
|
await this.ensureRoom(tools, signal);
|
|
333
345
|
const before = this.deps.snapshots ? await this.deps.snapshots.track() : null;
|
|
334
346
|
beginTurn();
|
|
335
|
-
turn = { messageIndex: this.session.messages.length, before, after: null, epoch: this.session.epoch, userText: input.text };
|
|
347
|
+
turn = { messageIndex: this.session.messages.length, before, after: null, epoch: this.session.epoch, userText: input.display ?? input.text };
|
|
336
348
|
this.session.turns.push(turn);
|
|
337
349
|
this.session.redo = [];
|
|
338
350
|
this.session.agent = this.agent.name;
|
|
339
351
|
this.session.messages.push(this.buildUserMessage(input));
|
|
352
|
+
this.logEntry({ role: "user", text: input.display ?? input.text, images: input.images?.length || undefined });
|
|
340
353
|
saveSession(this.session);
|
|
341
354
|
await this.loop(tools, signal);
|
|
342
355
|
}
|
|
@@ -361,6 +374,9 @@ export class Agent {
|
|
|
361
374
|
const result = await this.step(this.fullMessages(tools), tools, agent, signal, 0);
|
|
362
375
|
const message = result.message;
|
|
363
376
|
this.session.messages.push(message);
|
|
377
|
+
if (typeof message.content === "string" && message.content.trim() !== "") {
|
|
378
|
+
this.logEntry({ role: "assistant", text: message.content, model: result.slot.key });
|
|
379
|
+
}
|
|
364
380
|
saveSession(this.session);
|
|
365
381
|
if (!message.tool_calls || message.tool_calls.length === 0) {
|
|
366
382
|
if (this.events.onAnswer) {
|
|
@@ -387,6 +403,7 @@ export class Agent {
|
|
|
387
403
|
if (this.queue.length > 0) {
|
|
388
404
|
for (const queued of this.queue.splice(0)) {
|
|
389
405
|
this.session.messages.push(this.buildUserMessage({ text: `[Mensaje nuevo del usuario mientras trabajabas] ${queued.text}`, images: queued.images }));
|
|
406
|
+
this.logEntry({ role: "user", text: queued.text, images: queued.images?.length || undefined });
|
|
390
407
|
}
|
|
391
408
|
this.events.onInfo?.("(mensaje entregado al agente)");
|
|
392
409
|
saveSession(this.session);
|
|
@@ -464,6 +481,7 @@ export class Agent {
|
|
|
464
481
|
}
|
|
465
482
|
this.session.redo.push({ turn, messages: removed });
|
|
466
483
|
this.readTimes.clear();
|
|
484
|
+
this.logEntry({ role: "info", text: `Deshecho: «${turn.userText}»${files.length > 0 ? ` (ficheros restaurados: ${files.join(", ")})` : ""}` });
|
|
467
485
|
saveSession(this.session);
|
|
468
486
|
return { files, text: turn.userText, restoredMessages };
|
|
469
487
|
}
|
|
@@ -481,6 +499,7 @@ export class Agent {
|
|
|
481
499
|
}
|
|
482
500
|
this.session.turns.push(entry.turn);
|
|
483
501
|
this.readTimes.clear();
|
|
502
|
+
this.logEntry({ role: "info", text: `Rehecho: «${entry.turn.userText}»` });
|
|
484
503
|
saveSession(this.session);
|
|
485
504
|
return { files };
|
|
486
505
|
}
|
package/dist/app.js
CHANGED
|
@@ -16,7 +16,7 @@ import { SessionGrants, createAsker, permissionsFor } from "./permissions.js";
|
|
|
16
16
|
import { Ranking, loadRankingData, rankingKey, refreshRanking } from "./ranking.js";
|
|
17
17
|
import { Router } from "./router.js";
|
|
18
18
|
import { loadSkills, skillContent } from "./skills.js";
|
|
19
|
-
import { deleteSession, listSessions, newSession, saveExport, saveSession } from "./session.js";
|
|
19
|
+
import { deleteSession, listSessions, newSession, saveExport, saveSession, sessionMarkdownPath } from "./session.js";
|
|
20
20
|
import { Snapshots } from "./snapshot.js";
|
|
21
21
|
import { updateVipcode } from "./update.js";
|
|
22
22
|
import { formatTodos, truncate } from "./tools.js";
|
|
@@ -819,7 +819,7 @@ export class App {
|
|
|
819
819
|
})));
|
|
820
820
|
if (choice) {
|
|
821
821
|
this.loadSession(choice);
|
|
822
|
-
|
|
822
|
+
return { loaded: true };
|
|
823
823
|
}
|
|
824
824
|
return {};
|
|
825
825
|
}
|
|
@@ -832,11 +832,9 @@ export class App {
|
|
|
832
832
|
const chosen = this.lastSessions[Number(arg) - 1];
|
|
833
833
|
if (chosen) {
|
|
834
834
|
this.loadSession(chosen);
|
|
835
|
-
|
|
836
|
-
}
|
|
837
|
-
else {
|
|
838
|
-
ui.error("Número no válido. Usa /sessions primero.");
|
|
835
|
+
return { loaded: true };
|
|
839
836
|
}
|
|
837
|
+
ui.error("Número no válido. Usa /sessions primero.");
|
|
840
838
|
return {};
|
|
841
839
|
}
|
|
842
840
|
case "/delete": {
|
|
@@ -856,7 +854,7 @@ export class App {
|
|
|
856
854
|
ui.info("La conversación está vacía.");
|
|
857
855
|
}
|
|
858
856
|
else {
|
|
859
|
-
ui.info(`Conversación guardada en ${saveExport(this.agent.session)}`);
|
|
857
|
+
ui.info(`Conversación guardada en ${saveExport(this.agent.session)} (además se guarda sola en ${sessionMarkdownPath(this.agent.session)})`);
|
|
860
858
|
}
|
|
861
859
|
return {};
|
|
862
860
|
}
|
|
@@ -935,13 +933,13 @@ export class App {
|
|
|
935
933
|
const custom = this.commands.find((item) => item.name === name);
|
|
936
934
|
if (custom) {
|
|
937
935
|
const expanded = await expandCommand(custom, arg, this.root);
|
|
938
|
-
return { run: { input: { text: expanded }, options: { agent: custom.agent, model: custom.model, subtask: custom.subtask } } };
|
|
936
|
+
return { run: { input: { text: expanded, display: text.trim() }, options: { agent: custom.agent, model: custom.model, subtask: custom.subtask } } };
|
|
939
937
|
}
|
|
940
938
|
// /nombre-de-skill tarea: se le da la skill ya cargada junto con la tarea
|
|
941
939
|
const skill = this.skills.find((item) => item.name === name && item.userInvocable);
|
|
942
940
|
if (skill) {
|
|
943
941
|
const task = arg ? `\n\nTarea: ${arg}` : "";
|
|
944
|
-
return { run: { input: { text: `Usa la skill «${skill.name}» para esto.${task}\n\n${skillContent(skill)}
|
|
942
|
+
return { run: { input: { text: `Usa la skill «${skill.name}» para esto.${task}\n\n${skillContent(skill)}`, display: text.trim() }, options: {} } };
|
|
945
943
|
}
|
|
946
944
|
const agent = this.primaryAgents().find((item) => item.name === name);
|
|
947
945
|
if (agent) {
|
|
@@ -1010,6 +1008,8 @@ export class App {
|
|
|
1010
1008
|
signal?.removeEventListener("abort", stop);
|
|
1011
1009
|
const clean = output.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, "").trimEnd();
|
|
1012
1010
|
this.agent.addNote(`(El usuario ha ejecutado en la terminal \`${command}\`, código de salida ${code ?? "desconocido"}:\n\`\`\`\n${truncate(clean, 8000)}\n\`\`\`)\n\n`);
|
|
1011
|
+
this.agent.logEntry({ role: "tool", text: truncate(clean, 4000), tool: { name: "bash", summary: `(tú) ${command}`, isError: code !== 0 } });
|
|
1012
|
+
saveSession(this.agent.session);
|
|
1013
1013
|
resolve({ output: clean, code });
|
|
1014
1014
|
};
|
|
1015
1015
|
child.on("error", (err) => {
|
package/dist/index.js
CHANGED
|
@@ -15,7 +15,7 @@ import { createSkill, installSkills, loadSkills, removeSkill } from "./skills.js
|
|
|
15
15
|
import { projectTrust, trustProject, untrustProject } from "./trust.js";
|
|
16
16
|
import { updateVipcode } from "./update.js";
|
|
17
17
|
import { completeMention } from "./tools.js";
|
|
18
|
-
import { plainAdapter, plainEvents, plainPermissionPrompt, ui } from "./ui.js";
|
|
18
|
+
import { plainAdapter, plainEvents, plainPermissionPrompt, printLog, ui } from "./ui.js";
|
|
19
19
|
const USAGE = `vipcode ${VERSION} · agente de programación para la terminal con IAs gratuitas
|
|
20
20
|
|
|
21
21
|
Uso:
|
|
@@ -420,6 +420,7 @@ async function plainLoop(app, options, root) {
|
|
|
420
420
|
};
|
|
421
421
|
const adapter = { ...plainAdapter, setInput: (text) => ui.info(`Tu mensaje era: ${text}`) };
|
|
422
422
|
ui.banner(app.agent.agent.name, app.router.slots.length);
|
|
423
|
+
printLog(app.agent.session);
|
|
423
424
|
if (options.args.length > 0) {
|
|
424
425
|
await runTask(options.args.join(" "), {}, loadImages(options.images, root));
|
|
425
426
|
}
|
|
@@ -453,6 +454,9 @@ async function plainLoop(app, options, root) {
|
|
|
453
454
|
if (result.exit) {
|
|
454
455
|
break;
|
|
455
456
|
}
|
|
457
|
+
if (result.loaded) {
|
|
458
|
+
printLog(app.agent.session);
|
|
459
|
+
}
|
|
456
460
|
if (result.run) {
|
|
457
461
|
await runTask(result.run.input.text, result.run.options);
|
|
458
462
|
}
|
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/session.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { toolLabel } from "./labels.js";
|
|
3
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 });
|
package/dist/tui/app.js
CHANGED
|
@@ -8,36 +8,22 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
|
|
8
8
|
import { configNotices } from "../config.js";
|
|
9
9
|
import { discoveryIsStale } from "../discovery.js";
|
|
10
10
|
import { isImagePath, readClipboardImage, toDataUrl } from "../images.js";
|
|
11
|
+
import { toolLabel } from "../labels.js";
|
|
11
12
|
import { renderMarkdown } from "../markdown.js";
|
|
12
13
|
import { VERSION } from "../meta.js";
|
|
13
14
|
import { NoSlotError } from "../router.js";
|
|
14
15
|
import { showControl, stripControl, stripUnsafe } from "../sanitize.js";
|
|
15
|
-
import { appendHistory, loadHistory } from "../session.js";
|
|
16
|
+
import { appendHistory, loadHistory, sessionMarkdownPath } from "../session.js";
|
|
16
17
|
import { suggestFiles } from "../tools.js";
|
|
17
18
|
import { colorPreview } from "../ui.js";
|
|
18
19
|
import { checkForUpdate } from "../update.js";
|
|
19
20
|
import { PermissionDialog, PromptDialog, SelectDialog } from "./dialogs.js";
|
|
20
21
|
import * as E from "./editor.js";
|
|
22
|
+
// Al volver a una sesión se enseñan sus últimos pasos (el .md los tiene todos)
|
|
23
|
+
const REPLAY_MAX = 300;
|
|
21
24
|
// Comandos que se pueden usar mientras el agente trabaja (solo consultan)
|
|
22
25
|
const SAFE_WHILE_BUSY = ["/help", "/usage", "/todos", "/mcp", "/lsp", "/providers", "/skills"];
|
|
23
26
|
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
24
|
-
// Cómo se cuenta cada herramienta en pantalla
|
|
25
|
-
const TOOL_LABEL = {
|
|
26
|
-
read_file: "Lee",
|
|
27
|
-
write_file: "Escribe",
|
|
28
|
-
edit_file: "Edita",
|
|
29
|
-
list_dir: "Lista",
|
|
30
|
-
glob: "Busca ficheros",
|
|
31
|
-
grep: "Busca",
|
|
32
|
-
bash: "Ejecuta",
|
|
33
|
-
webfetch: "Abre",
|
|
34
|
-
websearch: "Busca en internet",
|
|
35
|
-
todowrite: "Tareas",
|
|
36
|
-
todoread: "Tareas",
|
|
37
|
-
diagnostics: "Diagnósticos",
|
|
38
|
-
task: "Subagente",
|
|
39
|
-
skill: "Skill",
|
|
40
|
-
};
|
|
41
27
|
function Spinner(props) {
|
|
42
28
|
const { frame } = useAnimation({ interval: 80 });
|
|
43
29
|
return _jsx(Text, { color: props.color || "cyan", children: SPINNER[frame % SPINNER.length] });
|
|
@@ -166,7 +152,7 @@ function toolDetail(event) {
|
|
|
166
152
|
}
|
|
167
153
|
// El resumen sale de lo que pidió el modelo (comandos, rutas): los caracteres de control se ven, no actúan
|
|
168
154
|
function ToolHeader(props) {
|
|
169
|
-
return (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children:
|
|
155
|
+
return (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: showControl(toolLabel(props.name)) }), props.summary ? _jsxs(Text, { children: [" ", showControl(props.summary)] }) : null] }));
|
|
170
156
|
}
|
|
171
157
|
function ToolView(props) {
|
|
172
158
|
const { event } = props;
|
|
@@ -505,6 +491,9 @@ function Main(props) {
|
|
|
505
491
|
await quit();
|
|
506
492
|
return;
|
|
507
493
|
}
|
|
494
|
+
if (result.loaded) {
|
|
495
|
+
replay(app.agent.session);
|
|
496
|
+
}
|
|
508
497
|
if (result.run) {
|
|
509
498
|
void runAgent({ ...result.run.input, images }, result.run.options);
|
|
510
499
|
}
|
|
@@ -546,6 +535,35 @@ function Main(props) {
|
|
|
546
535
|
}
|
|
547
536
|
refresh();
|
|
548
537
|
};
|
|
538
|
+
// La conversación de una sesión a la que vuelves, tal como la viste
|
|
539
|
+
const replay = (session) => {
|
|
540
|
+
const entries = session.log;
|
|
541
|
+
if (entries.length === 0) {
|
|
542
|
+
push({ kind: "info", text: `Sesión «${session.title}» cargada.` });
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
const shown = entries.slice(-REPLAY_MAX);
|
|
546
|
+
push({ kind: "info", text: `── Conversación anterior: «${session.title}» · ${new Date(session.updated).toLocaleString()} ──` });
|
|
547
|
+
if (entries.length > shown.length) {
|
|
548
|
+
push({ kind: "info", text: `… ${entries.length - shown.length} pasos antes; la conversación entera está en ${sessionMarkdownPath(session)}` });
|
|
549
|
+
}
|
|
550
|
+
shown.forEach((entry, index) => {
|
|
551
|
+
if (entry.role === "user") {
|
|
552
|
+
push({ kind: "user", text: entry.text, images: entry.images || 0, color: agentColor() });
|
|
553
|
+
}
|
|
554
|
+
else if (entry.role === "assistant") {
|
|
555
|
+
push({ kind: "assistant", text: entry.text, color: agentColor() });
|
|
556
|
+
}
|
|
557
|
+
else if (entry.role === "tool" && entry.tool) {
|
|
558
|
+
const event = { id: `historial-${index}`, name: entry.tool.name, summary: entry.tool.summary, depth: 0, agent: "" };
|
|
559
|
+
push({ kind: "tool", event: { ...event, output: entry.text, display: entry.tool.display, isError: entry.tool.isError }, ms: 0 });
|
|
560
|
+
}
|
|
561
|
+
else {
|
|
562
|
+
push({ kind: "info", text: entry.text });
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
push({ kind: "info", text: "── Sigues desde aquí (/new empieza otra conversación) ──" });
|
|
566
|
+
};
|
|
549
567
|
// Paleta de comandos (Ctrl+P): todos los comandos, skills y agentes con su descripción
|
|
550
568
|
const openPalette = async () => {
|
|
551
569
|
const commands = app.commandSuggestions();
|
|
@@ -659,9 +677,8 @@ function Main(props) {
|
|
|
659
677
|
for (const notice of configNotices) {
|
|
660
678
|
push({ kind: "notice", text: notice });
|
|
661
679
|
}
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
push({ kind: "info", text: `Continúas «${session.title}» (${session.turns.length} mensajes tuyos). /new empieza otra.` });
|
|
680
|
+
if (app.agent.session.log.length > 0) {
|
|
681
|
+
replay(app.agent.session);
|
|
665
682
|
}
|
|
666
683
|
if (app.router.slots.length === 0) {
|
|
667
684
|
push({
|
package/dist/ui.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import pc from "picocolors";
|
|
2
|
+
import { toolLabel } from "./labels.js";
|
|
3
|
+
import { renderMarkdown } from "./markdown.js";
|
|
2
4
|
import { suggestPattern } from "./permissions.js";
|
|
3
5
|
import { showControl, stripControl, stripUnsafe } from "./sanitize.js";
|
|
6
|
+
import { sessionMarkdownPath } from "./session.js";
|
|
4
7
|
import { formatTodos } from "./tools.js";
|
|
5
8
|
export const ui = {
|
|
6
9
|
text(chunk) {
|
|
@@ -175,6 +178,33 @@ export function plainPermissionPrompt(getReadline, grants) {
|
|
|
175
178
|
}
|
|
176
179
|
};
|
|
177
180
|
}
|
|
181
|
+
// La conversación de una sesión a la que vuelves, en el modo de texto simple
|
|
182
|
+
export function printLog(session, max = 300) {
|
|
183
|
+
const entries = session.log;
|
|
184
|
+
if (entries.length === 0) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const shown = entries.slice(-max);
|
|
188
|
+
console.log(pc.dim(`── Conversación anterior: «${stripControl(session.title)}» · ${new Date(session.updated).toLocaleString()} ──`));
|
|
189
|
+
if (entries.length > shown.length) {
|
|
190
|
+
console.log(pc.dim(`… ${entries.length - shown.length} pasos antes; la conversación entera está en ${sessionMarkdownPath(session)}`));
|
|
191
|
+
}
|
|
192
|
+
for (const entry of shown) {
|
|
193
|
+
if (entry.role === "user") {
|
|
194
|
+
console.log(`\n${pc.magenta("❯")} ${stripControl(entry.text)}`);
|
|
195
|
+
}
|
|
196
|
+
else if (entry.role === "assistant") {
|
|
197
|
+
console.log(`\n${renderMarkdown(entry.text, process.stdout.columns || 80)}`);
|
|
198
|
+
}
|
|
199
|
+
else if (entry.role === "tool" && entry.tool) {
|
|
200
|
+
ui.tool(toolLabel(entry.tool.name), entry.tool.summary);
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
console.log(pc.dim(stripControl(entry.text)));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
console.log(pc.dim("\n── Sigues desde aquí (/new empieza otra conversación) ──"));
|
|
207
|
+
}
|
|
178
208
|
// Interfaz mínima para los comandos / en el modo de texto
|
|
179
209
|
export const plainAdapter = {
|
|
180
210
|
info: (text) => ui.info(text),
|