@danieltmn/tamnora 0.15.0 → 0.16.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,19 @@ Todos los cambios relevantes de este proyecto se documentan en este archivo.
5
5
  El formato sigue [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/)
6
6
  y el proyecto usa [Versionado Semantico](https://semver.org/lang/es/).
7
7
 
8
+ ## [0.16.1] - 2026-10-10
9
+
10
+ ### Cambios
11
+
12
+ - fix(consola): boton continuar con estilo propio en vez de chip
13
+
14
+ ## [0.16.0] - 2026-10-10
15
+
16
+ ### Cambios
17
+
18
+ - docs: memoria, tareas, hooks, plugins y asesor
19
+ - feat(server): asesor y voz con resumen breve de lo hecho
20
+
8
21
  ## [0.15.0] - 2026-10-10
9
22
 
10
23
  ### Agregado
package/README.md CHANGED
@@ -18,6 +18,13 @@ con TUI, consola web, tools locales, MCP y evaluacion de modelos.
18
18
  o da un consejo (con tarjeta en el chat y voz opcional). No lee la respuesta.
19
19
  - **Actualizar desde la consola**: la consola avisa cuando hay una version nueva
20
20
  y la instala con un click (el server se reinicia solo).
21
+ - **Memoria y reglas**: memoria durable por proyecto (`memory`) y reglas por path
22
+ que se inyectan solo cuando el agente toca el archivo.
23
+ - **Automatizacion**: tareas programadas (`every`, `daily`, cron) que corren solas
24
+ y **deshacer** un turno (restaura los archivos a como estaban).
25
+ - **Contexto y costo**: skills on-demand, auto-compactacion, `max_tokens` por ronda
26
+ y prompt caching.
27
+ - **Plataforma**: hooks y plugins locales (`.tamnora/hooks`, `.tamnora/plugins`).
21
28
 
22
29
  ## Instalacion
23
30
 
@@ -124,6 +131,42 @@ menu "mas" (los tres puntitos). En el chat:
124
131
  `/resumen`, `/audioresumen`, `/compact`, `/clear`, `/cwd`, `/kill`, `/help`).
125
132
  - **Capturas**: el tool `screenshot` muestra la imagen inline en su tarjeta.
126
133
 
134
+ ### Memoria, reglas y automatizacion
135
+
136
+ - **Memoria durable** (`memory`): por proyecto en `<cwd>/.tamnora/memory/`
137
+ (`MEMORY.md` índice + un archivo por tema). El agente la mantiene con la tool
138
+ `memory` (`read`/`write`/`search`/`list`; `write` pide permiso) y se inyecta un
139
+ resumen en el system prompt de cada corrida. Se edita en Ajustes → **Memoria**.
140
+ - **Reglas por path** (`.tamnora/rules/*.md`): archivos con frontmatter `paths:`
141
+ (globs). Las que no tienen `paths` son estáticas (van siempre al prompt); las
142
+ demás se inyectan en runtime cuando una tool toca un archivo que matchea, sin
143
+ ensuciar el historial.
144
+ - **Tareas programadas** (Ajustes → **Tareas**): `every 30 m`, `hourly`,
145
+ `daily 09:00` o cron (`0 6 * * 1`). Cada tarea es `agent` (un turno headless que
146
+ reusa el historial y avisa por los canales) o `shell` (un comando permitido). El
147
+ planificador corre en el server.
148
+ - **Deshacer**: antes de cada `write`/`edit` se guarda la versión previa del
149
+ archivo; con **deshacer cambios** (menú `⋮` de cada respuesta) se restauran los
150
+ archivos y se borra el turno del historial.
151
+
152
+ ### Contexto y costo
153
+
154
+ - **Skills on-demand**: en vez de inyectar todas las skills, el prompt lleva un
155
+ índice y el modelo carga el cuerpo con la tool `skill({ name })` (menos tokens
156
+ por turno). `skills.mode: lazy` (default) o `eager`.
157
+ - **Auto-compactación**: si el historial no entra en el contexto del modelo, el
158
+ server resume lo viejo y sigue con el resumen + lo reciente, sin borrar nada.
159
+ - **`max_tokens` por ronda** (`maxOutputTokens`) y **prompt caching** opt-in
160
+ (`promptCaching`, `cache_control` en Anthropic y `prompt_cache_key` en OpenAI).
161
+
162
+ ### Hooks y plugins
163
+
164
+ - **Hooks** (`.tamnora/hooks/*.mjs` o `hooks[]`): observan el ciclo de vida
165
+ (`run.start/end`, `tool.before/after`, `session.compacting/compacted`, `advice`).
166
+ Corren aislados y con los datos redactados; son opt-in.
167
+ - **Plugins** (`.tamnora/plugins/*.mjs` o `plugins[]`): agregan **tools propias**
168
+ al agente (expuestas como `plugin__<plugin>__<tool>`, con permisos).
169
+
127
170
  ### Preview
128
171
 
129
172
  La consola puede levantar un dev server (deteccion de scripts `dev`/`start`,
@@ -210,8 +253,9 @@ entorno completo, `"inheritEnv": true`.
210
253
 
211
254
  Tools locales confinadas al workspace (`read`, `list`, `glob`, `grep`, `write`,
212
255
  `edit`, `bash`, `fetch_url`), de navegador (`screenshot`, `console_errors`,
213
- `preview_url`), de voz (`speak`) y de Git (`git_status`, `git_diff`, `git_log`,
214
- `git_commit`), con permisos `allow`/`ask`/`deny` por tool o por patron.
256
+ `preview_url`), de voz (`speak`), de Git (`git_status`, `git_diff`, `git_log`,
257
+ `git_commit`) y de conocimiento (`memory`, `skill`), con permisos
258
+ `allow`/`ask`/`deny` por tool o por patron.
215
259
 
216
260
  `fetch_url` lee URLs (HTML a texto) y avisa cuando un sitio exige JavaScript o
217
261
  esta detras de Cloudflare; para esos casos las tools `screenshot` / `console_errors`
@@ -1537,6 +1537,7 @@ function userImages(content) {
1537
1537
  function advisorGlyph(kind) {
1538
1538
  if (kind === 'code') return '⚠';
1539
1539
  if (kind === 'explain') return 'ℹ';
1540
+ if (kind === 'summary') return '✎';
1540
1541
  return '✦';
1541
1542
  }
1542
1543
 
@@ -2091,7 +2092,7 @@ function renderCompletion(message, data) {
2091
2092
 
2092
2093
  /** Retoma un turno cortado por límite/bucle sin tener que escribir "continúa". */
2093
2094
  function addContinueButton(turn, data) {
2094
- const btn = el('button', 'chip', data.wrappedUp ? '↻ Continuar el trabajo' : '↻ Continuar');
2095
+ const btn = el('button', 'continue-btn', data.wrappedUp ? '↻ Continuar el trabajo' : '↻ Continuar');
2095
2096
  btn.type = 'button';
2096
2097
  btn.addEventListener('click', () => {
2097
2098
  if (state.panes.get(turn.sessionKey)?.working) return;
@@ -3746,12 +3747,14 @@ const vozAnnStartEl = $('#voz-ann-start');
3746
3747
  const vozAnnToolsEl = $('#voz-ann-tools');
3747
3748
  const vozAnnEndEl = $('#voz-ann-end');
3748
3749
  const vozAnnAdviceEl = $('#voz-ann-advice');
3750
+ const vozAnnSummaryEl = $('#voz-ann-summary');
3749
3751
  const vozAnnAnswerEl = $('#voz-ann-answer');
3750
3752
  const vozFfmpegEl = $('#voz-ffmpeg');
3751
3753
  const asesorEnabledEl = $('#asesor-enabled');
3752
3754
  const asesorCodeEl = $('#asesor-code');
3753
3755
  const asesorExplainEl = $('#asesor-explain');
3754
3756
  const asesorAdviceEl = $('#asesor-advice');
3757
+ const asesorSummaryEl = $('#asesor-summary');
3755
3758
  const asesorWordsEl = $('#asesor-words');
3756
3759
  const asesorNotesEl = $('#asesor-notes');
3757
3760
  const asesorProviderEl = $('#asesor-provider');
@@ -4325,7 +4328,7 @@ const voz = {
4325
4328
  enabled: false,
4326
4329
  voice: 'es-AR-ElenaNeural',
4327
4330
  rate: '+0%',
4328
- announce: { start: true, tools: true, end: true, advice: true, answer: false },
4331
+ announce: { start: true, tools: true, end: true, advice: true, answer: false, summary: false },
4329
4332
  },
4330
4333
  };
4331
4334
 
@@ -4471,8 +4474,13 @@ function narrar(event, data, turn) {
4471
4474
  else if (event === 'tool_call' && announce.tools) hablar(data?.say);
4472
4475
  // El asesor y la respuesta final son el cierre del turno: si quedan en cola
4473
4476
  // detras de un audio largo, igual hay que leerlos (sin vencimiento).
4474
- else if (event === 'advice' && announce.advice) hablar(data?.say, { label: 'asesor', maxAgeMs: 0 });
4475
- else if (event === 'done' && announce.answer) {
4477
+ else if (event === 'advice') {
4478
+ if (data?.kind === 'summary') {
4479
+ if (announce.summary) hablar(data?.say, { label: 'resumen', maxAgeMs: 0 });
4480
+ } else if (announce.advice) {
4481
+ hablar(data?.say, { label: 'asesor', maxAgeMs: 0 });
4482
+ }
4483
+ } else if (event === 'done' && announce.answer) {
4476
4484
  // Lee la respuesta final limpia; si era solo codigo, cae al cierre.
4477
4485
  const spoken = speechText(data?.content);
4478
4486
  if (spoken) hablar(spoken, { label: 'respuesta', maxAgeMs: 0 });
@@ -4505,6 +4513,7 @@ async function aplicarVoz(data) {
4505
4513
  end: data.announce?.end !== false,
4506
4514
  advice: data.announce?.advice !== false,
4507
4515
  answer: data.announce?.answer === true,
4516
+ summary: data.announce?.summary === true,
4508
4517
  },
4509
4518
  };
4510
4519
  if (vozEnabledEl) vozEnabledEl.checked = voz.settings.enabled;
@@ -4523,6 +4532,7 @@ async function aplicarVoz(data) {
4523
4532
  if (vozAnnToolsEl) vozAnnToolsEl.checked = voz.settings.announce.tools;
4524
4533
  if (vozAnnEndEl) vozAnnEndEl.checked = voz.settings.announce.end;
4525
4534
  if (vozAnnAdviceEl) vozAnnAdviceEl.checked = voz.settings.announce.advice;
4535
+ if (vozAnnSummaryEl) vozAnnSummaryEl.checked = voz.settings.announce.summary;
4526
4536
  if (vozAnnAnswerEl) vozAnnAnswerEl.checked = voz.settings.announce.answer;
4527
4537
  const estado = $('#voz-status');
4528
4538
  if (estado) {
@@ -4549,6 +4559,7 @@ async function submitVoz() {
4549
4559
  end: vozAnnEndEl?.checked !== false,
4550
4560
  advice: vozAnnAdviceEl?.checked !== false,
4551
4561
  answer: vozAnnAnswerEl?.checked === true,
4562
+ summary: vozAnnSummaryEl?.checked === true,
4552
4563
  },
4553
4564
  };
4554
4565
  const data = await api('/api/tts/settings', { method: 'PUT', body }).catch((e) => ({ error: e.message }));
@@ -4716,7 +4727,7 @@ trTestBtn?.addEventListener('click', toggleTrTest);
4716
4727
  async function testVoz() {
4717
4728
  setStatus('#voz-msg', 'sintetizando…');
4718
4729
  try {
4719
- await reproducirVoz('Hola, soy tamnora y así es la voz con la que cuento lo que estoy haciendo. ¿Qué te parece?', {
4730
+ await reproducirVoz('Hola!, soy tu agente de IA, y así es la voz con la que cuento lo que estoy haciendo. ¿Qué te parece?', {
4720
4731
  voice: vozVoiceEl?.value,
4721
4732
  rate: vozRateEl?.value,
4722
4733
  });
@@ -4734,6 +4745,7 @@ function aplicarAsesor(data) {
4734
4745
  if (asesorCodeEl) asesorCodeEl.checked = data.code !== false;
4735
4746
  if (asesorExplainEl) asesorExplainEl.checked = data.explain !== false;
4736
4747
  if (asesorAdviceEl) asesorAdviceEl.checked = data.advice !== false;
4748
+ if (asesorSummaryEl) asesorSummaryEl.checked = data.summary === true;
4737
4749
  if (asesorWordsEl) asesorWordsEl.value = data.maxWords ?? 40;
4738
4750
  if (asesorNotesEl) asesorNotesEl.value = data.notes ?? '';
4739
4751
  if (asesorProviderEl) asesorProviderEl.value = data.provider ?? '';
@@ -4757,6 +4769,7 @@ function asesorBody() {
4757
4769
  code: asesorCodeEl?.checked !== false,
4758
4770
  explain: asesorExplainEl?.checked !== false,
4759
4771
  advice: asesorAdviceEl?.checked !== false,
4772
+ summary: asesorSummaryEl?.checked === true,
4760
4773
  maxWords: Number(asesorWordsEl?.value) || 40,
4761
4774
  notes: asesorNotesEl?.value ?? '',
4762
4775
  provider: asesorProviderEl?.value ?? '',
@@ -4778,7 +4791,8 @@ async function testAsesor() {
4778
4791
  setStatus('#asesor-msg', 'consultando…');
4779
4792
  const data = await api('/api/advisor/test', { method: 'POST' }).catch((e) => ({ error: e.message }));
4780
4793
  if (data?.error) return setStatus('#asesor-msg', data.error, 'err');
4781
- setStatus('#asesor-msg', data.speak ? data.text : '(no diría nada en este caso)', 'ok');
4794
+ const partes = [data.speak ? data.text : '', data.summary ? `resumen: ${data.summary}` : ''].filter(Boolean);
4795
+ setStatus('#asesor-msg', partes.length ? partes.join(' · ') : '(no diría nada en este caso)', 'ok');
4782
4796
  }
4783
4797
 
4784
4798
  /* --- ajustes: perfil del usuario ------------------------------------------ */
@@ -4916,7 +4930,7 @@ if (mcpListEl) {
4916
4930
  $('#voz-save')?.addEventListener('click', submitVoz);
4917
4931
  $('#voz-test')?.addEventListener('click', testVoz);
4918
4932
  $('#asesor-save')?.addEventListener('click', submitAsesor);
4919
- $('#asesor-test')?.addEventListener('click', testAsesor);
4933
+ $('#asesor-test')?.addEventListener('click', testAsesor);
4920
4934
  $('#mcp-close').addEventListener('click', closeMcpForm);
4921
4935
  $('#mf-cancel').addEventListener('click', closeMcpForm);
4922
4936
  $('#mf-transport').addEventListener('change', syncMcpFormFields);
@@ -5873,7 +5887,7 @@ function renderAlertSignals() {
5873
5887
  * Un SSE por pestana para enterarse al instante. **No decide nada**: dispara
5874
5888
  * `reconcile()`. Si se cae -que es lo normal en un tunel, y lo que hace iOS al
5875
5889
  * mandar la pestana a background- solo perdemos inmediatez, no correctitud.
5876
- */
5890
+ */
5877
5891
  function handleGlobalEvent(event) {
5878
5892
  // No decide nada: solo pide el estado fresco. El diff es el unico que sabe
5879
5893
  // que hay que hacer.
@@ -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.15.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.16.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>
@@ -714,6 +714,9 @@
714
714
  <label class="check" title="leer los consejos del asesor">
715
715
  <input id="voz-ann-advice" type="checkbox" /> los consejos del asesor
716
716
  </label>
717
+ <label class="check" title="leer un resumen breve de lo que hizo el agente">
718
+ <input id="voz-ann-summary" type="checkbox" /> Explicar resumidamente qué hizo
719
+ </label>
717
720
  <label class="check" title="leer la respuesta final del agente, sin bloques de código ni tablas">
718
721
  <input id="voz-ann-answer" type="checkbox" /> la respuesta final (sin código)
719
722
  </label>
@@ -761,6 +764,9 @@
761
764
  <label class="check" title="dar consejos concretos">
762
765
  <input id="asesor-advice" type="checkbox" /> consejos
763
766
  </label>
767
+ <label class="check" title="resumir en una o dos frases qué pidió y qué se hizo">
768
+ <input id="asesor-summary" type="checkbox" /> resumir qué hizo
769
+ </label>
764
770
  </div>
765
771
  <label class="field"><span>largo de cada consejo (palabras)</span>
766
772
  <input id="asesor-words" type="number" min="10" max="120" step="5" value="40" />
@@ -872,6 +872,24 @@ body.resizing * { cursor: col-resize !important; }
872
872
  .chip.err { border-left-color: var(--danger); color: var(--danger); }
873
873
  .chip.ok { border-left-color: var(--ok); color: var(--ok); }
874
874
  .chip.warn { border-left-color: var(--warn); color: var(--warn); }
875
+
876
+ .continue-btn {
877
+ align-self: flex-start;
878
+ display: inline-flex;
879
+ align-items: center;
880
+ gap: 6px;
881
+ background: var(--accent-soft);
882
+ color: var(--accent);
883
+ border: 1px solid var(--accent);
884
+ border-radius: 10px;
885
+ padding: 6px 14px;
886
+ cursor: pointer;
887
+ font: 600 12.5px var(--sans);
888
+ white-space: nowrap;
889
+ transition: 0.15s ease;
890
+ }
891
+ .continue-btn:hover { background: var(--accent); color: var(--primary-fg); }
892
+ .continue-btn:active { transform: translateY(1px); }
875
893
  details.reasoning { margin-top: 6px; }
876
894
  details.reasoning > summary { color: var(--muted); cursor: pointer; font-size: 12.5px; }
877
895
  details.reasoning .body { color: var(--muted); white-space: pre-wrap; margin-top: 4px; border-left: 1px solid var(--border); padding-left: 10px; }
@@ -2,6 +2,7 @@
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
6
  //
6
7
  // La salida es JSON estricto para poder decidir el silencio: si el modelo no
7
8
  // tiene nada que aportar, no habla ni deja tarjeta.
@@ -13,6 +14,7 @@ export const DEFAULT_ADVISOR = {
13
14
  code: true,
14
15
  explain: true,
15
16
  advice: true,
17
+ summary: false,
16
18
  notes: '',
17
19
  maxWords: 40,
18
20
  provider: '',
@@ -32,6 +34,7 @@ export function resolveAdvisor(app) {
32
34
  code: saved.code !== false,
33
35
  explain: saved.explain !== false,
34
36
  advice: saved.advice !== false,
37
+ summary: saved.summary === true,
35
38
  notes: typeof saved.notes === 'string' ? saved.notes.slice(0, 2000) : '',
36
39
  maxWords: Number.isFinite(maxWords)
37
40
  ? Math.min(Math.max(Math.round(maxWords), 10), 120)
@@ -108,18 +111,30 @@ function focusLines(options) {
108
111
  /** System + user para la consulta al asesor (expuestos para test). */
109
112
  export function buildAdvisorPrompt({ userInput = '', answer = '', toolSummary = [], changedFiles = [], options = {} } = {}) {
110
113
  const maxWords = options.maxWords ?? DEFAULT_ADVISOR.maxWords;
114
+ const wantsAdvice = Boolean(options.code || options.explain || options.advice);
115
+ const wantsSummary = options.summary === true;
111
116
  const system =
112
117
  'Sos el asesor personal del usuario dentro de Tamnora, al estilo de Jarvis.\n' +
113
118
  'Recibis lo que el agente hizo en el ultimo turno: el pedido del usuario, su respuesta\n' +
114
119
  'final, las herramientas que uso y los diffs de los archivos que toco.\n\n' +
115
- 'Tu trabajo NO es resumir ni repetir la respuesta del agente: el usuario ya la esta\n' +
116
- 'viendo. Solo aportas algo que el agente no dijo. Enfocate en:\n' +
117
- `${focusLines(options)}\n\n` +
120
+ (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' +
123
+ `${focusLines(options)}\n` +
124
+ 'Si no hay nada que valga la pena decir, pon "speak" en false.\n\n'
125
+ : '') +
126
+ (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'
130
+ : '') +
118
131
  'Hablale al usuario en segunda persona, breve (' +
119
132
  `${maxWords} palabras como maximo), en espanol, sin markdown ni listas. Si no hay nada\n` +
120
133
  'que valga la pena decir, no hables.\n' +
121
134
  (options.notes ? `\nInstrucciones del usuario: ${options.notes}\n` : '') +
122
- '\nResponde SOLO con JSON: {"speak": true|false, "kind": "code|explain|advice", "text": "..."}';
135
+ '\nResponde SOLO con JSON: {"speak": true|false, "kind": "code|explain|advice", "text": "..."' +
136
+ (wantsSummary ? ', "summary": "..."' : '') +
137
+ '}';
123
138
 
124
139
  const parts = [`PEDIDO DEL USUARIO:\n${userInput || '(sin texto)'}`];
125
140
  parts.push(`RESPUESTA FINAL DEL AGENTE:\n${answer || '(sin respuesta)'}`);
@@ -173,8 +188,9 @@ export async function consultAdvisor({
173
188
  if (error || !value || typeof value !== 'object') return null;
174
189
  const kind = KINDS.has(value.kind) ? value.kind : 'advice';
175
190
  const text = truncateWords(value.text, maxWords);
176
- if (value.speak !== true || !text) return { speak: false, kind, text: '' };
177
- return { speak: true, kind, text };
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 };
178
194
  } catch {
179
195
  return null;
180
196
  }
@@ -8,14 +8,14 @@ export const DEFAULT_TTS = {
8
8
  enabled: false,
9
9
  voice: VOZ_POR_DEFECTO,
10
10
  rate: '+0%',
11
- announce: { start: true, tools: true, end: true, advice: true, answer: false },
11
+ announce: { start: true, tools: true, end: true, advice: true, answer: false, summary: false },
12
12
  };
13
13
 
14
14
  /** Merge de defaults + lo guardado (con `announce` aplanado a booleans). */
15
15
  export function resolveTts(app) {
16
16
  const saved = app?.tts && typeof app.tts === 'object' ? app.tts : {};
17
17
  const announce = { ...DEFAULT_TTS.announce, ...(saved.announce ?? {}) };
18
- for (const key of ['start', 'tools', 'end', 'advice', 'answer']) announce[key] = announce[key] === true;
18
+ for (const key of ['start', 'tools', 'end', 'advice', 'answer', 'summary']) announce[key] = announce[key] === true;
19
19
  return {
20
20
  ...DEFAULT_TTS,
21
21
  ...saved,
@@ -19,6 +19,7 @@ export function registerAdvisor(route, ctx) {
19
19
  if ('code' in body) next.code = body.code !== false;
20
20
  if ('explain' in body) next.explain = body.explain !== false;
21
21
  if ('advice' in body) next.advice = body.advice !== false;
22
+ if ('summary' in body) next.summary = body.summary === true;
22
23
  if ('notes' in body) next.notes = String(body.notes ?? '').slice(0, 2000);
23
24
  if ('provider' in body) next.provider = String(body.provider ?? '').trim().slice(0, 60);
24
25
  if ('model' in body) next.model = String(body.model ?? '').trim().slice(0, 120);
@@ -394,6 +394,22 @@ export function registerChat(route, ctx) {
394
394
  ctx.runs.emit(sessionId, 'advice', { text: advice.text, kind: advice.kind, say: advice.text });
395
395
  ctx.hooks?.dispatch({ type: 'advice', sessionId, text: advice.text, kind: advice.kind });
396
396
  }
397
+ // El resumen de "que hizo" va como una tarjeta/aviso aparte, para que
398
+ // la voz pueda leerlo siempre (independiente del consejo).
399
+ if (advice?.summary) {
400
+ ctx.store.addMessage(sessionId, {
401
+ role: 'assistant',
402
+ name: 'advisor',
403
+ content: advice.summary,
404
+ mode: 'summary',
405
+ });
406
+ ctx.runs.emit(sessionId, 'advice', {
407
+ text: advice.summary,
408
+ kind: 'summary',
409
+ say: advice.summary,
410
+ });
411
+ ctx.hooks?.dispatch({ type: 'advice', sessionId, text: advice.summary, kind: 'summary' });
412
+ }
397
413
  }
398
414
  } catch (error) {
399
415
  partials.flush();
@@ -39,7 +39,7 @@ export function registerTts(route, ctx) {
39
39
  if ('rate' in body) next.rate = sanearRate(body.rate, actual.rate);
40
40
  if (body.announce && typeof body.announce === 'object') {
41
41
  const announce = { ...actual.announce };
42
- for (const key of ['start', 'tools', 'end', 'advice', 'answer']) {
42
+ for (const key of ['start', 'tools', 'end', 'advice', 'answer', 'summary']) {
43
43
  if (key in body.announce) announce[key] = body.announce[key] === true;
44
44
  }
45
45
  next.announce = announce;
@@ -25,15 +25,17 @@ const editMessages = () => [
25
25
  { role: 'tool', tool_call_id: 'c1', name: 'edit', content: '- const token = "secreto"\n+ const token = process.env.TOKEN' },
26
26
  ];
27
27
 
28
- test('resolveAdvisor mezcla defaults y sanitiza', () => {
29
- assert.equal(resolveAdvisor({}).enabled, false);
30
- const saved = resolveAdvisor({ advisor: { enabled: true, notes: 'x', maxWords: 999 } });
31
- assert.equal(saved.enabled, true);
32
- assert.equal(saved.code, true);
33
- assert.equal(saved.maxWords, 120);
34
- const off = resolveAdvisor({ advisor: { code: false, explain: false, advice: false, maxWords: 5 } });
35
- assert.equal(off.maxWords, 10);
36
- assert.equal(off.code, false);
28
+ test('resolveAdvisor mezcla defaults y sanitiza', () => {
29
+ assert.equal(resolveAdvisor({}).enabled, false);
30
+ assert.equal(resolveAdvisor({}).summary, false);
31
+ const saved = resolveAdvisor({ advisor: { enabled: true, notes: 'x', maxWords: 999, summary: true } });
32
+ assert.equal(saved.enabled, true);
33
+ assert.equal(saved.code, true);
34
+ assert.equal(saved.summary, true);
35
+ assert.equal(saved.maxWords, 120);
36
+ const off = resolveAdvisor({ advisor: { code: false, explain: false, advice: false, maxWords: 5 } });
37
+ assert.equal(off.maxWords, 10);
38
+ assert.equal(off.code, false);
37
39
  });
38
40
 
39
41
  test('advisorShouldRun exige tools o respuesta larga', () => {
@@ -55,19 +57,30 @@ test('collectToolSummary lista las tools del turno', () => {
55
57
  assert.match(summary[0], /^edit:/);
56
58
  });
57
59
 
58
- test('buildAdvisorPrompt respeta los toggles y las notas', () => {
59
- const { system, user } = buildAdvisorPrompt({
60
- userInput: 'hola',
61
- answer: 'listo',
62
- toolSummary: ['edit: src/a.js'],
63
- changedFiles: [{ path: 'src/a.js', diff: '- x\n+ y' }],
64
- options: { code: false, explain: true, advice: true, notes: 'avisame de secretos', maxWords: 20 },
65
- });
66
- assert.doesNotMatch(system, /problema real en el codigo/);
67
- assert.match(system, /explicar un proceso/);
68
- assert.match(system, /avisame de secretos/);
69
- assert.match(user, /PEDIDO DEL USUARIO/);
70
- assert.match(user, /src\/a\.js/);
60
+ test('buildAdvisorPrompt respeta los toggles y las notas', () => {
61
+ const { system, user } = buildAdvisorPrompt({
62
+ userInput: 'hola',
63
+ answer: 'listo',
64
+ toolSummary: ['edit: src/a.js'],
65
+ changedFiles: [{ path: 'src/a.js', diff: '- x\n+ y' }],
66
+ options: { code: false, explain: true, advice: true, notes: 'avisame de secretos', maxWords: 20 },
67
+ });
68
+ assert.doesNotMatch(system, /problema real en el codigo/);
69
+ assert.match(system, /explicar un proceso/);
70
+ assert.match(system, /avisame de secretos/);
71
+ assert.match(user, /PEDIDO DEL USUARIO/);
72
+ assert.match(user, /src\/a\.js/);
73
+ });
74
+
75
+ test('buildAdvisorPrompt pide el resumen cuando options.summary', () => {
76
+ const { system } = buildAdvisorPrompt({
77
+ userInput: 'hola',
78
+ answer: 'listo',
79
+ options: { summary: true, code: false, explain: false, advice: false, maxWords: 20 },
80
+ });
81
+ assert.match(system, /resumen breve/);
82
+ assert.match(system, /"summary"/);
83
+ assert.doesNotMatch(system, /Tu trabajo NO es resumir/);
71
84
  });
72
85
 
73
86
  const fakeProvider = (content) => ({ streamChat: async () => ({ content }) });
@@ -80,9 +93,29 @@ test('consultAdvisor interpreta speak:true', async () => {
80
93
  messages: editMessages(),
81
94
  options: { code: true, explain: true, advice: true, maxWords: 40 },
82
95
  });
83
- assert.equal(advice.speak, true);
84
- assert.equal(advice.kind, 'code');
85
- assert.match(advice.text, /token/);
96
+ assert.equal(advice.speak, true);
97
+ assert.equal(advice.kind, 'code');
98
+ assert.match(advice.text, /token/);
99
+ });
100
+
101
+ test('consultAdvisor devuelve el resumen aunque no hable', async () => {
102
+ const advice = await consultAdvisor({
103
+ provider: fakeProvider('{"speak":false,"text":"","summary":"Revisaste el token y lo pasaste a env."}'),
104
+ config: {},
105
+ model: 'x',
106
+ messages: editMessages(),
107
+ options: { summary: true, code: false, explain: false, advice: false, maxWords: 40 },
108
+ });
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"}'),
113
+ config: {},
114
+ model: 'x',
115
+ messages: [],
116
+ options: { summary: false, code: true, explain: true, advice: true },
117
+ });
118
+ assert.equal(ignored.summary, '');
86
119
  });
87
120
 
88
121
  test('consultAdvisor respeta speak:false, JSON invalido y errores', async () => {
@@ -138,14 +171,15 @@ test('GET/PUT /api/advisor/settings y POST /api/advisor/test con proveedor inyec
138
171
  const initial = await (await fetch(`${base}/api/advisor/settings`)).json();
139
172
  assert.equal(initial.enabled, false);
140
173
 
141
- const put = await fetch(`${base}/api/advisor/settings`, {
142
- method: 'PUT',
143
- headers: { 'Content-Type': 'application/json' },
144
- body: JSON.stringify({ enabled: true, code: false, maxWords: 500 }),
145
- });
146
- const saved = await put.json();
147
- assert.equal(saved.enabled, true);
148
- assert.equal(saved.code, false);
174
+ const put = await fetch(`${base}/api/advisor/settings`, {
175
+ method: 'PUT',
176
+ headers: { 'Content-Type': 'application/json' },
177
+ body: JSON.stringify({ enabled: true, code: false, summary: true, maxWords: 500 }),
178
+ });
179
+ const saved = await put.json();
180
+ assert.equal(saved.enabled, true);
181
+ assert.equal(saved.code, false);
182
+ assert.equal(saved.summary, true);
149
183
  assert.equal(saved.maxWords, 120);
150
184
 
151
185
  const on = await (await fetch(`${base}/api/advisor/test`, { method: 'POST' })).json();
@@ -173,33 +207,39 @@ test('el asesor emite el evento advice al cerrar el turno', async () => {
173
207
  onEvent?.({ type: 'text', delta: text, full: text });
174
208
  return { content: text, reasoning: '', toolCalls: [] };
175
209
  }
176
- return { content: '{"speak":true,"kind":"code","text":"Cuidado con el token."}', reasoning: '', toolCalls: [] };
177
- },
178
- };
179
- const instance = await createTamnoraServer({
180
- cwd,
181
- dbPath: path.join(cwd, 'tamnora.db'),
182
- loadMcp: false,
183
- resolveProvider: () => ({ provider, config: {} }),
184
- });
185
- instance.ctx.app = {
186
- ...instance.ctx.app,
187
- advisor: { enabled: true, code: true, explain: true, advice: true, notes: '', maxWords: 40 },
188
- };
189
- instance.ctx.store.setSetting('activeProvider', 'fake');
190
- instance.ctx.store.setSetting('activeModel', 'test-model');
191
- const info = await instance.listen({ port: 0 });
192
- try {
193
- const res = await fetch(`${info.url}/api/chat`, {
194
- method: 'POST',
195
- headers: { 'Content-Type': 'application/json' },
196
- body: JSON.stringify({ input: 'quién soy', model: 'test-model', provider: 'fake' }),
197
- });
198
- const body = await res.text();
199
- assert.equal(calls, 2);
200
- assert.match(body, /event: advice/);
201
- assert.match(body, /Cuidado con el token/);
202
- } finally {
203
- await instance.close();
204
- }
205
- });
210
+ return {
211
+ content: '{"speak":true,"kind":"code","text":"Cuidado con el token.","summary":"Revisaste la sesión."}',
212
+ reasoning: '',
213
+ toolCalls: [],
214
+ };
215
+ },
216
+ };
217
+ const instance = await createTamnoraServer({
218
+ cwd,
219
+ dbPath: path.join(cwd, 'tamnora.db'),
220
+ loadMcp: false,
221
+ resolveProvider: () => ({ provider, config: {} }),
222
+ });
223
+ instance.ctx.app = {
224
+ ...instance.ctx.app,
225
+ advisor: { enabled: true, code: true, explain: true, advice: true, summary: true, notes: '', maxWords: 40 },
226
+ };
227
+ instance.ctx.store.setSetting('activeProvider', 'fake');
228
+ instance.ctx.store.setSetting('activeModel', 'test-model');
229
+ const info = await instance.listen({ port: 0 });
230
+ try {
231
+ const res = await fetch(`${info.url}/api/chat`, {
232
+ method: 'POST',
233
+ headers: { 'Content-Type': 'application/json' },
234
+ body: JSON.stringify({ input: 'quién soy', model: 'test-model', provider: 'fake' }),
235
+ });
236
+ const body = await res.text();
237
+ assert.equal(calls, 2);
238
+ assert.match(body, /event: advice/);
239
+ assert.match(body, /Cuidado con el token/);
240
+ assert.match(body, /"kind":"summary"/);
241
+ assert.match(body, /Revisaste la sesión/);
242
+ } finally {
243
+ await instance.close();
244
+ }
245
+ });
@@ -78,20 +78,23 @@ test('los ajustes de voz exponen defaults y sanitizan lo guardado', async () =>
78
78
  assert.equal(initial.announce.start, true);
79
79
  assert.equal(initial.announce.tools, true);
80
80
  assert.equal(initial.announce.end, true);
81
+ assert.equal(initial.announce.summary, false);
81
82
 
82
83
  const put = await fetch(`${base}/api/tts/settings`, {
83
84
  method: 'PUT',
84
85
  headers: { 'Content-Type': 'application/json' },
85
- body: JSON.stringify({ enabled: true, voice: '<script>', rate: '150%' }),
86
+ body: JSON.stringify({ enabled: true, voice: '<script>', rate: '150%', announce: { summary: true } }),
86
87
  });
87
88
  const saved = await put.json();
88
89
  assert.equal(saved.enabled, true);
89
90
  assert.equal(saved.voice, 'es-AR-ElenaNeural');
90
91
  assert.equal(saved.rate, '+100%');
92
+ assert.equal(saved.announce.summary, true);
91
93
 
92
94
  const reread = await (await fetch(`${base}/api/tts/settings`)).json();
93
95
  assert.equal(reread.enabled, true);
94
96
  assert.equal(reread.rate, '+100%');
97
+ assert.equal(reread.announce.summary, true);
95
98
  } finally {
96
99
  global.WebSocket = original;
97
100
  await instance.close();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danieltmn/tamnora",
3
- "version": "0.15.0",
3
+ "version": "0.16.1",
4
4
  "description": "Harness local para IAs: agente CLI-first con TUI, consola web y evaluacion. Node puro, sin dependencias.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.25.0",