vipcode 0.2.1 → 0.2.3

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 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
@@ -118,7 +118,7 @@ vipcode trust # confiar en la configuración que trae esta carpet
118
118
  - **Con npm** (`npm install -g vipcode` o el paquete de la release): mira cuál es la última versión publicada en npm y en GitHub y, si es más nueva que la tuya, la instala.
119
119
  - **Desde el código** (`git clone` + `npm link`): trae lo último del repositorio con `git pull`, instala las dependencias y compila. Si tienes cambios sin guardar en esa carpeta, no toca nada y te avisa.
120
120
 
121
- Después cierra vipcode y vuelve a abrirlo. Además, una vez al día vipcode mira si hay una versión nueva y te avisa al abrirlo; se desactiva con `"checkUpdates": false` en la configuración.
121
+ Después cierra vipcode y vuelve a abrirlo. Además, una vez al día vipcode mira si hay una versión nueva y te avisa al abrirlo con un recuadro verde, y te lo recuerda en la barra de abajo hasta que actualizas; se desactiva con `"checkUpdates": false` en la configuración.
122
122
 
123
123
  ### En la interfaz
124
124
 
@@ -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";
@@ -127,6 +127,10 @@ export class App {
127
127
  yolo = false;
128
128
  local = {};
129
129
  lastSessions = [];
130
+ // Versión nueva de vipcode publicada: la interfaz la recuerda abajo hasta que la instalas con /update
131
+ newVersion = null;
132
+ // /update ya la ha instalado: se usa al cerrar y volver a abrir vipcode
133
+ updateInstalled = false;
130
134
  // La interfaz pone aquí cómo preguntar los permisos
131
135
  promptPermission = async () => ({
132
136
  allow: false,
@@ -206,7 +210,11 @@ export class App {
206
210
  }
207
211
  privacyNotice() {
208
212
  const names = [...new Set(this.router.slots.filter((slot) => slot.anonymous).map((slot) => (slot.provider.label || slot.provider.name).replace(/ \(sin clave\)$/, "")))];
209
- return `Sin clave, vipcode usa proveedores gratuitos que no piden registro (${names.join(", ")}). Pueden guardar lo que les envías y usarlo para mejorar sus modelos: no les mandes contraseñas, claves ni código confidencial. Con /connect añades un proveedor con clave, y con "keyless": false en la configuración dejas de usar los anónimos.`;
213
+ return [
214
+ `Para que puedas empezar ya mismo, vipcode usa IAs gratuitas que no piden registro (${names.join(", ")}).`,
215
+ "Como son gratis, pueden guardar lo que les escribes para mejorar sus modelos, así que mejor no compartas contraseñas, claves ni datos privados.",
216
+ "Cuando quieras, conecta una cuenta gratuita con /connect y tendrás más mensajes y más modelos.",
217
+ ].join("\n");
210
218
  }
211
219
  acceptPrivacyNotice() {
212
220
  let data = {};
@@ -303,7 +311,7 @@ export class App {
303
311
  }
304
312
  anonymousNotice() {
305
313
  const names = [...new Set(this.router.slots.map((slot) => (slot.provider.label || slot.provider.name).replace(/ \(sin clave\)/, "")))];
306
- return `Sin claves: usando modelos gratis anónimos (${names.join(", ")}). Tienen pocas peticiones por hora y pueden guardar lo que envías; con /connect añades claves gratis con más límites.`;
314
+ return `Estás usando IAs gratuitas sin registro (${names.join(", ")}). Consejo: conecta una cuenta gratuita con /connect para tener más mensajes y más modelos (y recuerda no compartir datos privados).`;
307
315
  }
308
316
  // Siguiente agente principal (Tab)
309
317
  cycleAgent(direction = 1) {
@@ -819,7 +827,7 @@ export class App {
819
827
  })));
820
828
  if (choice) {
821
829
  this.loadSession(choice);
822
- ui.info(`Cargada: ${choice.title} (${choice.messages.length} mensajes)`);
830
+ return { loaded: true };
823
831
  }
824
832
  return {};
825
833
  }
@@ -832,11 +840,9 @@ export class App {
832
840
  const chosen = this.lastSessions[Number(arg) - 1];
833
841
  if (chosen) {
834
842
  this.loadSession(chosen);
835
- ui.info(`Cargada: ${chosen.title} (${chosen.messages.length} mensajes)`);
836
- }
837
- else {
838
- ui.error("Número no válido. Usa /sessions primero.");
843
+ return { loaded: true };
839
844
  }
845
+ ui.error("Número no válido. Usa /sessions primero.");
840
846
  return {};
841
847
  }
842
848
  case "/delete": {
@@ -856,7 +862,7 @@ export class App {
856
862
  ui.info("La conversación está vacía.");
857
863
  }
858
864
  else {
859
- ui.info(`Conversación guardada en ${saveExport(this.agent.session)}`);
865
+ ui.info(`Conversación guardada en ${saveExport(this.agent.session)} (además se guarda sola en ${sessionMarkdownPath(this.agent.session)})`);
860
866
  }
861
867
  return {};
862
868
  }
@@ -915,6 +921,8 @@ export class App {
915
921
  }
916
922
  else {
917
923
  ui.info(result.message);
924
+ this.newVersion = null;
925
+ this.updateInstalled ||= result.status === "updated";
918
926
  }
919
927
  return {};
920
928
  }
@@ -935,13 +943,13 @@ export class App {
935
943
  const custom = this.commands.find((item) => item.name === name);
936
944
  if (custom) {
937
945
  const expanded = await expandCommand(custom, arg, this.root);
938
- return { run: { input: { text: expanded }, options: { agent: custom.agent, model: custom.model, subtask: custom.subtask } } };
946
+ return { run: { input: { text: expanded, display: text.trim() }, options: { agent: custom.agent, model: custom.model, subtask: custom.subtask } } };
939
947
  }
940
948
  // /nombre-de-skill tarea: se le da la skill ya cargada junto con la tarea
941
949
  const skill = this.skills.find((item) => item.name === name && item.userInvocable);
942
950
  if (skill) {
943
951
  const task = arg ? `\n\nTarea: ${arg}` : "";
944
- return { run: { input: { text: `Usa la skill «${skill.name}» para esto.${task}\n\n${skillContent(skill)}` }, options: {} } };
952
+ return { run: { input: { text: `Usa la skill «${skill.name}» para esto.${task}\n\n${skillContent(skill)}`, display: text.trim() }, options: {} } };
945
953
  }
946
954
  const agent = this.primaryAgents().find((item) => item.name === name);
947
955
  if (agent) {
@@ -1010,6 +1018,8 @@ export class App {
1010
1018
  signal?.removeEventListener("abort", stop);
1011
1019
  const clean = output.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, "").trimEnd();
1012
1020
  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`);
1021
+ this.agent.logEntry({ role: "tool", text: truncate(clean, 4000), tool: { name: "bash", summary: `(tú) ${command}`, isError: code !== 0 } });
1022
+ saveSession(this.agent.session);
1013
1023
  resolve({ output: clean, code });
1014
1024
  };
1015
1025
  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: Array.isArray(data.messages) ? data.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
- function contentText(content) {
108
- if (typeof content === "string") {
109
- return content;
110
- }
111
- if (Array.isArray(content)) {
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
- // La conversación en Markdown, para guardarla o compartirla
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: ${new Date(session.created).toLocaleString()}`,
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 message of session.messages) {
138
- if (message.role === "system") {
139
- continue;
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 (message.role === "user") {
142
- let text = contentText(message.content);
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 (message.role === "assistant") {
151
- const text = contentText(message.content);
152
- lines.push("## vipcode", "");
153
- if (text.trim() !== "") {
154
- lines.push(text, "");
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
- const calls = message.tool_calls || [];
157
- for (const call of calls) {
158
- let args = call.function?.arguments || "";
159
- if (args.length > 600) {
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 (message.role === "tool") {
166
- let text = contentText(message.content);
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: TOOL_LABEL[props.name] || showControl(props.name) }), props.summary ? _jsxs(Text, { children: [" ", showControl(props.summary)] }) : null] }));
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;
@@ -214,6 +200,8 @@ function ItemView(props) {
214
200
  const shown = lines.slice(-30);
215
201
  return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { children: [_jsxs(Text, { color: "yellow", bold: true, children: ["$", " "] }), _jsx(Text, { bold: true, children: showControl(item.command) })] }), lines.length > shown.length ? _jsxs(Text, { dimColor: true, children: [" \u2026 ", lines.length - shown.length, " l\u00EDneas antes"] }) : null, shown.length > 0 ? _jsx(Text, { children: shown.map((line) => ` ${line}`).join("\n") }) : null, _jsxs(Text, { dimColor: true, children: [" ", item.code === 0 ? "✓" : "✗", " c\u00F3digo ", item.code ?? "?", " \u00B7 el agente ver\u00E1 la salida con tu siguiente mensaje"] })] }));
216
202
  }
203
+ case "update":
204
+ return (_jsxs(Box, { marginY: 1, alignSelf: "flex-start", flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 2, children: [_jsx(Text, { bold: true, color: "green", children: "\u2191 \u00A1Hay una versi\u00F3n nueva de vipcode!" }), _jsxs(Text, { children: [VERSION, " \u2192 ", _jsx(Text, { bold: true, color: "green", children: stripControl(item.version) }), " \u00B7 escribe", " ", _jsx(Text, { bold: true, color: "cyan", children: "/update" }), " ", "para instalarla"] })] }));
217
205
  // Avisos y bloques pueden llevar mensajes de los proveedores o datos de fuera: solo se dejan los colores
218
206
  case "info":
219
207
  return _jsx(Text, { dimColor: true, children: stripUnsafe(item.text) });
@@ -289,11 +277,17 @@ function StatusBar(props) {
289
277
  const usage = app.agent.session.usage;
290
278
  const spent = usage.prompt + usage.completion;
291
279
  const queued = app.agent.queue.length;
292
- let hint = "ctrl+p comandos · /help";
280
+ let hint = _jsx(Text, { dimColor: true, children: "ctrl+p comandos \u00B7 /help" });
293
281
  if (props.busy) {
294
- hint = "esc interrumpe · escribe para añadir instrucciones";
282
+ hint = _jsx(Text, { dimColor: true, children: "esc interrumpe \u00B7 escribe para a\u00F1adir instrucciones" });
295
283
  }
296
- return (_jsxs(Box, { justifyContent: "space-between", paddingX: 1, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: agent?.color || "magenta", children: props.agentName }), _jsxs(Text, { dimColor: true, children: [" ", "\u00B7 ", current || "rotación automática", slot && slot.score !== null ? ` ★${slot.score}` : "", app.router.pinned ? " (fijado)" : ""] }), _jsx(ContextGauge, { used: app.agent.lastTokens, total: context }), _jsxs(Text, { dimColor: true, children: [" ", "\u00B7 ", available, "/", app.router.slots.length, " modelos", spent > 0 ? ` · ${shortTokens(spent)} tokens` : "", queued > 0 ? ` · ${queued} en cola` : ""] }), app.yolo ? _jsx(Text, { color: "red", children: " \u00B7 yolo" }) : null] }), _jsx(Text, { dimColor: true, wrap: "truncate-start", children: hint })] }));
284
+ else if (app.updateInstalled) {
285
+ hint = _jsx(Text, { color: "green", children: "\u2713 vipcode actualizado \u00B7 ci\u00E9rralo y vuelve a abrirlo para usarlo" });
286
+ }
287
+ else if (app.newVersion) {
288
+ hint = (_jsxs(Text, { bold: true, color: "green", children: ["\u2191 vipcode ", stripControl(app.newVersion), " disponible \u00B7 escribe /update"] }));
289
+ }
290
+ return (_jsxs(Box, { justifyContent: "space-between", paddingX: 1, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: agent?.color || "magenta", children: props.agentName }), _jsxs(Text, { dimColor: true, children: [" ", "\u00B7 ", current || "rotación automática", slot && slot.score !== null ? ` ★${slot.score}` : "", app.router.pinned ? " (fijado)" : ""] }), _jsx(ContextGauge, { used: app.agent.lastTokens, total: context }), _jsxs(Text, { dimColor: true, children: [" ", "\u00B7 ", available, "/", app.router.slots.length, " modelos", spent > 0 ? ` · ${shortTokens(spent)} tokens` : "", queued > 0 ? ` · ${queued} en cola` : ""] }), app.yolo ? _jsx(Text, { color: "red", children: " \u00B7 yolo" }) : null] }), _jsx(Text, { wrap: "truncate-start", children: hint })] }));
297
291
  }
298
292
  function Main(props) {
299
293
  const { app } = props;
@@ -505,6 +499,9 @@ function Main(props) {
505
499
  await quit();
506
500
  return;
507
501
  }
502
+ if (result.loaded) {
503
+ replay(app.agent.session);
504
+ }
508
505
  if (result.run) {
509
506
  void runAgent({ ...result.run.input, images }, result.run.options);
510
507
  }
@@ -524,10 +521,10 @@ function Main(props) {
524
521
  };
525
522
  // La primera vez que se van a usar proveedores sin clave, explicamos qué pasa con lo que envías
526
523
  const askPrivacy = async () => {
527
- const choice = await adapter.select(`Antes de empezar: ${app.privacyNotice()}`, [
528
- { label: "Entendido, empezar", value: "ok" },
529
- { label: "Añadir antes un proveedor con clave", description: "como /connect", value: "connect" },
530
- { label: "No usar nunca los que no piden clave", description: 'guarda "keyless": false en tu configuración', value: "off" },
524
+ const choice = await adapter.select(`¡Te damos la bienvenida a vipcode!\n${app.privacyNotice()}`, [
525
+ { label: "Empezar", description: "con las IAs gratuitas sin registro", value: "ok" },
526
+ { label: "Conectar antes una cuenta gratuita", description: "para tener más mensajes y más modelos", value: "connect" },
527
+ { label: "Prefiero usar solo mis cuentas", description: "y no las IAs sin registro", value: "off" },
531
528
  ]);
532
529
  if (!choice) {
533
530
  // Con Esc se vuelve a preguntar la próxima vez
@@ -539,13 +536,42 @@ function Main(props) {
539
536
  }
540
537
  else if (choice === "off") {
541
538
  app.disableKeyless();
542
- push({ kind: "info", text: "No usaré los proveedores sin clave (lo puedes cambiar con \"keyless\" en la configuración)." });
539
+ push({ kind: "info", text: 'Hecho: solo usaré las cuentas que conectes. Si cambias de idea, pon "keyless": true en tu configuración (/config te dice dónde está).' });
543
540
  if (app.router.slots.length === 0) {
544
541
  await app.connect(adapter, "");
545
542
  }
546
543
  }
547
544
  refresh();
548
545
  };
546
+ // La conversación de una sesión a la que vuelves, tal como la viste
547
+ const replay = (session) => {
548
+ const entries = session.log;
549
+ if (entries.length === 0) {
550
+ push({ kind: "info", text: `Sesión «${session.title}» cargada.` });
551
+ return;
552
+ }
553
+ const shown = entries.slice(-REPLAY_MAX);
554
+ push({ kind: "info", text: `── Conversación anterior: «${session.title}» · ${new Date(session.updated).toLocaleString()} ──` });
555
+ if (entries.length > shown.length) {
556
+ push({ kind: "info", text: `… ${entries.length - shown.length} pasos antes; la conversación entera está en ${sessionMarkdownPath(session)}` });
557
+ }
558
+ shown.forEach((entry, index) => {
559
+ if (entry.role === "user") {
560
+ push({ kind: "user", text: entry.text, images: entry.images || 0, color: agentColor() });
561
+ }
562
+ else if (entry.role === "assistant") {
563
+ push({ kind: "assistant", text: entry.text, color: agentColor() });
564
+ }
565
+ else if (entry.role === "tool" && entry.tool) {
566
+ const event = { id: `historial-${index}`, name: entry.tool.name, summary: entry.tool.summary, depth: 0, agent: "" };
567
+ push({ kind: "tool", event: { ...event, output: entry.text, display: entry.tool.display, isError: entry.tool.isError }, ms: 0 });
568
+ }
569
+ else {
570
+ push({ kind: "info", text: entry.text });
571
+ }
572
+ });
573
+ push({ kind: "info", text: "── Sigues desde aquí (/new empieza otra conversación) ──" });
574
+ };
549
575
  // Paleta de comandos (Ctrl+P): todos los comandos, skills y agentes con su descripción
550
576
  const openPalette = async () => {
551
577
  const commands = app.commandSuggestions();
@@ -659,14 +685,13 @@ function Main(props) {
659
685
  for (const notice of configNotices) {
660
686
  push({ kind: "notice", text: notice });
661
687
  }
662
- const session = app.agent.session;
663
- if (session.messages.length > 0) {
664
- push({ kind: "info", text: `Continúas «${session.title}» (${session.turns.length} mensajes tuyos). /new empieza otra.` });
688
+ if (app.agent.session.log.length > 0) {
689
+ replay(app.agent.session);
665
690
  }
666
691
  if (app.router.slots.length === 0) {
667
692
  push({
668
693
  kind: "notice",
669
- text: "Todavía no tienes ningún modelo. Elige un proveedor gratuito y pega su clave (puedes repetirlo con /connect).",
694
+ text: "Para empezar, conecta una IA gratuita: elige una de la lista y sigue los pasos (puedes repetirlo cuando quieras con /connect).",
670
695
  });
671
696
  void app.connect(adapter, "").then(refresh);
672
697
  }
@@ -688,7 +713,8 @@ function Main(props) {
688
713
  if (app.config.checkUpdates !== false) {
689
714
  void checkForUpdate().then((version) => {
690
715
  if (version) {
691
- push({ kind: "notice", text: `Hay una versión nueva de vipcode (${version}, tienes la ${VERSION}). Escribe /update para instalarla.` });
716
+ app.newVersion = version;
717
+ push({ kind: "update", version });
692
718
  }
693
719
  });
694
720
  }
@@ -154,7 +154,9 @@ export function SelectDialog(props) {
154
154
  const windowSize = 12;
155
155
  const start = Math.max(0, Math.min(selected - Math.floor(windowSize / 2), filtered.length - windowSize));
156
156
  const visible = filtered.slice(start, start + windowSize);
157
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: props.title }), _jsx(Text, { dimColor: true, children: filter ? `Filtro: ${filter}` : "Escribe para filtrar · ↑↓ elegir · Enter aceptar · Esc salir" }), visible.length === 0 ? _jsx(Text, { dimColor: true, children: "(nada coincide)" }) : null, visible.map((item, index) => {
157
+ // La primera línea del título va destacada; las siguientes, si las hay, explican la pregunta
158
+ const [heading, ...body] = props.title.split("\n");
159
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: heading }), body.length > 0 ? (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: body.join("\n") }) })) : null, _jsx(Text, { dimColor: true, children: filter ? `Filtro: ${filter}` : "Escribe para filtrar · ↑↓ elegir · Enter aceptar · Esc salir" }), visible.length === 0 ? _jsx(Text, { dimColor: true, children: "(nada coincide)" }) : null, visible.map((item, index) => {
158
160
  const active = start + index === selected;
159
161
  return (_jsxs(Text, { color: active ? "cyan" : undefined, wrap: "truncate-end", children: [active ? "❯ " : " ", stripControl(item.label), item.description ? _jsxs(Text, { dimColor: true, children: [" ", stripControl(item.description)] }) : null] }, `${start + index}-${item.label}`));
160
162
  }), filtered.length > windowSize ? (_jsxs(Text, { dimColor: true, children: [selected + 1, "/", filtered.length] })) : null] }));
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),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vipcode",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Agente de programación para la terminal que usa IAs gratuitas y salta a otro proveedor cuando uno se agota",
5
5
  "keywords": [
6
6
  "ai",