@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
|
@@ -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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
173
|
-
return { speak:
|
|
191
|
+
const summary = options.summary === true ? truncateWords(value.summary, maxWords) : '';
|
|
192
|
+
if (value.speak !== true || !text) return { speak: false, kind, text: '', summary };
|
|
193
|
+
return { speak: true, kind, text, summary };
|
|
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({
|
|
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({
|
|
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({
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
assert.equal(saved.
|
|
33
|
-
assert.equal(saved.
|
|
34
|
-
|
|
35
|
-
assert.equal(
|
|
36
|
-
|
|
28
|
+
test('resolveAdvisor mezcla defaults y sanitiza', () => {
|
|
29
|
+
assert.equal(resolveAdvisor({}).enabled, false);
|
|
30
|
+
assert.equal(resolveAdvisor({}).summary, false);
|
|
31
|
+
const saved = resolveAdvisor({ advisor: { enabled: true, notes: 'x', maxWords: 999, summary: true } });
|
|
32
|
+
assert.equal(saved.enabled, true);
|
|
33
|
+
assert.equal(saved.code, true);
|
|
34
|
+
assert.equal(saved.summary, true);
|
|
35
|
+
assert.equal(saved.maxWords, 120);
|
|
36
|
+
const off = resolveAdvisor({ advisor: { code: false, explain: false, advice: false, maxWords: 5 } });
|
|
37
|
+
assert.equal(off.maxWords, 10);
|
|
38
|
+
assert.equal(off.code, false);
|
|
37
39
|
});
|
|
38
40
|
|
|
39
41
|
test('advisorShouldRun exige tools o respuesta larga', () => {
|
|
@@ -55,19 +57,30 @@ test('collectToolSummary lista las tools del turno', () => {
|
|
|
55
57
|
assert.match(summary[0], /^edit:/);
|
|
56
58
|
});
|
|
57
59
|
|
|
58
|
-
test('buildAdvisorPrompt respeta los toggles y las notas', () => {
|
|
59
|
-
const { system, user } = buildAdvisorPrompt({
|
|
60
|
-
userInput: 'hola',
|
|
61
|
-
answer: 'listo',
|
|
62
|
-
toolSummary: ['edit: src/a.js'],
|
|
63
|
-
changedFiles: [{ path: 'src/a.js', diff: '- x\n+ y' }],
|
|
64
|
-
options: { code: false, explain: true, advice: true, notes: 'avisame de secretos', maxWords: 20 },
|
|
65
|
-
});
|
|
66
|
-
assert.doesNotMatch(system, /problema real en el codigo/);
|
|
67
|
-
assert.match(system, /explicar un proceso/);
|
|
68
|
-
assert.match(system, /avisame de secretos/);
|
|
69
|
-
assert.match(user, /PEDIDO DEL USUARIO/);
|
|
70
|
-
assert.match(user, /src\/a\.js/);
|
|
60
|
+
test('buildAdvisorPrompt respeta los toggles y las notas', () => {
|
|
61
|
+
const { system, user } = buildAdvisorPrompt({
|
|
62
|
+
userInput: 'hola',
|
|
63
|
+
answer: 'listo',
|
|
64
|
+
toolSummary: ['edit: src/a.js'],
|
|
65
|
+
changedFiles: [{ path: 'src/a.js', diff: '- x\n+ y' }],
|
|
66
|
+
options: { code: false, explain: true, advice: true, notes: 'avisame de secretos', maxWords: 20 },
|
|
67
|
+
});
|
|
68
|
+
assert.doesNotMatch(system, /problema real en el codigo/);
|
|
69
|
+
assert.match(system, /explicar un proceso/);
|
|
70
|
+
assert.match(system, /avisame de secretos/);
|
|
71
|
+
assert.match(user, /PEDIDO DEL USUARIO/);
|
|
72
|
+
assert.match(user, /src\/a\.js/);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('buildAdvisorPrompt pide el resumen cuando options.summary', () => {
|
|
76
|
+
const { system } = buildAdvisorPrompt({
|
|
77
|
+
userInput: 'hola',
|
|
78
|
+
answer: 'listo',
|
|
79
|
+
options: { summary: true, code: false, explain: false, advice: false, maxWords: 20 },
|
|
80
|
+
});
|
|
81
|
+
assert.match(system, /resumen breve/);
|
|
82
|
+
assert.match(system, /"summary"/);
|
|
83
|
+
assert.doesNotMatch(system, /Tu trabajo NO es resumir/);
|
|
71
84
|
});
|
|
72
85
|
|
|
73
86
|
const fakeProvider = (content) => ({ streamChat: async () => ({ content }) });
|
|
@@ -80,9 +93,29 @@ test('consultAdvisor interpreta speak:true', async () => {
|
|
|
80
93
|
messages: editMessages(),
|
|
81
94
|
options: { code: true, explain: true, advice: true, maxWords: 40 },
|
|
82
95
|
});
|
|
83
|
-
assert.equal(advice.speak, true);
|
|
84
|
-
assert.equal(advice.kind, 'code');
|
|
85
|
-
assert.match(advice.text, /token/);
|
|
96
|
+
assert.equal(advice.speak, true);
|
|
97
|
+
assert.equal(advice.kind, 'code');
|
|
98
|
+
assert.match(advice.text, /token/);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('consultAdvisor devuelve el resumen aunque no hable', async () => {
|
|
102
|
+
const advice = await consultAdvisor({
|
|
103
|
+
provider: fakeProvider('{"speak":false,"text":"","summary":"Revisaste el token y lo pasaste a env."}'),
|
|
104
|
+
config: {},
|
|
105
|
+
model: 'x',
|
|
106
|
+
messages: editMessages(),
|
|
107
|
+
options: { summary: true, code: false, explain: false, advice: false, maxWords: 40 },
|
|
108
|
+
});
|
|
109
|
+
assert.equal(advice.speak, false);
|
|
110
|
+
assert.match(advice.summary, /Revisaste el token/);
|
|
111
|
+
const ignored = await consultAdvisor({
|
|
112
|
+
provider: fakeProvider('{"speak":false,"text":"","summary":"algo"}'),
|
|
113
|
+
config: {},
|
|
114
|
+
model: 'x',
|
|
115
|
+
messages: [],
|
|
116
|
+
options: { summary: false, code: true, explain: true, advice: true },
|
|
117
|
+
});
|
|
118
|
+
assert.equal(ignored.summary, '');
|
|
86
119
|
});
|
|
87
120
|
|
|
88
121
|
test('consultAdvisor respeta speak:false, JSON invalido y errores', async () => {
|
|
@@ -138,14 +171,15 @@ test('GET/PUT /api/advisor/settings y POST /api/advisor/test con proveedor inyec
|
|
|
138
171
|
const initial = await (await fetch(`${base}/api/advisor/settings`)).json();
|
|
139
172
|
assert.equal(initial.enabled, false);
|
|
140
173
|
|
|
141
|
-
const put = await fetch(`${base}/api/advisor/settings`, {
|
|
142
|
-
method: 'PUT',
|
|
143
|
-
headers: { 'Content-Type': 'application/json' },
|
|
144
|
-
body: JSON.stringify({ enabled: true, code: false, maxWords: 500 }),
|
|
145
|
-
});
|
|
146
|
-
const saved = await put.json();
|
|
147
|
-
assert.equal(saved.enabled, true);
|
|
148
|
-
assert.equal(saved.code, false);
|
|
174
|
+
const put = await fetch(`${base}/api/advisor/settings`, {
|
|
175
|
+
method: 'PUT',
|
|
176
|
+
headers: { 'Content-Type': 'application/json' },
|
|
177
|
+
body: JSON.stringify({ enabled: true, code: false, summary: true, maxWords: 500 }),
|
|
178
|
+
});
|
|
179
|
+
const saved = await put.json();
|
|
180
|
+
assert.equal(saved.enabled, true);
|
|
181
|
+
assert.equal(saved.code, false);
|
|
182
|
+
assert.equal(saved.summary, true);
|
|
149
183
|
assert.equal(saved.maxWords, 120);
|
|
150
184
|
|
|
151
185
|
const on = await (await fetch(`${base}/api/advisor/test`, { method: 'POST' })).json();
|
|
@@ -173,33 +207,39 @@ test('el asesor emite el evento advice al cerrar el turno', async () => {
|
|
|
173
207
|
onEvent?.({ type: 'text', delta: text, full: text });
|
|
174
208
|
return { content: text, reasoning: '', toolCalls: [] };
|
|
175
209
|
}
|
|
176
|
-
return {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
};
|
|
189
|
-
instance.ctx.
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
210
|
+
return {
|
|
211
|
+
content: '{"speak":true,"kind":"code","text":"Cuidado con el token.","summary":"Revisaste la sesión."}',
|
|
212
|
+
reasoning: '',
|
|
213
|
+
toolCalls: [],
|
|
214
|
+
};
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
const instance = await createTamnoraServer({
|
|
218
|
+
cwd,
|
|
219
|
+
dbPath: path.join(cwd, 'tamnora.db'),
|
|
220
|
+
loadMcp: false,
|
|
221
|
+
resolveProvider: () => ({ provider, config: {} }),
|
|
222
|
+
});
|
|
223
|
+
instance.ctx.app = {
|
|
224
|
+
...instance.ctx.app,
|
|
225
|
+
advisor: { enabled: true, code: true, explain: true, advice: true, summary: true, notes: '', maxWords: 40 },
|
|
226
|
+
};
|
|
227
|
+
instance.ctx.store.setSetting('activeProvider', 'fake');
|
|
228
|
+
instance.ctx.store.setSetting('activeModel', 'test-model');
|
|
229
|
+
const info = await instance.listen({ port: 0 });
|
|
230
|
+
try {
|
|
231
|
+
const res = await fetch(`${info.url}/api/chat`, {
|
|
232
|
+
method: 'POST',
|
|
233
|
+
headers: { 'Content-Type': 'application/json' },
|
|
234
|
+
body: JSON.stringify({ input: 'quién soy', model: 'test-model', provider: 'fake' }),
|
|
235
|
+
});
|
|
236
|
+
const body = await res.text();
|
|
237
|
+
assert.equal(calls, 2);
|
|
238
|
+
assert.match(body, /event: advice/);
|
|
239
|
+
assert.match(body, /Cuidado con el token/);
|
|
240
|
+
assert.match(body, /"kind":"summary"/);
|
|
241
|
+
assert.match(body, /Revisaste la sesión/);
|
|
242
|
+
} finally {
|
|
243
|
+
await instance.close();
|
|
244
|
+
}
|
|
245
|
+
});
|