@danieltmn/tamnora 0.19.0 → 0.20.1

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.
@@ -37,7 +37,7 @@
37
37
  </head>
38
38
  <body>
39
39
  <header class="topbar">
40
- <div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span id="topbar-section" class="topbar-section">chat</span> <span class="ver">v0.19.0</span></div>
40
+ <div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span id="topbar-section" class="topbar-section">chat</span> <span class="ver">v0.20.1</span></div>
41
41
  <nav class="tabs">
42
42
  <button class="tab active" data-tab="chat">chat</button>
43
43
  <button class="tab" data-tab="providers">proveedores</button>
@@ -184,6 +184,11 @@
184
184
  <span>Full access</span>
185
185
  <svg class="ctrl-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5" /></svg>
186
186
  </button>
187
+ <button type="button" class="ctrl-menu-item" data-kind="voice" role="menuitemradio" aria-checked="false" title="conversación por voz: solo notas .md y .json">
188
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 19v3" /><path d="M19 10v2a7 7 0 0 1-14 0v-2" /><path d="M12 15a3 3 0 0 0 3-3V6a3 3 0 0 0-6 0v6a3 3 0 0 0 3 3Z" /></svg>
189
+ <span>Voz (.md / .json)</span>
190
+ <svg class="ctrl-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5" /></svg>
191
+ </button>
187
192
  </div>
188
193
  </div>
189
194
  </div>
@@ -200,6 +205,15 @@
200
205
  </svg>
201
206
  <span class="mic-count" hidden></span>
202
207
  </button>
208
+ <button type="button" id="hands-free-btn" class="add-btn" title="manos libres: conversación por voz" aria-label="manos libres" aria-pressed="false">
209
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
210
+ <path d="M4.9 19.1a10 10 0 0 1 0-14.2" />
211
+ <path d="M7.8 16.2a6 6 0 0 1 0-8.4" />
212
+ <circle cx="12" cy="12" r="2" />
213
+ <path d="M16.2 7.8a6 6 0 0 1 0 8.4" />
214
+ <path d="M19.1 4.9a10 10 0 0 1 0 14.2" />
215
+ </svg>
216
+ </button>
203
217
  <input id="image-input" type="file" accept="image/*" multiple hidden />
204
218
  <textarea id="chat-input" rows="1" placeholder="escribí un mensaje…" title="Enter envía · Shift+Enter salto" spellcheck="false"></textarea>
205
219
  <button type="button" id="model-pill" class="ctrl-pill model-pill" title="proveedor y modelo activos">
@@ -758,8 +772,8 @@
758
772
  <label class="check" title="avisar al terminar">
759
773
  <input id="voz-ann-end" type="checkbox" /> el cierre
760
774
  </label>
761
- <label class="check" title="leer los consejos del asesor">
762
- <input id="voz-ann-advice" type="checkbox" /> los consejos del asesor
775
+ <label class="check" title="leer los consejos y el resumen del asesor">
776
+ <input id="voz-ann-advice" type="checkbox" /> los consejos/resumen del asesor
763
777
  </label>
764
778
  <label class="check" title="leer un resumen breve de lo que hizo el agente">
765
779
  <input id="voz-ann-summary" type="checkbox" /> Explicar resumidamente qué hizo
@@ -1092,6 +1092,9 @@ details.reasoning .body { color: var(--muted); white-space: pre-wrap; margin-top
1092
1092
  .add-btn.recording { color: #2eb872; border-color: currentColor; animation: live-pulse 1s ease-in-out infinite; }
1093
1093
  .add-btn.busy { opacity: 0.55; }
1094
1094
  .add-btn.mic-counting > svg { visibility: hidden; }
1095
+ /* Manos libres: encendido (verde fijo) y escuchando (pulso). */
1096
+ .add-btn.hands-free-active { color: #2eb872; border-color: currentColor; }
1097
+ .add-btn.hands-free-active.listening { animation: live-pulse 1s ease-in-out infinite; }
1095
1098
  .mic-count {
1096
1099
  position: absolute;
1097
1100
  inset: 0;
@@ -2074,5 +2077,27 @@ dialog.modal::backdrop { background: rgba(24, 22, 16, 0.55); backdrop-filter: bl
2074
2077
  .advisor-summary .advisor-glyph,
2075
2078
  .advisor-summary .advisor-badge { color: var(--muted); }
2076
2079
 
2080
+ /* compact marker ---------------------------------------------------------- */
2081
+ .compact-card { margin: 6px 0; }
2082
+ .compact-rule {
2083
+ display: flex;
2084
+ align-items: center;
2085
+ gap: 10px;
2086
+ font: 600 10px var(--mono);
2087
+ letter-spacing: 0.12em;
2088
+ text-transform: uppercase;
2089
+ color: var(--muted);
2090
+ }
2091
+ .compact-rule::before,
2092
+ .compact-rule::after { content: ''; flex: 1; height: 1px; background: var(--border); }
2093
+ .compact-text {
2094
+ margin-top: 10px;
2095
+ padding-left: 12px;
2096
+ border-left: 2px dashed var(--border);
2097
+ word-break: break-word;
2098
+ color: var(--text);
2099
+ line-height: 1.5;
2100
+ }
2101
+
2077
2102
  .brand .ver.ver-action { cursor: pointer; }
2078
2103
  .brand .ver.ver-action:hover { color: var(--accent); }
@@ -2,7 +2,8 @@
2
2
  // el agente hizo (pedido, respuesta, herramientas y diffs), lo interpreta con
3
3
  // una llamada corta al modelo y decide si vale la pena decir algo. NO lee la
4
4
  // respuesta del agente: solo agrega alertas de codigo, explicaciones o consejos.
5
- // Opcionalmente (options.summary) devuelve tambien un resumen breve de lo hecho.
5
+ // Opcionalmente (options.summary) arranca el mensaje con un resumen breve: un
6
+ // SOLO texto continuo (resumen + alerta/consejo), como hablaria Jarvis.
6
7
  //
7
8
  // La salida es JSON estricto para poder decidir el silencio: si el modelo no
8
9
  // tiene nada que aportar, no habla ni deja tarjeta.
@@ -21,7 +22,7 @@ export const DEFAULT_ADVISOR = {
21
22
  model: '',
22
23
  };
23
24
 
24
- const KINDS = new Set(['code', 'explain', 'advice']);
25
+ const KINDS = new Set(['code', 'explain', 'advice', 'summary']);
25
26
  const MAX_PROMPT_CHARS = 8000;
26
27
  const MAX_DIFF_CHARS = 2500;
27
28
 
@@ -118,23 +119,24 @@ export function buildAdvisorPrompt({ userInput = '', answer = '', toolSummary =
118
119
  'Recibis lo que el agente hizo en el ultimo turno: el pedido del usuario, su respuesta\n' +
119
120
  'final, las herramientas que uso y los diffs de los archivos que toco.\n\n' +
120
121
  (wantsAdvice
121
- ? 'Tu trabajo NO es resumir ni repetir la respuesta del agente: el usuario ya la esta\n' +
122
- 'viendo. Solo aportas algo que el agente no dijo. Enfocate en:\n' +
122
+ ? 'Tu trabajo NO es repetir la respuesta del agente: el usuario ya la esta viendo.\n' +
123
+ 'Solo aportas algo que el agente no dijo. Enfocate en:\n' +
123
124
  `${focusLines(options)}\n` +
124
125
  'Si no hay nada que valga la pena decir, pon "speak" en false.\n\n'
125
126
  : '') +
126
127
  (wantsSummary
127
- ? 'Ademas el usuario quiere un resumen breve de lo que hizo el agente: en una o dos\n' +
128
- 'frases, que se pidio y que se hizo, sin copiar la respuesta palabra por palabra.\n' +
129
- 'Inclui siempre el campo "summary", aunque no tengas un consejo ("speak" puede ser false).\n\n'
128
+ ? 'El usuario quiere UN SOLO mensaje continuo: arrancá con un resumen muy breve de\n' +
129
+ 'lo que se hizo (una o dos frases, que se pidio y que se hizo, sin copiar la respuesta)\n' +
130
+ 'y, si tenes un consejo o una alerta, continua con eso en el mismo texto.\n' +
131
+ 'Si no hay nada que alertar, limita el mensaje al resumen y usa "kind": "summary".\n\n'
130
132
  : '') +
131
133
  'Hablale al usuario en segunda persona, breve (' +
132
134
  `${maxWords} palabras como maximo), en espanol, sin markdown ni listas. Si no hay nada\n` +
133
135
  'que valga la pena decir, no hables.\n' +
134
136
  (options.notes ? `\nInstrucciones del usuario: ${options.notes}\n` : '') +
135
- '\nResponde SOLO con JSON: {"speak": true|false, "kind": "code|explain|advice", "text": "..."' +
136
- (wantsSummary ? ', "summary": "..."' : '') +
137
- '}';
137
+ `\nResponde SOLO con JSON: {"speak": true|false, "kind": "${
138
+ wantsSummary ? 'code|explain|advice|summary' : 'code|explain|advice'
139
+ }", "text": "..."}`;
138
140
 
139
141
  const parts = [`PEDIDO DEL USUARIO:\n${userInput || '(sin texto)'}`];
140
142
  parts.push(`RESPUESTA FINAL DEL AGENTE:\n${answer || '(sin respuesta)'}`);
@@ -188,9 +190,8 @@ export async function consultAdvisor({
188
190
  if (error || !value || typeof value !== 'object') return null;
189
191
  const kind = KINDS.has(value.kind) ? value.kind : 'advice';
190
192
  const text = truncateWords(value.text, maxWords);
191
- const summary = options.summary === true ? truncateWords(value.summary, maxWords) : '';
192
- if (value.speak !== true || !text) return { speak: false, kind, text: '', summary };
193
- return { speak: true, kind, text, summary };
193
+ if (value.speak !== true || !text) return { speak: false, kind, text: '' };
194
+ return { speak: true, kind, text };
194
195
  } catch {
195
196
  return null;
196
197
  }
@@ -48,7 +48,7 @@ async function summarize(ctx, session, messages, instruction, previous) {
48
48
 
49
49
  /** Resumen no destructivo de toda la sesión (no toca el historial). */
50
50
  export async function summarizeSession(ctx, session, instruction) {
51
- const messages = ctx.store.getMessages(session.id);
51
+ const messages = liveHistory(ctx.store.getMessages(session.id));
52
52
  if (!messages.length) return { error: 'La sesión no tiene mensajes para resumir.', status: 400 };
53
53
  const resolved = providerFor(ctx, session);
54
54
  if (resolved.error) return resolved;
@@ -59,6 +59,31 @@ export async function summarizeSession(ctx, session, instruction) {
59
59
  return { summary };
60
60
  }
61
61
 
62
+ /**
63
+ * Compactación manual (/compact): resume el historial vigente y manda al modelo
64
+ * `meta.summary` + lo posterior al corte (`meta.summaryCursor`).
65
+ *
66
+ * A diferencia de la auto-compactación, deja una **marca visible** (un mensaje
67
+ * `name:'compact'`) en el punto del corte, para que la consola siga mostrando el
68
+ * historial completo en pantalla sin perderlo. Ese mensaje no entra al contexto
69
+ * del modelo (`liveHistory` lo excluye; el resumen ya viaja como system).
70
+ */
71
+ export async function compactSession(ctx, session) {
72
+ const messages = ctx.store.getMessages(session.id);
73
+ const live = liveHistory(messages, session.meta?.summaryCursor ?? null);
74
+ if (!live.length) return { error: 'La sesión no tiene mensajes para compactar.', status: 400 };
75
+ const resolved = providerFor(ctx, session);
76
+ if (resolved.error) return resolved;
77
+ ctx.hooks?.dispatch({ type: 'session.compacting', sessionId: session.id, messages: live.length });
78
+ const summary = await summarize(ctx, session, live, COMPACT_INSTRUCTION, session.meta?.summary);
79
+ if (!summary) return { error: 'El modelo no devolvió un resumen.', status: 502 };
80
+ const cursor = live.at(-1)?.id ?? null;
81
+ ctx.store.addMessage(session.id, { role: 'assistant', name: 'compact', mode: 'compact', content: summary });
82
+ ctx.store.touchSession(session.id, { meta: { summary, summaryCursor: cursor } });
83
+ ctx.hooks?.dispatch({ type: 'session.compacted', sessionId: session.id, summary });
84
+ return { summary, cursor };
85
+ }
86
+
62
87
  /**
63
88
  * Auto-compactación: si el historial vigente supera el umbral del contexto,
64
89
  * resume lo viejo y lo reemplaza por `meta.summary` + `meta.summaryCursor`.
@@ -57,6 +57,7 @@ async function runAgentSchedule(ctx, schedule) {
57
57
  root: cwd,
58
58
  enabled: mode.tools ?? undefined,
59
59
  permissions: ctx.config.permissions,
60
+ writeExtensions: mode.writeExtensions,
60
61
  yes: true,
61
62
  mcp,
62
63
  mcpMode: ctx.effectiveMcpMode(),
@@ -11,6 +11,7 @@ export function sessionView(session) {
11
11
  title: session.title,
12
12
  provider: session.provider,
13
13
  model: session.model,
14
+ mode: session.meta?.mode ?? null,
14
15
  cwd: session.cwd,
15
16
  goal: session.meta?.goal ?? null,
16
17
  hasSummary: Boolean(session.meta?.summary),
@@ -69,7 +69,17 @@ export function createSubagentTool(ctx, options) {
69
69
  Array.isArray(args.tools) && args.tools.length
70
70
  ? args.tools.filter((tool) => !exclude.includes(tool))
71
71
  : mode.tools ?? undefined;
72
- const local = createTools({ root: cwd, enabled, permissions, yes: false, mcp, mcpMode, mcpPinned, signal });
72
+ const local = createTools({
73
+ root: cwd,
74
+ enabled,
75
+ permissions,
76
+ writeExtensions: mode.writeExtensions,
77
+ yes: false,
78
+ mcp,
79
+ mcpMode,
80
+ mcpPinned,
81
+ signal,
82
+ });
73
83
  const basePrompt = await ctx.systemPromptFor(cwd);
74
84
  const messages = [
75
85
  { role: 'system', content: `${basePrompt}\n\n${SUBAGENT_PROMPT}\n\nTarea: ${description}` },
@@ -140,23 +140,30 @@ export function registerChat(route, ctx) {
140
140
  );
141
141
  if (!input && !images.length) return sendError(res, 400, 'Falta "input".');
142
142
 
143
- const providerId = body.provider || ctx.effectiveProviderId();
144
- const model = body.model || ctx.effectiveModel();
145
- if (!model) return sendError(res, 400, 'Falta "model" (en el cuerpo, en Ajustes o en tamnora.json).');
146
-
147
143
  let sessionId = body.sessionId;
148
144
  let history = [];
149
145
  let sessionCwd;
146
+ let session = null;
150
147
  if (sessionId) {
151
- const existing = ctx.store.getSession(sessionId);
152
- if (!existing) return sendError(res, 404, 'Sesión no encontrada.');
148
+ session = ctx.store.getSession(sessionId);
149
+ if (!session) return sendError(res, 404, 'Sesión no encontrada.');
153
150
  history = ctx.store.getMessages(sessionId);
154
151
  try {
155
- sessionCwd = resolveExistingDir(existing.cwd || ctx.cwd);
152
+ sessionCwd = resolveExistingDir(session.cwd || ctx.cwd);
156
153
  } catch {
157
154
  sessionCwd = ctx.cwd;
158
155
  }
159
- } else {
156
+ }
157
+
158
+ // Cada sesion tiene su propio "tipo de agente": el proveedor/modelo/modo
159
+ // guardados en la sesion mandan sobre el default global. El cuerpo solo los
160
+ // pisa si vienen explicitos.
161
+ const providerId = body.provider || session?.provider || ctx.effectiveProviderId();
162
+ const model = body.model || session?.model || ctx.effectiveModel();
163
+ if (!model) return sendError(res, 400, 'Falta "model" (en el cuerpo, en Ajustes o en tamnora.json).');
164
+ const mode = resolveMode(body.mode ?? session?.meta?.mode ?? ctx.store.getSetting('activeMode'));
165
+
166
+ if (!sessionId) {
160
167
  try {
161
168
  sessionCwd = workspaceForRequest({ req, raw: body.cwd, fallback: ctx.cwd });
162
169
  } catch (error) {
@@ -167,6 +174,7 @@ export function registerChat(route, ctx) {
167
174
  provider: providerId,
168
175
  model,
169
176
  cwd: sessionCwd,
177
+ meta: { mode: mode.id },
170
178
  });
171
179
  }
172
180
 
@@ -183,7 +191,6 @@ export function registerChat(route, ctx) {
183
191
  await ctx.runs.waitForIdle(sessionId);
184
192
  }
185
193
 
186
- const mode = resolveMode(body.mode ?? ctx.store.getSetting('activeMode'));
187
194
  const sse = openSse(req, res);
188
195
  ctx.runs.start(sessionId);
189
196
  const unsubscribe = ctx.runs.subscribe(sessionId, (event) => sse.event(event.type, event.data, event.id));
@@ -236,6 +243,7 @@ export function registerChat(route, ctx) {
236
243
  root: sessionCwd,
237
244
  enabled,
238
245
  permissions: ctx.config.permissions,
246
+ writeExtensions: mode.writeExtensions,
239
247
  yes: autoApprove,
240
248
  onAsk,
241
249
  onQuestion,
@@ -374,7 +382,7 @@ export function registerChat(route, ctx) {
374
382
  const current = ctx.store.getSession(sessionId);
375
383
  const title =
376
384
  current && (!current.title || current.title === 'Nueva sesión') ? input.slice(0, 60) : undefined;
377
- ctx.store.touchSession(sessionId, { provider: providerId, model, title });
385
+ ctx.store.touchSession(sessionId, { provider: providerId, model, title, meta: { mode: mode.id } });
378
386
  const toolCalls = newMessages.filter((message) => message.role === 'tool').length;
379
387
  const durationMs = Date.now() - startedAt;
380
388
  if (result.stopReason === 'aborted')
@@ -421,6 +429,8 @@ export function registerChat(route, ctx) {
421
429
  messages: newMessages,
422
430
  options: advisorOptions,
423
431
  });
432
+ // Un solo mensaje (estilo Jarvis): el asesor arranca con el resumen y
433
+ // sigue con la alerta/consejo en el mismo texto. Una sola locucion.
424
434
  if (advice?.speak) {
425
435
  ctx.store.addMessage(sessionId, {
426
436
  role: 'assistant',
@@ -431,22 +441,6 @@ export function registerChat(route, ctx) {
431
441
  ctx.runs.emit(sessionId, 'advice', { text: advice.text, kind: advice.kind, say: advice.text });
432
442
  ctx.hooks?.dispatch({ type: 'advice', sessionId, text: advice.text, kind: advice.kind });
433
443
  }
434
- // El resumen de "que hizo" va como una tarjeta/aviso aparte, para que
435
- // la voz pueda leerlo siempre (independiente del consejo).
436
- if (advice?.summary) {
437
- ctx.store.addMessage(sessionId, {
438
- role: 'assistant',
439
- name: 'advisor',
440
- content: advice.summary,
441
- mode: 'summary',
442
- });
443
- ctx.runs.emit(sessionId, 'advice', {
444
- text: advice.summary,
445
- kind: 'summary',
446
- say: advice.summary,
447
- });
448
- ctx.hooks?.dispatch({ type: 'advice', sessionId, text: advice.summary, kind: 'summary' });
449
- }
450
444
  }
451
445
  } catch (error) {
452
446
  partials.flush();
@@ -1,7 +1,8 @@
1
1
  import { readJson, sendJson, sendError } from '../lib/http.js';
2
+ import { resolveMode } from '../lib/core.js';
2
3
  import { workspaceForRequest } from '../lib/workspace.js';
3
4
  import { sessionView, sessionState, awaitingBySession } from '../lib/sessionView.js';
4
- import { summarizeSession, COMPACT_INSTRUCTION, SUMMARY_INSTRUCTION } from '../lib/compact.js';
5
+ import { summarizeSession, compactSession, SUMMARY_INSTRUCTION } from '../lib/compact.js';
5
6
  import { rewindSession, checkpointTurns } from '../lib/checkpoints.js';
6
7
 
7
8
  export function registerSessions(route, ctx) {
@@ -45,13 +46,15 @@ export function registerSessions(route, ctx) {
45
46
  } catch (error) {
46
47
  return sendError(res, 400, error.message);
47
48
  }
48
- const meta = body.goal ? { goal: String(body.goal) } : undefined;
49
+ const meta = {};
50
+ if (body.goal) meta.goal = String(body.goal);
51
+ if (body.mode) meta.mode = resolveMode(String(body.mode)).id;
49
52
  const id = ctx.store.createSession({
50
53
  title: String(body.title || 'Nueva sesión').slice(0, 80),
51
- provider: body.provider || ctx.config.provider,
52
- model: body.model || ctx.config.model,
54
+ provider: body.provider || ctx.effectiveProviderId(),
55
+ model: body.model || ctx.effectiveModel(),
53
56
  cwd,
54
- meta,
57
+ meta: Object.keys(meta).length ? meta : undefined,
55
58
  });
56
59
  sendJson(res, 201, { session: sessionView(ctx.store.getSession(id)) });
57
60
  });
@@ -95,7 +98,14 @@ export function registerSessions(route, ctx) {
95
98
  const body = await readJson(req);
96
99
  const patch = {};
97
100
  if ('title' in body) patch.title = String(body.title || '').slice(0, 80) || session.title;
98
- if ('goal' in body) patch.meta = { goal: body.goal ? String(body.goal) : undefined };
101
+ // El "tipo de agente" de la sesion: cada sesion puede correr con su propio
102
+ // proveedor/modelo/modo sin tocar el default global.
103
+ if (body.provider) patch.provider = String(body.provider);
104
+ if (body.model) patch.model = String(body.model);
105
+ const meta = {};
106
+ if ('goal' in body) meta.goal = body.goal ? String(body.goal) : undefined;
107
+ if ('mode' in body) meta.mode = resolveMode(String(body.mode)).id;
108
+ if (Object.keys(meta).length) patch.meta = meta;
99
109
  ctx.store.touchSession(params.id, patch);
100
110
  sendJson(res, 200, { session: sessionView(ctx.store.getSession(params.id)) });
101
111
  });
@@ -136,13 +146,12 @@ export function registerSessions(route, ctx) {
136
146
  sendJson(res, 200, { ok: true, ...result });
137
147
  });
138
148
 
139
- route('POST', '/api/sessions/:id/compact', async ({ res, params }) => { const session = ctx.store.getSession(params.id);
149
+ route('POST', '/api/sessions/:id/compact', async ({ res, params }) => {
150
+ const session = ctx.store.getSession(params.id);
140
151
  if (!session) return sendError(res, 404, 'Sesión no encontrada.');
141
- const out = await summarizeSession(ctx, session, COMPACT_INSTRUCTION);
152
+ const out = await compactSession(ctx, session);
142
153
  if (out.error) return sendError(res, out.status, out.error);
143
- ctx.store.clearMessages(params.id);
144
- ctx.store.touchSession(params.id, { meta: { summary: out.summary, summaryCursor: null } });
145
- sendJson(res, 200, { summary: out.summary, messages: 0 });
154
+ sendJson(res, 200, { summary: out.summary, cursor: out.cursor });
146
155
  });
147
156
 
148
157
  route('POST', '/api/sessions/:id/summary', async ({ res, params }) => {
@@ -72,15 +72,17 @@ test('buildAdvisorPrompt respeta los toggles y las notas', () => {
72
72
  assert.match(user, /src\/a\.js/);
73
73
  });
74
74
 
75
- test('buildAdvisorPrompt pide el resumen cuando options.summary', () => {
75
+ test('buildAdvisorPrompt pide un solo mensaje con el resumen cuando options.summary', () => {
76
76
  const { system } = buildAdvisorPrompt({
77
77
  userInput: 'hola',
78
78
  answer: 'listo',
79
79
  options: { summary: true, code: false, explain: false, advice: false, maxWords: 20 },
80
80
  });
81
- assert.match(system, /resumen breve/);
82
- assert.match(system, /"summary"/);
83
- assert.doesNotMatch(system, /Tu trabajo NO es resumir/);
81
+ assert.match(system, /UN SOLO mensaje/);
82
+ assert.match(system, /resumen muy breve/);
83
+ assert.match(system, /code\|explain\|advice\|summary/);
84
+ assert.doesNotMatch(system, /"summary":/);
85
+ assert.doesNotMatch(system, /Tu trabajo NO es repetir/);
84
86
  });
85
87
 
86
88
  const fakeProvider = (content) => ({ streamChat: async () => ({ content }) });
@@ -98,24 +100,26 @@ test('consultAdvisor interpreta speak:true', async () => {
98
100
  assert.match(advice.text, /token/);
99
101
  });
100
102
 
101
- test('consultAdvisor devuelve el resumen aunque no hable', async () => {
103
+ test('consultAdvisor devuelve un resumen solo como kind summary', async () => {
102
104
  const advice = await consultAdvisor({
103
- provider: fakeProvider('{"speak":false,"text":"","summary":"Revisaste el token y lo pasaste a env."}'),
105
+ provider: fakeProvider('{"speak":true,"kind":"summary","text":"Revisaste el token y lo pasaste a env."}'),
104
106
  config: {},
105
107
  model: 'x',
106
108
  messages: editMessages(),
107
109
  options: { summary: true, code: false, explain: false, advice: false, maxWords: 40 },
108
110
  });
109
- assert.equal(advice.speak, false);
110
- assert.match(advice.summary, /Revisaste el token/);
111
- const ignored = await consultAdvisor({
112
- provider: fakeProvider('{"speak":false,"text":"","summary":"algo"}'),
111
+ assert.equal(advice.speak, true);
112
+ assert.equal(advice.kind, 'summary');
113
+ assert.match(advice.text, /Revisaste el token/);
114
+ const off = await consultAdvisor({
115
+ provider: fakeProvider('{"speak":false,"text":"algo"}'),
113
116
  config: {},
114
117
  model: 'x',
115
118
  messages: [],
116
119
  options: { summary: false, code: true, explain: true, advice: true },
117
120
  });
118
- assert.equal(ignored.summary, '');
121
+ assert.equal(off.speak, false);
122
+ assert.equal(off.text, '');
119
123
  });
120
124
 
121
125
  test('consultAdvisor respeta speak:false, JSON invalido y errores', async () => {
@@ -208,7 +212,7 @@ test('el asesor emite el evento advice al cerrar el turno', async () => {
208
212
  return { content: text, reasoning: '', toolCalls: [] };
209
213
  }
210
214
  return {
211
- content: '{"speak":true,"kind":"code","text":"Cuidado con el token.","summary":"Revisaste la sesión."}',
215
+ content: '{"speak":true,"kind":"code","text":"Revisaste la sesión. Cuidado con el token."}',
212
216
  reasoning: '',
213
217
  toolCalls: [],
214
218
  };
@@ -237,8 +241,8 @@ test('el asesor emite el evento advice al cerrar el turno', async () => {
237
241
  assert.equal(calls, 2);
238
242
  assert.match(body, /event: advice/);
239
243
  assert.match(body, /Cuidado con el token/);
240
- assert.match(body, /"kind":"summary"/);
241
244
  assert.match(body, /Revisaste la sesión/);
245
+ assert.equal((body.match(/event: advice/g) ?? []).length, 1);
242
246
  } finally {
243
247
  await instance.close();
244
248
  }