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 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
- ui.info(`Cargada: ${choice.title} (${choice.messages.length} mensajes)`);
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
- ui.info(`Cargada: ${chosen.title} (${chosen.messages.length} mensajes)`);
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)}` }, options: {} } };
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: 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;
@@ -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
- 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.` });
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),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vipcode",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
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",