@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 +13 -0
- package/README.md +46 -2
- package/harness/packages/server/public/app.js +22 -8
- package/harness/packages/server/public/index.html +7 -1
- package/harness/packages/server/public/styles.css +18 -0
- package/harness/packages/server/src/lib/advisor.js +22 -6
- package/harness/packages/server/src/lib/ttsSettings.js +2 -2
- package/harness/packages/server/src/routes/advisor.js +1 -0
- package/harness/packages/server/src/routes/chat.js +16 -0
- package/harness/packages/server/src/routes/tts.js +1 -1
- package/harness/packages/server/test/advisor.test.js +103 -63
- package/harness/packages/server/test/tts.test.js +4 -1
- package/package.json +1 -1
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`)
|
|
214
|
-
`git_commit`)
|
|
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', '
|
|
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'
|
|
4475
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
177
|
-
return { speak:
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
assert.equal(saved.
|
|
33
|
-
assert.equal(saved.
|
|
34
|
-
|
|
35
|
-
assert.equal(
|
|
36
|
-
|
|
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 {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
};
|
|
189
|
-
instance.ctx.
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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