@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.
- package/CHANGELOG.md +22 -0
- package/README.md +46 -2
- package/harness/packages/core/src/config/index.js +2 -0
- package/harness/packages/core/src/hooks/index.js +42 -0
- package/harness/packages/core/src/index.js +2 -0
- package/harness/packages/core/src/plugins/index.js +36 -0
- package/harness/packages/core/src/rules/index.js +9 -63
- package/harness/packages/core/src/tools/glob.js +64 -2
- package/harness/packages/core/test/hooks.test.js +29 -0
- package/harness/packages/core/test/plugins.test.js +25 -0
- package/harness/packages/server/public/app.js +27 -7
- package/harness/packages/server/public/index.html +16 -2
- package/harness/packages/server/src/lib/advisor.js +26 -6
- package/harness/packages/server/src/lib/agentRun.js +3 -1
- package/harness/packages/server/src/lib/channels/telegram.js +7 -1
- package/harness/packages/server/src/lib/compact.js +4 -0
- package/harness/packages/server/src/lib/hooks.js +45 -0
- package/harness/packages/server/src/lib/plugins.js +76 -0
- package/harness/packages/server/src/lib/scheduler.js +8 -1
- package/harness/packages/server/src/lib/ttsSettings.js +2 -2
- package/harness/packages/server/src/routes/advisor.js +5 -2
- package/harness/packages/server/src/routes/chat.js +40 -5
- package/harness/packages/server/src/routes/tts.js +1 -1
- package/harness/packages/server/src/server.js +16 -0
- package/harness/packages/server/test/advisor.test.js +103 -63
- package/harness/packages/server/test/server.test.js +66 -0
- package/harness/packages/server/test/tts.test.js +4 -1
- package/harness/tamnora.example.json +2 -0
- 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`)
|
|
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`
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
54
|
+
/* ignore unreadable rule */
|
|
84
55
|
}
|
|
85
56
|
}
|
|
86
|
-
|
|
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'
|
|
4473
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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" />
|