@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
@@ -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,8 +14,11 @@ 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,
20
+ provider: '',
21
+ model: '',
18
22
  };
19
23
 
20
24
  const KINDS = new Set(['code', 'explain', 'advice']);
@@ -30,10 +34,13 @@ export function resolveAdvisor(app) {
30
34
  code: saved.code !== false,
31
35
  explain: saved.explain !== false,
32
36
  advice: saved.advice !== false,
37
+ summary: saved.summary === true,
33
38
  notes: typeof saved.notes === 'string' ? saved.notes.slice(0, 2000) : '',
34
39
  maxWords: Number.isFinite(maxWords)
35
40
  ? Math.min(Math.max(Math.round(maxWords), 10), 120)
36
41
  : DEFAULT_ADVISOR.maxWords,
42
+ provider: typeof saved.provider === 'string' ? saved.provider.trim().slice(0, 60) : '',
43
+ model: typeof saved.model === 'string' ? saved.model.trim().slice(0, 120) : '',
37
44
  };
38
45
  }
39
46
 
@@ -104,18 +111,30 @@ function focusLines(options) {
104
111
  /** System + user para la consulta al asesor (expuestos para test). */
105
112
  export function buildAdvisorPrompt({ userInput = '', answer = '', toolSummary = [], changedFiles = [], options = {} } = {}) {
106
113
  const maxWords = options.maxWords ?? DEFAULT_ADVISOR.maxWords;
114
+ const wantsAdvice = Boolean(options.code || options.explain || options.advice);
115
+ const wantsSummary = options.summary === true;
107
116
  const system =
108
117
  'Sos el asesor personal del usuario dentro de Tamnora, al estilo de Jarvis.\n' +
109
118
  'Recibis lo que el agente hizo en el ultimo turno: el pedido del usuario, su respuesta\n' +
110
119
  'final, las herramientas que uso y los diffs de los archivos que toco.\n\n' +
111
- 'Tu trabajo NO es resumir ni repetir la respuesta del agente: el usuario ya la esta\n' +
112
- 'viendo. Solo aportas algo que el agente no dijo. Enfocate en:\n' +
113
- `${focusLines(options)}\n\n` +
120
+ (wantsAdvice
121
+ ? 'Tu trabajo NO es resumir ni repetir la respuesta del agente: el usuario ya la esta\n' +
122
+ 'viendo. Solo aportas algo que el agente no dijo. Enfocate en:\n' +
123
+ `${focusLines(options)}\n` +
124
+ 'Si no hay nada que valga la pena decir, pon "speak" en false.\n\n'
125
+ : '') +
126
+ (wantsSummary
127
+ ? 'Ademas el usuario quiere un resumen breve de lo que hizo el agente: en una o dos\n' +
128
+ 'frases, que se pidio y que se hizo, sin copiar la respuesta palabra por palabra.\n' +
129
+ 'Inclui siempre el campo "summary", aunque no tengas un consejo ("speak" puede ser false).\n\n'
130
+ : '') +
114
131
  'Hablale al usuario en segunda persona, breve (' +
115
132
  `${maxWords} palabras como maximo), en espanol, sin markdown ni listas. Si no hay nada\n` +
116
133
  'que valga la pena decir, no hables.\n' +
117
134
  (options.notes ? `\nInstrucciones del usuario: ${options.notes}\n` : '') +
118
- '\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
+ '}';
119
138
 
120
139
  const parts = [`PEDIDO DEL USUARIO:\n${userInput || '(sin texto)'}`];
121
140
  parts.push(`RESPUESTA FINAL DEL AGENTE:\n${answer || '(sin respuesta)'}`);
@@ -169,8 +188,9 @@ export async function consultAdvisor({
169
188
  if (error || !value || typeof value !== 'object') return null;
170
189
  const kind = KINDS.has(value.kind) ? value.kind : 'advice';
171
190
  const text = truncateWords(value.text, maxWords);
172
- if (value.speak !== true || !text) return { speak: false, kind, text: '' };
173
- return { speak: true, kind, text };
191
+ const summary = options.summary === true ? truncateWords(value.summary, maxWords) : '';
192
+ if (value.speak !== true || !text) return { speak: false, kind, text: '', summary };
193
+ return { speak: true, kind, text, summary };
174
194
  } catch {
175
195
  return null;
176
196
  }
@@ -20,16 +20,18 @@ export function toolCallPayload(event) {
20
20
  * Combina tools locales + admin + subagentes en un toolset unificado.
21
21
  * `approve` es el callback de aprobacion de las tools de administraación.
22
22
  */
23
- export function combineToolset({ local, admin = null, tasks = null, exposeAdmin = false, approve = null }) {
23
+ export function combineToolset({ local, admin = null, tasks = null, plugins = null, exposeAdmin = false, approve = null }) {
24
24
  const adminAllowed = (name) => Boolean(admin?.handles(name)) && (exposeAdmin || admin.readOnly?.has(name));
25
25
  const definitions = [
26
26
  ...local.definitions,
27
27
  ...(admin ? admin.definitions.filter((d) => adminAllowed(d.function.name)) : []),
28
28
  ...(tasks ? tasks.definitions : []),
29
+ ...(plugins ? plugins.definitions : []),
29
30
  ];
30
31
  const execute = (name, args, context = {}) => {
31
32
  if (tasks?.handles(name)) return tasks.execute(name, args);
32
33
  if (adminAllowed(name)) return admin.execute(name, args, { approve, ...context });
34
+ if (plugins?.handles(name)) return plugins.execute(name, args, { approve, ...context });
33
35
  return local.execute(name, args, context);
34
36
  };
35
37
  return { definitions, execute };
@@ -477,7 +477,13 @@ export function startTelegramChannel(
477
477
  });
478
478
  const exposeAdmin = !mode.tools;
479
479
  const admin = ctx.agentTools;
480
- const { definitions, execute } = combineToolset({ local, admin, exposeAdmin, approve: onAsk });
480
+ const { definitions, execute } = combineToolset({
481
+ local,
482
+ admin,
483
+ plugins: exposeAdmin ? ctx.plugins : null,
484
+ exposeAdmin,
485
+ approve: onAsk,
486
+ });
481
487
  const { provider, config } = resolveProviderFor(ctx, providerId);
482
488
  const userMessage = buildUserMessage(input, images);
483
489
  const messages = await composeAgentMessages({ ctx, cwd, mode, history, userMessage });
@@ -52,8 +52,10 @@ export async function summarizeSession(ctx, session, instruction) {
52
52
  if (!messages.length) return { error: 'La sesión no tiene mensajes para resumir.', status: 400 };
53
53
  const resolved = providerFor(ctx, session);
54
54
  if (resolved.error) return resolved;
55
+ ctx.hooks?.dispatch({ type: 'session.compacting', sessionId: session.id, messages: messages.length });
55
56
  const summary = await summarize(ctx, session, messages, instruction);
56
57
  if (!summary) return { error: 'El modelo no devolvió un resumen.', status: 502 };
58
+ ctx.hooks?.dispatch({ type: 'session.compacted', sessionId: session.id, summary });
57
59
  return { summary };
58
60
  }
59
61
 
@@ -70,8 +72,10 @@ export async function maybeAutoCompact(ctx, session, history, { contextTokens }
70
72
  const live = liveHistory(history, session.meta?.summaryCursor ?? null);
71
73
  const plan = planCompaction(live, { contextTokens, threshold, keepLast });
72
74
  if (!plan) return null;
75
+ ctx.hooks?.dispatch({ type: 'session.compacting', sessionId: session.id, auto: true, messages: plan.toSummarize.length });
73
76
  const summary = await summarize(ctx, session, plan.toSummarize, COMPACT_INSTRUCTION, session.meta?.summary);
74
77
  if (!summary) return null;
75
78
  ctx.store.touchSession(session.id, { meta: { summary, summaryCursor: plan.cursor } });
79
+ ctx.hooks?.dispatch({ type: 'session.compacted', sessionId: session.id, auto: true, summary });
76
80
  return { summary, cursor: plan.cursor, summarized: plan.toSummarize.length };
77
81
  }
@@ -0,0 +1,45 @@
1
+ // Runner de hooks: carga los modulos (`core/src/hooks`) y despacha eventos a
2
+ // sus handlers de forma aislada (un hook que tira no rompe el run) y con los
3
+ // datos redactados. Es opt-in: sin archivos de hooks, no se ejecuta codigo.
4
+
5
+ import { loadHooks, redactDeep } from './core.js';
6
+
7
+ /**
8
+ * Crea el runner. `dispatch(event)` llama a los handlers suscritos a
9
+ * `event.type` (y a los wildcard `*`); no devuelve valor (observadores).
10
+ */
11
+ export async function createHookRunner(ctx, patterns = []) {
12
+ let hooks = [];
13
+ try {
14
+ hooks = await loadHooks(ctx.cwd, patterns);
15
+ } catch {
16
+ hooks = [];
17
+ }
18
+ const handlers = new Map();
19
+ for (const hook of hooks) {
20
+ for (const [event, fn] of Object.entries(hook.events)) {
21
+ if (typeof fn !== 'function') continue;
22
+ const list = handlers.get(event) ?? [];
23
+ list.push(fn);
24
+ handlers.set(event, list);
25
+ }
26
+ }
27
+
28
+ function dispatch(event) {
29
+ const list = [...(handlers.get(event.type) ?? []), ...(handlers.get('*') ?? [])];
30
+ if (!list.length) return;
31
+ const safe = redactDeep(event);
32
+ for (const fn of list) {
33
+ Promise.resolve()
34
+ .then(() => fn(safe, ctx))
35
+ .catch(() => {
36
+ // un hook caido no debe frenar el run
37
+ });
38
+ }
39
+ }
40
+
41
+ const view = () =>
42
+ hooks.map((hook) => ({ id: hook.id, path: hook.path, events: Object.keys(hook.events).filter((e) => e !== '*') }));
43
+
44
+ return { dispatch, count: hooks.length, list: view };
45
+ }
@@ -0,0 +1,76 @@
1
+ // Plugins: agregan herramientas propias al agente (`.tamnora/plugins/*.mjs` o
2
+ // las rutas de `config.plugins`). Corren codigo del usuario, asi que pasan por
3
+ // permisos (default `ask`).
4
+
5
+ import { loadPlugins } from './core.js';
6
+
7
+ function normalizeName(value) {
8
+ return String(value ?? '')
9
+ .replace(/[^a-z0-9_-]+/gi, '_')
10
+ .replace(/^_+|_+$/g, '')
11
+ .slice(0, 64);
12
+ }
13
+
14
+ /** Crea el toolset de plugins. `execute(name, args, { approve })`. */
15
+ export async function createPlugins(ctx, patterns = []) {
16
+ let plugins = [];
17
+ try {
18
+ plugins = await loadPlugins(ctx.cwd, patterns);
19
+ } catch {
20
+ plugins = [];
21
+ }
22
+
23
+ const tools = [];
24
+ const used = new Set();
25
+ for (const plugin of plugins) {
26
+ for (const tool of plugin.tools) {
27
+ const name = `plugin__${normalizeName(plugin.id)}__${normalizeName(tool.name)}`;
28
+ if (!name || used.has(name)) continue;
29
+ used.add(name);
30
+ tools.push({
31
+ name,
32
+ source: tool.name,
33
+ plugin: plugin.id,
34
+ description: String(tool.description || tool.name),
35
+ parameters: tool.parameters && typeof tool.parameters === 'object' ? tool.parameters : {},
36
+ required: Array.isArray(tool.required) ? tool.required : [],
37
+ handler: tool.handler,
38
+ });
39
+ }
40
+ }
41
+
42
+ const byName = new Map(tools.map((tool) => [tool.name, tool]));
43
+ const definitions = tools.map((tool) => ({
44
+ type: 'function',
45
+ function: {
46
+ name: tool.name,
47
+ description: tool.description,
48
+ parameters: { type: 'object', properties: tool.parameters, required: tool.required },
49
+ },
50
+ }));
51
+
52
+ async function execute(name, args = {}, { approve } = {}) {
53
+ const tool = byName.get(name);
54
+ if (!tool) return `Herramienta desconocida: ${name}`;
55
+ const decision = (ctx.config.permissions ?? {})[tool.name] ?? 'ask';
56
+ if (decision === 'deny') return `Permiso denegado para "${name}".`;
57
+ if (decision === 'ask') {
58
+ const ok = approve ? await approve(name, args) : false;
59
+ if (!ok) return `Permiso denegado para "${name}".`;
60
+ }
61
+ try {
62
+ const out = await tool.handler(args, ctx);
63
+ return typeof out === 'string' ? out : JSON.stringify(out ?? null, null, 2);
64
+ } catch (error) {
65
+ return `Error en ${name}: ${error?.message ?? error}`;
66
+ }
67
+ }
68
+
69
+ return {
70
+ definitions,
71
+ handles: (name) => byName.has(name),
72
+ execute,
73
+ count: tools.length,
74
+ list: () => tools.map(({ name, source, plugin, description }) => ({ name, source, plugin, description })),
75
+ };
76
+ }
@@ -68,7 +68,14 @@ async function runAgentSchedule(ctx, schedule) {
68
68
  exclude: admin.definitions.map((d) => d.function.name),
69
69
  emit,
70
70
  });
71
- const { definitions, execute } = combineToolset({ local, admin, tasks, exposeAdmin, approve: async () => true });
71
+ const { definitions, execute } = combineToolset({
72
+ local,
73
+ admin,
74
+ tasks,
75
+ plugins: exposeAdmin ? ctx.plugins : null,
76
+ exposeAdmin,
77
+ approve: async () => true,
78
+ });
72
79
  const { provider, config } = resolveProviderFor(ctx, providerId);
73
80
 
74
81
  try {
@@ -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,7 +19,10 @@ 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);
24
+ if ('provider' in body) next.provider = String(body.provider ?? '').trim().slice(0, 60);
25
+ if ('model' in body) next.model = String(body.model ?? '').trim().slice(0, 120);
23
26
  if ('maxWords' in body) {
24
27
  const value = Number(body.maxWords);
25
28
  next.maxWords = Number.isFinite(value)
@@ -34,8 +37,8 @@ export function registerAdvisor(route, ctx) {
34
37
 
35
38
  route('POST', '/api/advisor/test', async ({ res }) => {
36
39
  const options = resolveAdvisor(ctx.app);
37
- const providerId = ctx.effectiveProviderId();
38
- const model = ctx.effectiveModel();
40
+ const providerId = options.provider || ctx.effectiveProviderId();
41
+ const model = options.model || ctx.effectiveModel();
39
42
  if (!model) return sendError(res, 400, 'Elegí un proveedor y un modelo primero.');
40
43
  const { provider, config } = ctx.resolveProvider({
41
44
  ...ctx.providerSettings(providerId),
@@ -221,11 +221,18 @@ export function registerChat(route, ctx) {
221
221
  permissions: ctx.config.permissions,
222
222
  mcp,
223
223
  signal: ctx.runs.signal(sessionId),
224
- exclude: admin.definitions.map((d) => d.function.name),
224
+ exclude: [...admin.definitions.map((d) => d.function.name), ...(ctx.plugins?.list?.().map((t) => t.name) ?? [])],
225
225
  emit: emitAgent,
226
226
  });
227
227
  const approve = autoApprove ? async () => true : (name, args) => onAsk(name, args);
228
- const { definitions, execute } = combineToolset({ local, admin, tasks, exposeAdmin, approve });
228
+ const { definitions, execute } = combineToolset({
229
+ local,
230
+ admin,
231
+ tasks,
232
+ plugins: exposeAdmin ? ctx.plugins : null,
233
+ exposeAdmin,
234
+ approve,
235
+ });
229
236
 
230
237
  const { provider, config } = resolveProviderFor(ctx, providerId, {
231
238
  baseUrl: body.baseUrl || ctx.providerSettings(providerId).baseUrl,
@@ -261,6 +268,7 @@ export function registerChat(route, ctx) {
261
268
  const onLifecycle = combineLifecycle([
262
269
  createRulesInjector({ cwd: sessionCwd, patterns: ctx.config.rules ?? [] }),
263
270
  createCheckpointer(ctx, { sessionId, cwd: sessionCwd, turnId: userMessageId }),
271
+ ctx.hooks ? (event) => ctx.hooks.dispatch(event) : null,
264
272
  ]);
265
273
 
266
274
  (async () => {
@@ -356,10 +364,20 @@ export function registerChat(route, ctx) {
356
364
  advisorOptions.enabled &&
357
365
  advisorShouldRun({ toolCalls, answer: result.content })
358
366
  ) {
367
+ // El asesor puede tener proveedor/modelo propios; si no, usa los activos.
368
+ let advisorProvider = provider;
369
+ let advisorConfig = config;
370
+ let advisorModel = model;
371
+ if (advisorOptions.provider || advisorOptions.model) {
372
+ const resolved = resolveProviderFor(ctx, advisorOptions.provider || providerId);
373
+ advisorProvider = resolved.provider;
374
+ advisorConfig = resolved.config;
375
+ advisorModel = advisorOptions.model || model;
376
+ }
359
377
  const advice = await consultAdvisor({
360
- provider,
361
- config,
362
- model,
378
+ provider: advisorProvider,
379
+ config: advisorConfig,
380
+ model: advisorModel,
363
381
  signal: ctx.runs.signal(sessionId),
364
382
  userInput: input,
365
383
  answer: result.content,
@@ -374,6 +392,23 @@ export function registerChat(route, ctx) {
374
392
  mode: advice.kind,
375
393
  });
376
394
  ctx.runs.emit(sessionId, 'advice', { text: advice.text, kind: advice.kind, say: advice.text });
395
+ ctx.hooks?.dispatch({ type: 'advice', sessionId, text: advice.text, kind: advice.kind });
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' });
377
412
  }
378
413
  }
379
414
  } catch (error) {
@@ -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;
@@ -53,6 +53,8 @@ import { registerPresence } from './routes/presence.js';
53
53
  import { registerEval } from './routes/eval.js';
54
54
  import { registerSchedules } from './routes/schedules.js';
55
55
  import { createScheduler } from './lib/scheduler.js';
56
+ import { createHookRunner } from './lib/hooks.js';
57
+ import { createPlugins } from './lib/plugins.js';
56
58
 
57
59
  const VERSION = readPackageVersion();
58
60
 
@@ -217,6 +219,12 @@ export async function createTamnoraServer({
217
219
  ctx.systemPrompt = (await ctx.systemPromptFor(cwd)) || DEFAULT_SYSTEM_PROMPT;
218
220
  ctx.agentTools = createAgentTools(ctx);
219
221
 
222
+ // Hooks locales (opt-in: solo si hay archivos). Observan el ciclo de vida.
223
+ ctx.hooks = await createHookRunner(ctx, config.hooks ?? []);
224
+
225
+ // Plugins locales: tools propias del usuario (`.tamnora/plugins/*.mjs`).
226
+ ctx.plugins = await createPlugins(ctx, config.plugins ?? []);
227
+
220
228
  // Canales de mensajeria (Telegram via long polling) si hay token.
221
229
  const channels = createChannelsManager(ctx, {
222
230
  enabled: telegram,
@@ -270,6 +278,14 @@ export async function createTamnoraServer({
270
278
  registerEval(route, ctx);
271
279
  registerSchedules(route, ctx);
272
280
 
281
+ route('GET', '/api/hooks', ({ res }) => {
282
+ sendJson(res, 200, { hooks: ctx.hooks?.list?.() ?? [] });
283
+ });
284
+
285
+ route('GET', '/api/plugins', ({ res }) => {
286
+ sendJson(res, 200, { plugins: ctx.plugins?.list?.() ?? [] });
287
+ });
288
+
273
289
  function matchRoute(method, pathname) {
274
290
  const parts = pathname.split('/').filter(Boolean);
275
291
  for (const r of routes) {
@@ -25,15 +25,17 @@ const editMessages = () => [
25
25
  { role: 'tool', tool_call_id: 'c1', name: 'edit', content: '- const token = "secreto"\n+ const token = process.env.TOKEN' },
26
26
  ];
27
27
 
28
- test('resolveAdvisor mezcla defaults y sanitiza', () => {
29
- assert.equal(resolveAdvisor({}).enabled, false);
30
- const saved = resolveAdvisor({ advisor: { enabled: true, notes: 'x', maxWords: 999 } });
31
- assert.equal(saved.enabled, true);
32
- assert.equal(saved.code, true);
33
- assert.equal(saved.maxWords, 120);
34
- const off = resolveAdvisor({ advisor: { code: false, explain: false, advice: false, maxWords: 5 } });
35
- assert.equal(off.maxWords, 10);
36
- assert.equal(off.code, false);
28
+ test('resolveAdvisor mezcla defaults y sanitiza', () => {
29
+ assert.equal(resolveAdvisor({}).enabled, false);
30
+ assert.equal(resolveAdvisor({}).summary, false);
31
+ const saved = resolveAdvisor({ advisor: { enabled: true, notes: 'x', maxWords: 999, summary: true } });
32
+ assert.equal(saved.enabled, true);
33
+ assert.equal(saved.code, true);
34
+ assert.equal(saved.summary, true);
35
+ assert.equal(saved.maxWords, 120);
36
+ const off = resolveAdvisor({ advisor: { code: false, explain: false, advice: false, maxWords: 5 } });
37
+ assert.equal(off.maxWords, 10);
38
+ assert.equal(off.code, false);
37
39
  });
38
40
 
39
41
  test('advisorShouldRun exige tools o respuesta larga', () => {
@@ -55,19 +57,30 @@ test('collectToolSummary lista las tools del turno', () => {
55
57
  assert.match(summary[0], /^edit:/);
56
58
  });
57
59
 
58
- test('buildAdvisorPrompt respeta los toggles y las notas', () => {
59
- const { system, user } = buildAdvisorPrompt({
60
- userInput: 'hola',
61
- answer: 'listo',
62
- toolSummary: ['edit: src/a.js'],
63
- changedFiles: [{ path: 'src/a.js', diff: '- x\n+ y' }],
64
- options: { code: false, explain: true, advice: true, notes: 'avisame de secretos', maxWords: 20 },
65
- });
66
- assert.doesNotMatch(system, /problema real en el codigo/);
67
- assert.match(system, /explicar un proceso/);
68
- assert.match(system, /avisame de secretos/);
69
- assert.match(user, /PEDIDO DEL USUARIO/);
70
- assert.match(user, /src\/a\.js/);
60
+ test('buildAdvisorPrompt respeta los toggles y las notas', () => {
61
+ const { system, user } = buildAdvisorPrompt({
62
+ userInput: 'hola',
63
+ answer: 'listo',
64
+ toolSummary: ['edit: src/a.js'],
65
+ changedFiles: [{ path: 'src/a.js', diff: '- x\n+ y' }],
66
+ options: { code: false, explain: true, advice: true, notes: 'avisame de secretos', maxWords: 20 },
67
+ });
68
+ assert.doesNotMatch(system, /problema real en el codigo/);
69
+ assert.match(system, /explicar un proceso/);
70
+ assert.match(system, /avisame de secretos/);
71
+ assert.match(user, /PEDIDO DEL USUARIO/);
72
+ assert.match(user, /src\/a\.js/);
73
+ });
74
+
75
+ test('buildAdvisorPrompt pide el resumen cuando options.summary', () => {
76
+ const { system } = buildAdvisorPrompt({
77
+ userInput: 'hola',
78
+ answer: 'listo',
79
+ options: { summary: true, code: false, explain: false, advice: false, maxWords: 20 },
80
+ });
81
+ assert.match(system, /resumen breve/);
82
+ assert.match(system, /"summary"/);
83
+ assert.doesNotMatch(system, /Tu trabajo NO es resumir/);
71
84
  });
72
85
 
73
86
  const fakeProvider = (content) => ({ streamChat: async () => ({ content }) });
@@ -80,9 +93,29 @@ test('consultAdvisor interpreta speak:true', async () => {
80
93
  messages: editMessages(),
81
94
  options: { code: true, explain: true, advice: true, maxWords: 40 },
82
95
  });
83
- assert.equal(advice.speak, true);
84
- assert.equal(advice.kind, 'code');
85
- assert.match(advice.text, /token/);
96
+ assert.equal(advice.speak, true);
97
+ assert.equal(advice.kind, 'code');
98
+ assert.match(advice.text, /token/);
99
+ });
100
+
101
+ test('consultAdvisor devuelve el resumen aunque no hable', async () => {
102
+ const advice = await consultAdvisor({
103
+ provider: fakeProvider('{"speak":false,"text":"","summary":"Revisaste el token y lo pasaste a env."}'),
104
+ config: {},
105
+ model: 'x',
106
+ messages: editMessages(),
107
+ options: { summary: true, code: false, explain: false, advice: false, maxWords: 40 },
108
+ });
109
+ assert.equal(advice.speak, false);
110
+ assert.match(advice.summary, /Revisaste el token/);
111
+ const ignored = await consultAdvisor({
112
+ provider: fakeProvider('{"speak":false,"text":"","summary":"algo"}'),
113
+ config: {},
114
+ model: 'x',
115
+ messages: [],
116
+ options: { summary: false, code: true, explain: true, advice: true },
117
+ });
118
+ assert.equal(ignored.summary, '');
86
119
  });
87
120
 
88
121
  test('consultAdvisor respeta speak:false, JSON invalido y errores', async () => {
@@ -138,14 +171,15 @@ test('GET/PUT /api/advisor/settings y POST /api/advisor/test con proveedor inyec
138
171
  const initial = await (await fetch(`${base}/api/advisor/settings`)).json();
139
172
  assert.equal(initial.enabled, false);
140
173
 
141
- const put = await fetch(`${base}/api/advisor/settings`, {
142
- method: 'PUT',
143
- headers: { 'Content-Type': 'application/json' },
144
- body: JSON.stringify({ enabled: true, code: false, maxWords: 500 }),
145
- });
146
- const saved = await put.json();
147
- assert.equal(saved.enabled, true);
148
- assert.equal(saved.code, false);
174
+ const put = await fetch(`${base}/api/advisor/settings`, {
175
+ method: 'PUT',
176
+ headers: { 'Content-Type': 'application/json' },
177
+ body: JSON.stringify({ enabled: true, code: false, summary: true, maxWords: 500 }),
178
+ });
179
+ const saved = await put.json();
180
+ assert.equal(saved.enabled, true);
181
+ assert.equal(saved.code, false);
182
+ assert.equal(saved.summary, true);
149
183
  assert.equal(saved.maxWords, 120);
150
184
 
151
185
  const on = await (await fetch(`${base}/api/advisor/test`, { method: 'POST' })).json();
@@ -173,33 +207,39 @@ test('el asesor emite el evento advice al cerrar el turno', async () => {
173
207
  onEvent?.({ type: 'text', delta: text, full: text });
174
208
  return { content: text, reasoning: '', toolCalls: [] };
175
209
  }
176
- return { content: '{"speak":true,"kind":"code","text":"Cuidado con el token."}', reasoning: '', toolCalls: [] };
177
- },
178
- };
179
- const instance = await createTamnoraServer({
180
- cwd,
181
- dbPath: path.join(cwd, 'tamnora.db'),
182
- loadMcp: false,
183
- resolveProvider: () => ({ provider, config: {} }),
184
- });
185
- instance.ctx.app = {
186
- ...instance.ctx.app,
187
- advisor: { enabled: true, code: true, explain: true, advice: true, notes: '', maxWords: 40 },
188
- };
189
- instance.ctx.store.setSetting('activeProvider', 'fake');
190
- instance.ctx.store.setSetting('activeModel', 'test-model');
191
- const info = await instance.listen({ port: 0 });
192
- try {
193
- const res = await fetch(`${info.url}/api/chat`, {
194
- method: 'POST',
195
- headers: { 'Content-Type': 'application/json' },
196
- body: JSON.stringify({ input: 'quién soy', model: 'test-model', provider: 'fake' }),
197
- });
198
- const body = await res.text();
199
- assert.equal(calls, 2);
200
- assert.match(body, /event: advice/);
201
- assert.match(body, /Cuidado con el token/);
202
- } finally {
203
- await instance.close();
204
- }
205
- });
210
+ return {
211
+ content: '{"speak":true,"kind":"code","text":"Cuidado con el token.","summary":"Revisaste la sesión."}',
212
+ reasoning: '',
213
+ toolCalls: [],
214
+ };
215
+ },
216
+ };
217
+ const instance = await createTamnoraServer({
218
+ cwd,
219
+ dbPath: path.join(cwd, 'tamnora.db'),
220
+ loadMcp: false,
221
+ resolveProvider: () => ({ provider, config: {} }),
222
+ });
223
+ instance.ctx.app = {
224
+ ...instance.ctx.app,
225
+ advisor: { enabled: true, code: true, explain: true, advice: true, summary: true, notes: '', maxWords: 40 },
226
+ };
227
+ instance.ctx.store.setSetting('activeProvider', 'fake');
228
+ instance.ctx.store.setSetting('activeModel', 'test-model');
229
+ const info = await instance.listen({ port: 0 });
230
+ try {
231
+ const res = await fetch(`${info.url}/api/chat`, {
232
+ method: 'POST',
233
+ headers: { 'Content-Type': 'application/json' },
234
+ body: JSON.stringify({ input: 'quién soy', model: 'test-model', provider: 'fake' }),
235
+ });
236
+ const body = await res.text();
237
+ assert.equal(calls, 2);
238
+ assert.match(body, /event: advice/);
239
+ assert.match(body, /Cuidado con el token/);
240
+ assert.match(body, /"kind":"summary"/);
241
+ assert.match(body, /Revisaste la sesión/);
242
+ } finally {
243
+ await instance.close();
244
+ }
245
+ });