@danieltmn/tamnora 0.14.0 → 0.16.0

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.
Files changed (29) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +46 -2
  3. package/harness/packages/core/src/config/index.js +2 -0
  4. package/harness/packages/core/src/hooks/index.js +42 -0
  5. package/harness/packages/core/src/index.js +2 -0
  6. package/harness/packages/core/src/plugins/index.js +36 -0
  7. package/harness/packages/core/src/rules/index.js +9 -63
  8. package/harness/packages/core/src/tools/glob.js +64 -2
  9. package/harness/packages/core/test/hooks.test.js +29 -0
  10. package/harness/packages/core/test/plugins.test.js +25 -0
  11. package/harness/packages/server/public/app.js +27 -7
  12. package/harness/packages/server/public/index.html +16 -2
  13. package/harness/packages/server/src/lib/advisor.js +26 -6
  14. package/harness/packages/server/src/lib/agentRun.js +3 -1
  15. package/harness/packages/server/src/lib/channels/telegram.js +7 -1
  16. package/harness/packages/server/src/lib/compact.js +4 -0
  17. package/harness/packages/server/src/lib/hooks.js +45 -0
  18. package/harness/packages/server/src/lib/plugins.js +76 -0
  19. package/harness/packages/server/src/lib/scheduler.js +8 -1
  20. package/harness/packages/server/src/lib/ttsSettings.js +2 -2
  21. package/harness/packages/server/src/routes/advisor.js +5 -2
  22. package/harness/packages/server/src/routes/chat.js +40 -5
  23. package/harness/packages/server/src/routes/tts.js +1 -1
  24. package/harness/packages/server/src/server.js +16 -0
  25. package/harness/packages/server/test/advisor.test.js +103 -63
  26. package/harness/packages/server/test/server.test.js +66 -0
  27. package/harness/packages/server/test/tts.test.js +4 -1
  28. package/harness/tamnora.example.json +2 -0
  29. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,28 @@ 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.0] - 2026-10-10
9
+
10
+ ### Cambios
11
+
12
+ - docs: memoria, tareas, hooks, plugins y asesor
13
+ - feat(server): asesor y voz con resumen breve de lo hecho
14
+
15
+ ## [0.15.0] - 2026-10-10
16
+
17
+ ### Agregado
18
+
19
+ - **Fase 4 del roadmap del server (plataforma)**:
20
+ - **Hooks locales**: módulos `.mjs` en `.tamnora/hooks/*.mjs` (o `hooks[]` en
21
+ `tamnora.json`) que observan el ciclo de vida (`run.start/end`,
22
+ `tool.before/after`, `session.compacting/compacted`, `advice`). Aislados de
23
+ errores, con datos redactados, opt-in.
24
+ - **Plugins**: módulos `.mjs` en `.tamnora/plugins/*.mjs` (o `plugins[]`) que
25
+ agregan **tools propias** al agente (`plugin__<plugin>__<tool>`), sujetas a
26
+ permisos (default `ask`).
27
+ - **Asesor con proveedor/modelo propios**: `app.json -> advisor`
28
+ `provider`/`model`; si están vacíos usa los activos.
29
+
8
30
  ## [0.14.0] - 2026-10-10
9
31
 
10
32
  ### 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`
@@ -16,6 +16,8 @@ export const DEFAULT_CONFIG = {
16
16
  compact: { enabled: true, threshold: 0.75, keepLast: 8 },
17
17
  rules: ['.tamnora/rules/*.md'],
18
18
  memory: { enabled: true },
19
+ hooks: [],
20
+ plugins: [],
19
21
  tools: [],
20
22
  permissions: {},
21
23
  mcp: [],
@@ -0,0 +1,42 @@
1
+ // Hooks locales: modulos `.mjs` que observan el ciclo de vida del agente
2
+ // (cargados con `import()` dinamico, opt-in por presencia y aislados de errores).
3
+ // Un hook exporta por default un objeto `{ name?, events: { '<tipo>': fn } }` o
4
+ // una funcion (se llama para todos los eventos).
5
+
6
+ import path from 'node:path';
7
+ import { pathToFileURL } from 'node:url';
8
+ import { globFiles } from '../tools/glob.js';
9
+
10
+ export const DEFAULT_HOOK_PATTERNS = ['.tamnora/hooks/*.mjs'];
11
+
12
+ /** Normaliza el modulo de un hook a `{ id, events }` (o null si no es valido). */
13
+ export function normalizeHookModule(mod, id = '') {
14
+ const value = mod?.default ?? mod;
15
+ if (typeof value === 'function') return { id, events: { '*': value } };
16
+ if (value && typeof value === 'object' && value.events && typeof value.events === 'object') {
17
+ return { id: value.name || id, events: value.events };
18
+ }
19
+ if (value && typeof value === 'object' && typeof value.onEvent === 'function') {
20
+ return { id: value.name || id, events: { '*': value.onEvent } };
21
+ }
22
+ return null;
23
+ }
24
+
25
+ /**
26
+ * Carga los hooks de `patterns` + `.tamnora/hooks/*.mjs` (relativos a `cwd`).
27
+ * Un modulo roto se ignora.
28
+ */
29
+ export async function loadHooks(cwd, patterns = []) {
30
+ const list = [...new Set([...(patterns ?? []), ...DEFAULT_HOOK_PATTERNS])];
31
+ const hooks = [];
32
+ for (const rel of await globFiles(cwd, list)) {
33
+ try {
34
+ const mod = await import(pathToFileURL(path.join(cwd, rel)).href);
35
+ const hook = normalizeHookModule(mod, rel);
36
+ if (hook) hooks.push({ ...hook, path: rel });
37
+ } catch {
38
+ /* modulo roto: se ignora */
39
+ }
40
+ }
41
+ return hooks;
42
+ }
@@ -124,6 +124,8 @@ export {
124
124
  buildPathRulesBlock,
125
125
  } from './rules/index.js';
126
126
  export { parseScheduleSpec, parseCronFields, nextRun, nextRunFor } from './schedule/cron.js';
127
+ export { loadHooks, normalizeHookModule, DEFAULT_HOOK_PATTERNS } from './hooks/index.js';
128
+ export { loadPlugins, normalizePlugin, DEFAULT_PLUGIN_PATTERNS } from './plugins/index.js';
127
129
  export {
128
130
  searchMcpRegistry,
129
131
  getMcpRegistryServer,
@@ -0,0 +1,36 @@
1
+ // Plugins locales: modulos `.mjs` que exportan `{ name?, tools: [...] }` para
2
+ // agregar herramientas propias al agente. Se cargan con `import()` dinamico,
3
+ // opt-in por presencia. Cada tool: `{ name, description, parameters?, required?,
4
+ // handler(args, ctx) }`.
5
+
6
+ import path from 'node:path';
7
+ import { pathToFileURL } from 'node:url';
8
+ import { globFiles } from '../tools/glob.js';
9
+
10
+ export const DEFAULT_PLUGIN_PATTERNS = ['.tamnora/plugins/*.mjs'];
11
+
12
+ /** Normaliza el modulo de un plugin a `{ id, tools }` (o null). */
13
+ export function normalizePlugin(mod, id = '') {
14
+ const value = mod?.default ?? mod;
15
+ if (!value || typeof value !== 'object') return null;
16
+ const tools = Array.isArray(value.tools) ? value.tools : [];
17
+ const valid = tools.filter((tool) => tool && typeof tool.name === 'string' && typeof tool.handler === 'function');
18
+ if (!valid.length) return null;
19
+ return { id: value.name || id, tools: valid };
20
+ }
21
+
22
+ /** Carga los plugins de `patterns` + `.tamnora/plugins/*.mjs`. */
23
+ export async function loadPlugins(cwd, patterns = []) {
24
+ const list = [...new Set([...(patterns ?? []), ...DEFAULT_PLUGIN_PATTERNS])];
25
+ const plugins = [];
26
+ for (const rel of await globFiles(cwd, list)) {
27
+ try {
28
+ const mod = await import(pathToFileURL(path.join(cwd, rel)).href);
29
+ const plugin = normalizePlugin(mod, rel);
30
+ if (plugin) plugins.push({ ...plugin, path: rel });
31
+ } catch {
32
+ /* plugin roto: se ignora */
33
+ }
34
+ }
35
+ return plugins;
36
+ }
@@ -2,9 +2,9 @@
2
2
  // Las que no tienen `paths` son estáticas (van siempre al system prompt); las
3
3
  // que sí, se inyectan en runtime cuando una tool toca un archivo que matchea.
4
4
 
5
- import { readFile, readdir, stat } from 'node:fs/promises';
5
+ import { readFile } from 'node:fs/promises';
6
6
  import path from 'node:path';
7
- import { globToRegExp } from '../tools/glob.js';
7
+ import { globToRegExp, globFiles } from '../tools/glob.js';
8
8
 
9
9
  /** Parsea un archivo de reglas: frontmatter (name, paths/globs) + body. */
10
10
  export function parseRuleMarkdown(text, fallbackId = '') {
@@ -41,74 +41,20 @@ function normalizePaths(paths) {
41
41
  return [...new Set((paths ?? []).map((p) => String(p).replace(/\\/g, '/').replace(/^\.?\//, '').trim()).filter(Boolean))];
42
42
  }
43
43
 
44
- function hasGlob(pattern) {
45
- return /[*?]/.test(pattern);
46
- }
47
-
48
- function fixedPrefix(pattern) {
49
- if (!hasGlob(pattern)) return pattern;
50
- const at = pattern.search(/[*?]/);
51
- const slash = pattern.slice(0, at).lastIndexOf('/');
52
- return slash >= 0 ? pattern.slice(0, slash) : '';
53
- }
54
-
55
44
  /** Carga y parsea los archivos de reglas que matchean `patterns` (rel a `root`). */
56
45
  export async function loadRules(root, patterns = []) {
57
- const seen = new Set();
58
46
  const rules = [];
59
- for (const pattern of patterns) {
60
- const normalized = String(pattern).replace(/\\/g, '/');
61
- const candidates = await matchFiles(root, normalized);
62
- for (const rel of candidates) {
63
- if (seen.has(rel)) continue;
64
- seen.add(rel);
65
- try {
66
- const text = await readFile(path.join(root, rel), 'utf8');
67
- const parsed = parseRuleMarkdown(text, rel);
68
- if (!parsed.body) continue;
69
- rules.push({ id: rel, name: parsed.name, paths: parsed.paths, body: parsed.body });
70
- } catch {
71
- /* ignore unreadable rule */
72
- }
73
- }
74
- }
75
- return rules.sort((a, b) => a.id.localeCompare(b.id, 'es'));
76
- }
77
-
78
- async function matchFiles(root, pattern) {
79
- if (!hasGlob(pattern)) {
47
+ for (const rel of await globFiles(root, patterns)) {
80
48
  try {
81
- return (await stat(path.join(root, pattern))).isFile() ? [pattern] : [];
49
+ const text = await readFile(path.join(root, rel), 'utf8');
50
+ const parsed = parseRuleMarkdown(text, rel);
51
+ if (!parsed.body) continue;
52
+ rules.push({ id: rel, name: parsed.name, paths: parsed.paths, body: parsed.body });
82
53
  } catch {
83
- return [];
54
+ /* ignore unreadable rule */
84
55
  }
85
56
  }
86
- const prefix = fixedPrefix(pattern);
87
- const base = prefix ? path.join(root, prefix) : root;
88
- const files = [];
89
- const walk = async (dir, depth) => {
90
- let entries;
91
- try {
92
- entries = await readdir(dir, { withFileTypes: true });
93
- } catch {
94
- return;
95
- }
96
- for (const entry of entries) {
97
- if (entry.name === '.git' || entry.name === 'node_modules') continue;
98
- const full = path.join(dir, entry.name);
99
- if (entry.isDirectory()) {
100
- if (depth < 6 && entry.name !== '.git' && entry.name !== 'node_modules') {
101
- await walk(full, depth + 1);
102
- }
103
- } else if (entry.isFile()) {
104
- const rel = path.relative(root, full).replace(/\\/g, '/');
105
- files.push(rel);
106
- }
107
- }
108
- };
109
- await walk(base, 0);
110
- const re = globToRegExp(pattern);
111
- return files.filter((rel) => re.test(rel));
57
+ return rules.sort((a, b) => a.id.localeCompare(b.id, 'es'));
112
58
  }
113
59
 
114
60
  /** Reglas sin `paths` (aplican siempre). */
@@ -1,6 +1,5 @@
1
- import { readdir } from 'node:fs/promises';
1
+ import { readdir, stat } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
-
4
3
  export const DEFAULT_IGNORES = new Set([
5
4
  'node_modules',
6
5
  '.git',
@@ -65,3 +64,66 @@ export async function* walkFiles(root, { ignores = DEFAULT_IGNORES } = {}) {
65
64
  }
66
65
  }
67
66
  }
67
+
68
+ function hasGlob(pattern) {
69
+ return /[*?]/.test(pattern);
70
+ }
71
+
72
+ function fixedPrefix(pattern) {
73
+ if (!hasGlob(pattern)) return pattern;
74
+ const at = pattern.search(/[*?]/);
75
+ const slash = pattern.slice(0, at).lastIndexOf('/');
76
+ return slash >= 0 ? pattern.slice(0, slash) : '';
77
+ }
78
+
79
+ async function matchPattern(root, pattern) {
80
+ if (!hasGlob(pattern)) {
81
+ try {
82
+ return (await stat(path.join(root, pattern))).isFile() ? [pattern] : [];
83
+ } catch {
84
+ return [];
85
+ }
86
+ }
87
+ const prefix = fixedPrefix(pattern);
88
+ const base = prefix ? path.join(root, prefix) : root;
89
+ const files = [];
90
+ const walk = async (dir, depth) => {
91
+ let entries;
92
+ try {
93
+ entries = await readdir(dir, { withFileTypes: true });
94
+ } catch {
95
+ return;
96
+ }
97
+ for (const entry of entries) {
98
+ if (entry.name === '.git' || entry.name === 'node_modules') continue;
99
+ const full = path.join(dir, entry.name);
100
+ if (entry.isDirectory()) {
101
+ if (depth < 6) await walk(full, depth + 1);
102
+ } else if (entry.isFile()) {
103
+ files.push(path.relative(root, full).replace(/\\/g, '/'));
104
+ }
105
+ }
106
+ };
107
+ await walk(base, 0);
108
+ const re = globToRegExp(pattern);
109
+ return files.filter((rel) => re.test(rel));
110
+ }
111
+
112
+ /**
113
+ * Resuelve una lista de patrones glob a archivos (relativos a `root`). A
114
+ * diferencia de `walkFiles`, entra en `.tamnora` (los patrones suelen apuntar
115
+ * ahi): solo saltea `.git` y `node_modules`.
116
+ */
117
+ export async function globFiles(root, patterns = []) {
118
+ const seen = new Set();
119
+ const out = [];
120
+ for (const pattern of patterns) {
121
+ const rel = String(pattern).replace(/\\/g, '/');
122
+ for (const file of await matchPattern(root, rel)) {
123
+ if (seen.has(file)) continue;
124
+ seen.add(file);
125
+ out.push(file);
126
+ }
127
+ }
128
+ return out;
129
+ }
@@ -0,0 +1,29 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import path from 'node:path';
6
+ import { normalizeHookModule, loadHooks } from '../src/hooks/index.js';
7
+
8
+ test('normalizeHookModule acepta objeto events, funcion y onEvent', () => {
9
+ const byEvents = normalizeHookModule({ default: { name: 'a', events: { 'run.end': () => {} } } }, 'x');
10
+ assert.equal(byEvents.id, 'a');
11
+ assert.deepEqual(Object.keys(byEvents.events), ['run.end']);
12
+ assert.ok(normalizeHookModule({ default: () => {} }, 'f').events['*']);
13
+ assert.ok(normalizeHookModule({ default: { onEvent: () => {} } }, 'o').events['*']);
14
+ assert.equal(normalizeHookModule({ default: {} }, 'n'), null);
15
+ });
16
+
17
+ test('loadHooks carga .tamnora/hooks/*.mjs e ignora lo roto', async () => {
18
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-hooks-'));
19
+ await mkdir(path.join(cwd, '.tamnora', 'hooks'), { recursive: true });
20
+ await writeFile(
21
+ path.join(cwd, '.tamnora', 'hooks', 'demo.mjs'),
22
+ "export default { name: 'demo', events: { 'run.end': () => {} } };\n"
23
+ );
24
+ await writeFile(path.join(cwd, '.tamnora', 'hooks', 'roto.mjs'), 'this is not valid javascript {{{\n');
25
+ const hooks = await loadHooks(cwd, []);
26
+ assert.equal(hooks.length, 1);
27
+ assert.equal(hooks[0].id, 'demo');
28
+ assert.deepEqual(Object.keys(hooks[0].events), ['run.end']);
29
+ });
@@ -0,0 +1,25 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import path from 'node:path';
6
+ import { normalizePlugin, loadPlugins } from '../src/plugins/index.js';
7
+
8
+ test('normalizePlugin filtra tools invalidas', () => {
9
+ const plugin = normalizePlugin({ default: { name: 'demo', tools: [{ name: 'x', handler: () => {} }, { name: 'bad' }] } }, 'z');
10
+ assert.equal(plugin.id, 'demo');
11
+ assert.equal(plugin.tools.length, 1);
12
+ assert.equal(normalizePlugin({ default: {} }, 'n'), null);
13
+ });
14
+
15
+ test('loadPlugins carga .tamnora/plugins/*.mjs', async () => {
16
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-plugins-'));
17
+ await mkdir(path.join(cwd, '.tamnora', 'plugins'), { recursive: true });
18
+ await writeFile(
19
+ path.join(cwd, '.tamnora', 'plugins', 'demo.mjs'),
20
+ "export default { name: 'demo', tools: [{ name: 'saludar', handler: () => 'hi' }] };\n"
21
+ );
22
+ const plugins = await loadPlugins(cwd, []);
23
+ assert.equal(plugins.length, 1);
24
+ assert.equal(plugins[0].tools[0].name, 'saludar');
25
+ });
@@ -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
 
@@ -3746,14 +3747,18 @@ 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');
3760
+ const asesorProviderEl = $('#asesor-provider');
3761
+ const asesorModelEl = $('#asesor-model');
3757
3762
  const trProviderEl = $('#tr-provider');
3758
3763
  const trModelEl = $('#tr-model');
3759
3764
 
@@ -4323,7 +4328,7 @@ const voz = {
4323
4328
  enabled: false,
4324
4329
  voice: 'es-AR-ElenaNeural',
4325
4330
  rate: '+0%',
4326
- 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 },
4327
4332
  },
4328
4333
  };
4329
4334
 
@@ -4469,8 +4474,13 @@ function narrar(event, data, turn) {
4469
4474
  else if (event === 'tool_call' && announce.tools) hablar(data?.say);
4470
4475
  // El asesor y la respuesta final son el cierre del turno: si quedan en cola
4471
4476
  // detras de un audio largo, igual hay que leerlos (sin vencimiento).
4472
- else if (event === 'advice' && announce.advice) hablar(data?.say, { label: 'asesor', maxAgeMs: 0 });
4473
- 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) {
4474
4484
  // Lee la respuesta final limpia; si era solo codigo, cae al cierre.
4475
4485
  const spoken = speechText(data?.content);
4476
4486
  if (spoken) hablar(spoken, { label: 'respuesta', maxAgeMs: 0 });
@@ -4503,6 +4513,7 @@ async function aplicarVoz(data) {
4503
4513
  end: data.announce?.end !== false,
4504
4514
  advice: data.announce?.advice !== false,
4505
4515
  answer: data.announce?.answer === true,
4516
+ summary: data.announce?.summary === true,
4506
4517
  },
4507
4518
  };
4508
4519
  if (vozEnabledEl) vozEnabledEl.checked = voz.settings.enabled;
@@ -4521,6 +4532,7 @@ async function aplicarVoz(data) {
4521
4532
  if (vozAnnToolsEl) vozAnnToolsEl.checked = voz.settings.announce.tools;
4522
4533
  if (vozAnnEndEl) vozAnnEndEl.checked = voz.settings.announce.end;
4523
4534
  if (vozAnnAdviceEl) vozAnnAdviceEl.checked = voz.settings.announce.advice;
4535
+ if (vozAnnSummaryEl) vozAnnSummaryEl.checked = voz.settings.announce.summary;
4524
4536
  if (vozAnnAnswerEl) vozAnnAnswerEl.checked = voz.settings.announce.answer;
4525
4537
  const estado = $('#voz-status');
4526
4538
  if (estado) {
@@ -4547,6 +4559,7 @@ async function submitVoz() {
4547
4559
  end: vozAnnEndEl?.checked !== false,
4548
4560
  advice: vozAnnAdviceEl?.checked !== false,
4549
4561
  answer: vozAnnAnswerEl?.checked === true,
4562
+ summary: vozAnnSummaryEl?.checked === true,
4550
4563
  },
4551
4564
  };
4552
4565
  const data = await api('/api/tts/settings', { method: 'PUT', body }).catch((e) => ({ error: e.message }));
@@ -4714,7 +4727,7 @@ trTestBtn?.addEventListener('click', toggleTrTest);
4714
4727
  async function testVoz() {
4715
4728
  setStatus('#voz-msg', 'sintetizando…');
4716
4729
  try {
4717
- 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?', {
4718
4731
  voice: vozVoiceEl?.value,
4719
4732
  rate: vozRateEl?.value,
4720
4733
  });
@@ -4732,8 +4745,11 @@ function aplicarAsesor(data) {
4732
4745
  if (asesorCodeEl) asesorCodeEl.checked = data.code !== false;
4733
4746
  if (asesorExplainEl) asesorExplainEl.checked = data.explain !== false;
4734
4747
  if (asesorAdviceEl) asesorAdviceEl.checked = data.advice !== false;
4748
+ if (asesorSummaryEl) asesorSummaryEl.checked = data.summary === true;
4735
4749
  if (asesorWordsEl) asesorWordsEl.value = data.maxWords ?? 40;
4736
4750
  if (asesorNotesEl) asesorNotesEl.value = data.notes ?? '';
4751
+ if (asesorProviderEl) asesorProviderEl.value = data.provider ?? '';
4752
+ if (asesorModelEl) asesorModelEl.value = data.model ?? '';
4737
4753
  const estado = $('#asesor-status');
4738
4754
  if (estado) {
4739
4755
  estado.className = `summary${data.enabled ? ' ok' : ''}`;
@@ -4753,8 +4769,11 @@ function asesorBody() {
4753
4769
  code: asesorCodeEl?.checked !== false,
4754
4770
  explain: asesorExplainEl?.checked !== false,
4755
4771
  advice: asesorAdviceEl?.checked !== false,
4772
+ summary: asesorSummaryEl?.checked === true,
4756
4773
  maxWords: Number(asesorWordsEl?.value) || 40,
4757
4774
  notes: asesorNotesEl?.value ?? '',
4775
+ provider: asesorProviderEl?.value ?? '',
4776
+ model: asesorModelEl?.value ?? '',
4758
4777
  };
4759
4778
  }
4760
4779
 
@@ -4772,7 +4791,8 @@ async function testAsesor() {
4772
4791
  setStatus('#asesor-msg', 'consultando…');
4773
4792
  const data = await api('/api/advisor/test', { method: 'POST' }).catch((e) => ({ error: e.message }));
4774
4793
  if (data?.error) return setStatus('#asesor-msg', data.error, 'err');
4775
- 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');
4776
4796
  }
4777
4797
 
4778
4798
  /* --- ajustes: perfil del usuario ------------------------------------------ */
@@ -4910,7 +4930,7 @@ if (mcpListEl) {
4910
4930
  $('#voz-save')?.addEventListener('click', submitVoz);
4911
4931
  $('#voz-test')?.addEventListener('click', testVoz);
4912
4932
  $('#asesor-save')?.addEventListener('click', submitAsesor);
4913
- $('#asesor-test')?.addEventListener('click', testAsesor);
4933
+ $('#asesor-test')?.addEventListener('click', testAsesor);
4914
4934
  $('#mcp-close').addEventListener('click', closeMcpForm);
4915
4935
  $('#mf-cancel').addEventListener('click', closeMcpForm);
4916
4936
  $('#mf-transport').addEventListener('change', syncMcpFormFields);
@@ -5867,7 +5887,7 @@ function renderAlertSignals() {
5867
5887
  * Un SSE por pestana para enterarse al instante. **No decide nada**: dispara
5868
5888
  * `reconcile()`. Si se cae -que es lo normal en un tunel, y lo que hace iOS al
5869
5889
  * mandar la pestana a background- solo perdemos inmediatez, no correctitud.
5870
- */
5890
+ */
5871
5891
  function handleGlobalEvent(event) {
5872
5892
  // No decide nada: solo pide el estado fresco. El diff es el unico que sabe
5873
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.14.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.0</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>
@@ -737,8 +740,16 @@
737
740
  <p class="muted verify-line">
738
741
  Un asesor que observa lo que el agente hace, lo interpreta y solo habla cuando tiene
739
742
  algo que aportar: te alerta de un problema en el código, te explica un proceso o te da
740
- un consejo. No lee la respuesta del agente. Usa el proveedor y modelo activos.
743
+ un consejo. No lee la respuesta del agente. Usa el proveedor y modelo activos, o unos propios.
741
744
  </p>
745
+ <div class="row">
746
+ <label class="field"><span>proveedor (opcional)</span>
747
+ <input id="asesor-provider" spellcheck="false" placeholder="por defecto: el activo" />
748
+ </label>
749
+ <label class="field grow"><span>modelo (opcional)</span>
750
+ <input id="asesor-model" spellcheck="false" placeholder="por defecto: el activo" />
751
+ </label>
752
+ </div>
742
753
  <label class="check" title="tener un asesor que interprete y comente el turno">
743
754
  <input id="asesor-enabled" type="checkbox" /> Habilitar el asesor
744
755
  </label>
@@ -753,6 +764,9 @@
753
764
  <label class="check" title="dar consejos concretos">
754
765
  <input id="asesor-advice" type="checkbox" /> consejos
755
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>
756
770
  </div>
757
771
  <label class="field"><span>largo de cada consejo (palabras)</span>
758
772
  <input id="asesor-words" type="number" min="10" max="120" step="5" value="40" />