@danieltmn/tamnora 0.14.0 → 0.15.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 CHANGED
@@ -5,6 +5,21 @@ 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.15.0] - 2026-10-10
9
+
10
+ ### Agregado
11
+
12
+ - **Fase 4 del roadmap del server (plataforma)**:
13
+ - **Hooks locales**: módulos `.mjs` en `.tamnora/hooks/*.mjs` (o `hooks[]` en
14
+ `tamnora.json`) que observan el ciclo de vida (`run.start/end`,
15
+ `tool.before/after`, `session.compacting/compacted`, `advice`). Aislados de
16
+ errores, con datos redactados, opt-in.
17
+ - **Plugins**: módulos `.mjs` en `.tamnora/plugins/*.mjs` (o `plugins[]`) que
18
+ agregan **tools propias** al agente (`plugin__<plugin>__<tool>`), sujetas a
19
+ permisos (default `ask`).
20
+ - **Asesor con proveedor/modelo propios**: `app.json -> advisor`
21
+ `provider`/`model`; si están vacíos usa los activos.
22
+
8
23
  ## [0.14.0] - 2026-10-10
9
24
 
10
25
  ### Agregado
@@ -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
+ });
@@ -3754,6 +3754,8 @@ const asesorExplainEl = $('#asesor-explain');
3754
3754
  const asesorAdviceEl = $('#asesor-advice');
3755
3755
  const asesorWordsEl = $('#asesor-words');
3756
3756
  const asesorNotesEl = $('#asesor-notes');
3757
+ const asesorProviderEl = $('#asesor-provider');
3758
+ const asesorModelEl = $('#asesor-model');
3757
3759
  const trProviderEl = $('#tr-provider');
3758
3760
  const trModelEl = $('#tr-model');
3759
3761
 
@@ -4734,6 +4736,8 @@ function aplicarAsesor(data) {
4734
4736
  if (asesorAdviceEl) asesorAdviceEl.checked = data.advice !== false;
4735
4737
  if (asesorWordsEl) asesorWordsEl.value = data.maxWords ?? 40;
4736
4738
  if (asesorNotesEl) asesorNotesEl.value = data.notes ?? '';
4739
+ if (asesorProviderEl) asesorProviderEl.value = data.provider ?? '';
4740
+ if (asesorModelEl) asesorModelEl.value = data.model ?? '';
4737
4741
  const estado = $('#asesor-status');
4738
4742
  if (estado) {
4739
4743
  estado.className = `summary${data.enabled ? ' ok' : ''}`;
@@ -4755,6 +4759,8 @@ function asesorBody() {
4755
4759
  advice: asesorAdviceEl?.checked !== false,
4756
4760
  maxWords: Number(asesorWordsEl?.value) || 40,
4757
4761
  notes: asesorNotesEl?.value ?? '',
4762
+ provider: asesorProviderEl?.value ?? '',
4763
+ model: asesorModelEl?.value ?? '',
4758
4764
  };
4759
4765
  }
4760
4766
 
@@ -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.15.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>
@@ -737,8 +737,16 @@
737
737
  <p class="muted verify-line">
738
738
  Un asesor que observa lo que el agente hace, lo interpreta y solo habla cuando tiene
739
739
  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.
740
+ un consejo. No lee la respuesta del agente. Usa el proveedor y modelo activos, o unos propios.
741
741
  </p>
742
+ <div class="row">
743
+ <label class="field"><span>proveedor (opcional)</span>
744
+ <input id="asesor-provider" spellcheck="false" placeholder="por defecto: el activo" />
745
+ </label>
746
+ <label class="field grow"><span>modelo (opcional)</span>
747
+ <input id="asesor-model" spellcheck="false" placeholder="por defecto: el activo" />
748
+ </label>
749
+ </div>
742
750
  <label class="check" title="tener un asesor que interprete y comente el turno">
743
751
  <input id="asesor-enabled" type="checkbox" /> Habilitar el asesor
744
752
  </label>
@@ -15,6 +15,8 @@ export const DEFAULT_ADVISOR = {
15
15
  advice: true,
16
16
  notes: '',
17
17
  maxWords: 40,
18
+ provider: '',
19
+ model: '',
18
20
  };
19
21
 
20
22
  const KINDS = new Set(['code', 'explain', 'advice']);
@@ -34,6 +36,8 @@ export function resolveAdvisor(app) {
34
36
  maxWords: Number.isFinite(maxWords)
35
37
  ? Math.min(Math.max(Math.round(maxWords), 10), 120)
36
38
  : DEFAULT_ADVISOR.maxWords,
39
+ provider: typeof saved.provider === 'string' ? saved.provider.trim().slice(0, 60) : '',
40
+ model: typeof saved.model === 'string' ? saved.model.trim().slice(0, 120) : '',
37
41
  };
38
42
  }
39
43
 
@@ -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 {
@@ -20,6 +20,8 @@ export function registerAdvisor(route, ctx) {
20
20
  if ('explain' in body) next.explain = body.explain !== false;
21
21
  if ('advice' in body) next.advice = body.advice !== false;
22
22
  if ('notes' in body) next.notes = String(body.notes ?? '').slice(0, 2000);
23
+ if ('provider' in body) next.provider = String(body.provider ?? '').trim().slice(0, 60);
24
+ if ('model' in body) next.model = String(body.model ?? '').trim().slice(0, 120);
23
25
  if ('maxWords' in body) {
24
26
  const value = Number(body.maxWords);
25
27
  next.maxWords = Number.isFinite(value)
@@ -34,8 +36,8 @@ export function registerAdvisor(route, ctx) {
34
36
 
35
37
  route('POST', '/api/advisor/test', async ({ res }) => {
36
38
  const options = resolveAdvisor(ctx.app);
37
- const providerId = ctx.effectiveProviderId();
38
- const model = ctx.effectiveModel();
39
+ const providerId = options.provider || ctx.effectiveProviderId();
40
+ const model = options.model || ctx.effectiveModel();
39
41
  if (!model) return sendError(res, 400, 'Elegí un proveedor y un modelo primero.');
40
42
  const { provider, config } = ctx.resolveProvider({
41
43
  ...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,7 @@ 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 });
377
396
  }
378
397
  }
379
398
  } catch (error) {
@@ -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) {
@@ -1356,6 +1356,72 @@ test('rewind restores files and truncates the turn', async () => {
1356
1356
  }
1357
1357
  });
1358
1358
 
1359
+ test('hooks observe tool events and plugins add custom tools', async () => {
1360
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-hooks-plugins-'));
1361
+ await mkdir(path.join(cwd, '.tamnora', 'hooks'), { recursive: true });
1362
+ await mkdir(path.join(cwd, '.tamnora', 'plugins'), { recursive: true });
1363
+ await writeFile(
1364
+ path.join(cwd, '.tamnora', 'hooks', 'audit.mjs'),
1365
+ "import { appendFileSync } from 'node:fs';\nimport path from 'node:path';\n" +
1366
+ "export default { name: 'audit', events: { 'tool.after': (event, ctx) => { appendFileSync(path.join(ctx.cwd, 'hook.log'), `${event.type}:${event.name}\\n`); } } };\n"
1367
+ );
1368
+ await writeFile(
1369
+ path.join(cwd, '.tamnora', 'plugins', 'demo.mjs'),
1370
+ "export default { name: 'demo', tools: [{ name: 'saludar', description: 'saluda', parameters: { quien: { type: 'string' } }, handler: (args) => `Hola ${args.quien}` }] };\n"
1371
+ );
1372
+ await writeFile(path.join(cwd, 'tamnora.json'), JSON.stringify({ permissions: { plugin__demo__saludar: 'allow' } }));
1373
+
1374
+ let captured = null;
1375
+ const provider = {
1376
+ id: 'hp',
1377
+ async streamChat(_config, { messages }) {
1378
+ const toolMsg = messages.find((m) => m.role === 'tool' && String(m.name).startsWith('plugin__'));
1379
+ if (toolMsg) {
1380
+ captured = toolMsg.content;
1381
+ return { content: 'ok', reasoning: '', toolCalls: [] };
1382
+ }
1383
+ return {
1384
+ content: '',
1385
+ reasoning: '',
1386
+ toolCalls: [
1387
+ {
1388
+ id: 't1',
1389
+ type: 'function',
1390
+ function: { name: 'plugin__demo__saludar', arguments: JSON.stringify({ quien: 'mundo' }) },
1391
+ },
1392
+ ],
1393
+ };
1394
+ },
1395
+ };
1396
+ const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
1397
+ const json = { 'Content-Type': 'application/json' };
1398
+ try {
1399
+ const hooks = await (await fetch(`${base}/api/hooks`)).json();
1400
+ assert.ok(hooks.hooks.some((h) => h.id === 'audit'));
1401
+
1402
+ const plugins = await (await fetch(`${base}/api/plugins`)).json();
1403
+ assert.ok(plugins.plugins.some((p) => p.name === 'plugin__demo__saludar'));
1404
+
1405
+ const created = await (
1406
+ await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ title: 't', model: 'm', cwd }) })
1407
+ ).json();
1408
+ await (
1409
+ await fetch(`${base}/api/chat`, {
1410
+ method: 'POST',
1411
+ headers: json,
1412
+ body: JSON.stringify({ sessionId: created.session.id, model: 'm', input: 'saludá' }),
1413
+ })
1414
+ ).text();
1415
+ assert.equal(captured, 'Hola mundo');
1416
+
1417
+ await new Promise((resolve) => setTimeout(resolve, 150));
1418
+ const log = await readFile(path.join(cwd, 'hook.log'), 'utf8').catch(() => '');
1419
+ assert.match(log, /tool\.after:plugin__demo__saludar/);
1420
+ } finally {
1421
+ await instance.close();
1422
+ }
1423
+ });
1424
+
1359
1425
  test('skills: modo eager inyecta el body en el system prompt', async () => {
1360
1426
  let captured = '';
1361
1427
  const captureProvider = {
@@ -11,6 +11,8 @@
11
11
  "compact": { "enabled": true, "threshold": 0.75, "keepLast": 8 },
12
12
  "rules": [".tamnora/rules/*.md"],
13
13
  "memory": { "enabled": true },
14
+ "hooks": [],
15
+ "plugins": [],
14
16
  "tools": [],
15
17
  "permissions": {},
16
18
  "mcp": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danieltmn/tamnora",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Harness local para IAs: agente CLI-first con TUI, consola web y evaluacion. Node puro, sin dependencias.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.25.0",