@danieltmn/tamnora 0.25.0 → 0.26.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 (110) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +13 -25
  3. package/SECURITY.md +3 -4
  4. package/harness/packages/cli/src/cli.js +0 -3
  5. package/harness/packages/cli/src/commands/bridge.js +0 -1
  6. package/harness/packages/cli/src/commands/repl.js +20 -4
  7. package/harness/packages/cli/src/commands/run.js +6 -2
  8. package/harness/packages/cli/src/commands/server.js +1 -1
  9. package/harness/packages/cli/src/commands/tui.js +6 -2
  10. package/harness/packages/cli/src/lib/runtime.js +2 -0
  11. package/harness/packages/cli/src/lib/util.js +2 -2
  12. package/harness/packages/cli/src/tui/app.js +24 -4
  13. package/harness/packages/cli/src/tui/layout.js +1 -1
  14. package/harness/packages/cli/test/tunnel.test.js +5 -32
  15. package/harness/packages/core/src/agent/runAgent.js +5 -2
  16. package/harness/packages/core/src/agents/index.js +113 -0
  17. package/harness/packages/core/src/config/app.js +0 -4
  18. package/harness/packages/core/src/config/home.js +1 -20
  19. package/harness/packages/core/src/config/index.js +6 -8
  20. package/harness/packages/core/src/extensions/load.js +22 -0
  21. package/harness/packages/core/src/frontmatter.js +32 -0
  22. package/harness/packages/core/src/hooks/index.js +2 -16
  23. package/harness/packages/core/src/index.js +12 -14
  24. package/harness/packages/core/src/plugins/index.js +2 -16
  25. package/harness/packages/core/src/procs/project.js +68 -0
  26. package/harness/packages/core/src/procs/static.js +17 -0
  27. package/harness/packages/core/src/providers/index.js +3 -1
  28. package/harness/packages/core/src/providers/ollama.js +4 -1
  29. package/harness/packages/core/src/providers/openaiCompatible.js +6 -1
  30. package/harness/packages/core/src/providers/openrouter.js +5 -1
  31. package/harness/packages/core/src/providers/presets.js +4 -3
  32. package/harness/packages/core/src/providers/reasoning.js +58 -0
  33. package/harness/packages/core/src/rules/index.js +8 -27
  34. package/harness/packages/core/src/skills/index.js +8 -19
  35. package/harness/packages/core/src/storage/sqlite.js +595 -690
  36. package/harness/packages/core/src/tunnel.js +9 -147
  37. package/harness/packages/core/test/agents.test.js +111 -0
  38. package/harness/packages/core/test/home.test.js +2 -35
  39. package/harness/packages/core/test/procs.test.js +3 -64
  40. package/harness/packages/core/test/reasoning.test.js +43 -0
  41. package/harness/packages/core/test/storage.test.js +1 -12
  42. package/harness/packages/server/public/app.js +39 -19
  43. package/harness/packages/server/public/appearance.js +2 -56
  44. package/harness/packages/server/public/boot.js +2 -2
  45. package/harness/packages/server/public/cards.js +2 -21
  46. package/harness/packages/server/public/chat.js +1 -16
  47. package/harness/packages/server/public/chatFormat.js +0 -11
  48. package/harness/packages/server/public/chrome.js +95 -5
  49. package/harness/packages/server/public/commands.js +1 -2
  50. package/harness/packages/server/public/core.js +1 -1
  51. package/harness/packages/server/public/explorer.js +7 -10
  52. package/harness/packages/server/public/icons (1).zip +0 -0
  53. package/harness/packages/server/public/index.html +1228 -1237
  54. package/harness/packages/server/public/landing/boot.js +2 -3
  55. package/harness/packages/server/public/landing/index.html +3 -367
  56. package/harness/packages/server/public/sessions.js +1 -0
  57. package/harness/packages/server/public/settings.js +1255 -1155
  58. package/harness/packages/server/public/settingsNav.js +1 -1
  59. package/harness/packages/server/public/styles.css +2341 -2867
  60. package/harness/packages/server/public/system.js +356 -495
  61. package/harness/packages/server/public/voice.js +6 -22
  62. package/harness/packages/server/src/auth.js +4 -15
  63. package/harness/packages/server/src/cli.js +1 -1
  64. package/harness/packages/server/src/lib/agents.js +60 -0
  65. package/harness/packages/server/src/lib/appearance.js +3 -22
  66. package/harness/packages/server/src/lib/channels/telegram/commands.js +1 -1
  67. package/harness/packages/server/src/lib/channels/telegram/run.js +1 -0
  68. package/harness/packages/server/src/lib/definitions.js +40 -0
  69. package/harness/packages/server/src/lib/prompt.js +11 -3
  70. package/harness/packages/server/src/lib/scheduler.js +4 -0
  71. package/harness/packages/server/src/lib/sessionView.js +1 -0
  72. package/harness/packages/server/src/lib/skills.js +27 -21
  73. package/harness/packages/server/src/lib/tasks.js +71 -23
  74. package/harness/packages/server/src/lib/ttsSettings.js +2 -2
  75. package/harness/packages/server/src/lib/workspace.js +9 -11
  76. package/harness/packages/server/src/routes/agents.js +88 -0
  77. package/harness/packages/server/src/routes/chat.js +9 -44
  78. package/harness/packages/server/src/routes/preview.js +5 -22
  79. package/harness/packages/server/src/routes/procs.js +2 -4
  80. package/harness/packages/server/src/routes/providers.js +7 -0
  81. package/harness/packages/server/src/routes/sessions.js +3 -1
  82. package/harness/packages/server/src/routes/tts.js +1 -1
  83. package/harness/packages/server/src/server.js +8 -5
  84. package/harness/packages/server/test/agents.test.js +194 -0
  85. package/harness/packages/server/test/appearance-route.test.js +3 -3
  86. package/harness/packages/server/test/appearance.test.js +12 -38
  87. package/harness/packages/server/test/chatFormat.test.js +0 -8
  88. package/harness/packages/server/test/consoleTabs.test.js +2 -2
  89. package/harness/packages/server/test/ops.test.js +8 -56
  90. package/harness/packages/server/test/preview.test.js +2 -42
  91. package/harness/packages/server/test/security.test.js +194 -370
  92. package/harness/packages/server/test/settingsNav.test.js +2 -2
  93. package/harness/packages/server/test/tts.test.js +4 -4
  94. package/harness/tamnora.example.json +2 -1
  95. package/package.json +2 -2
  96. package/harness/packages/cli/src/commands/eval.js +0 -186
  97. package/harness/packages/core/src/eval/dataset.js +0 -47
  98. package/harness/packages/core/src/eval/judge.js +0 -39
  99. package/harness/packages/core/src/eval/report.js +0 -48
  100. package/harness/packages/core/src/eval/runTask.js +0 -62
  101. package/harness/packages/core/src/eval/runner.js +0 -153
  102. package/harness/packages/core/src/eval/scorers.js +0 -217
  103. package/harness/packages/core/src/eval/task.js +0 -59
  104. package/harness/packages/core/src/procs/detect.js +0 -149
  105. package/harness/packages/core/test/eval.test.js +0 -144
  106. package/harness/packages/server/src/lib/advisor.js +0 -198
  107. package/harness/packages/server/src/routes/advisor.js +0 -79
  108. package/harness/packages/server/src/routes/eval.js +0 -100
  109. package/harness/packages/server/test/advisor.test.js +0 -249
  110. package/harness/scripts/example-dataset.jsonl +0 -4
@@ -1,198 +0,0 @@
1
- // Asesor tipo Jarvis: una capa de comentario sobre el run activo. Mira lo que
2
- // el agente hizo (pedido, respuesta, herramientas y diffs), lo interpreta con
3
- // una llamada corta al modelo y decide si vale la pena decir algo. NO lee la
4
- // respuesta del agente: solo agrega alertas de codigo, explicaciones o consejos.
5
- // Opcionalmente (options.summary) arranca el mensaje con un resumen breve: un
6
- // SOLO texto continuo (resumen + alerta/consejo), como hablaria Jarvis.
7
- //
8
- // La salida es JSON estricto para poder decidir el silencio: si el modelo no
9
- // tiene nada que aportar, no habla ni deja tarjeta.
10
-
11
- import { summarizeToolCall, extractJson } from './core.js';
12
-
13
- export const DEFAULT_ADVISOR = {
14
- enabled: false,
15
- code: true,
16
- explain: true,
17
- advice: true,
18
- summary: false,
19
- notes: '',
20
- maxWords: 40,
21
- provider: '',
22
- model: '',
23
- };
24
-
25
- const KINDS = new Set(['code', 'explain', 'advice', 'summary']);
26
- const MAX_PROMPT_CHARS = 8000;
27
- const MAX_DIFF_CHARS = 2500;
28
-
29
- /** Config efectiva: lo guardado en app.json (la casa) sobre los defaults. */
30
- export function resolveAdvisor(app) {
31
- const saved = app?.advisor && typeof app.advisor === 'object' ? app.advisor : {};
32
- const maxWords = Number(saved.maxWords);
33
- return {
34
- enabled: saved.enabled === true,
35
- code: saved.code !== false,
36
- explain: saved.explain !== false,
37
- advice: saved.advice !== false,
38
- summary: saved.summary === true,
39
- notes: typeof saved.notes === 'string' ? saved.notes.slice(0, 2000) : '',
40
- maxWords: Number.isFinite(maxWords)
41
- ? Math.min(Math.max(Math.round(maxWords), 10), 120)
42
- : DEFAULT_ADVISOR.maxWords,
43
- provider: typeof saved.provider === 'string' ? saved.provider.trim().slice(0, 60) : '',
44
- model: typeof saved.model === 'string' ? saved.model.trim().slice(0, 120) : '',
45
- };
46
- }
47
-
48
- /** Gate barato: sin herramientas ni una respuesta larga no hay nada que mirar. */
49
- export function advisorShouldRun({ toolCalls = 0, answer = '' } = {}) {
50
- return Number(toolCalls) > 0 || String(answer ?? '').length > 200;
51
- }
52
-
53
- function parseArgs(call) {
54
- try {
55
- return JSON.parse(call?.function?.arguments ?? call?.arguments ?? '{}') ?? {};
56
- } catch {
57
- return {};
58
- }
59
- }
60
-
61
- /** Tool-calls de los mensajes del turno como `nombre: resumen` (deduplicado, acotado). */
62
- export function collectToolSummary(messages = [], limit = 40) {
63
- const out = [];
64
- const seen = new Set();
65
- for (const message of messages) {
66
- if (message.role !== 'assistant') continue;
67
- for (const call of message.tool_calls ?? []) {
68
- const name = call.function?.name ?? call.name ?? '';
69
- if (!name) continue;
70
- const line = `${name}: ${summarizeToolCall(name, parseArgs(call))}`;
71
- if (seen.has(line)) continue;
72
- seen.add(line);
73
- out.push(line);
74
- if (out.length >= limit) return out;
75
- }
76
- }
77
- return out;
78
- }
79
-
80
- /** Archivos tocados por write/edit con su diff, tal como lo devolvio la tool. */
81
- export function collectChangedFiles(messages = []) {
82
- const calls = new Map();
83
- for (const message of messages) {
84
- if (message.role !== 'assistant') continue;
85
- for (const call of message.tool_calls ?? []) {
86
- const name = call.function?.name ?? call.name;
87
- if (name !== 'write' && name !== 'edit') continue;
88
- const id = call.id ?? call.tool_call_id;
89
- if (id) calls.set(id, { name, path: parseArgs(call).path ?? '' });
90
- }
91
- }
92
- const files = [];
93
- for (const message of messages) {
94
- if (message.role !== 'tool') continue;
95
- const call = calls.get(message.tool_call_id);
96
- if (!call) continue;
97
- const content = typeof message.content === 'string' ? message.content : '';
98
- files.push({ path: call.path || 'archivo', diff: content.slice(0, MAX_DIFF_CHARS) });
99
- }
100
- return files;
101
- }
102
-
103
- function focusLines(options) {
104
- const lines = [];
105
- if (options.code) lines.push('- alertar de un problema real en el codigo (bug, riesgo, secreto expuesto, inconsistencia)');
106
- if (options.explain) lines.push('- explicar un proceso o decision que quedo poco clara');
107
- if (options.advice) lines.push('- dar un consejo concreto y accionable');
108
- if (!lines.length) lines.push('- aportar algo util que el agente no haya dicho');
109
- return lines.join('\n');
110
- }
111
-
112
- /** System + user para la consulta al asesor (expuestos para test). */
113
- export function buildAdvisorPrompt({ userInput = '', answer = '', toolSummary = [], changedFiles = [], options = {} } = {}) {
114
- const maxWords = options.maxWords ?? DEFAULT_ADVISOR.maxWords;
115
- const wantsAdvice = Boolean(options.code || options.explain || options.advice);
116
- const wantsSummary = options.summary === true;
117
- const system =
118
- 'Sos el asesor personal del usuario dentro de Tamnora, al estilo de Jarvis.\n' +
119
- 'Recibis lo que el agente hizo en el ultimo turno: el pedido del usuario, su respuesta\n' +
120
- 'final, las herramientas que uso y los diffs de los archivos que toco.\n\n' +
121
- (wantsAdvice
122
- ? 'Tu trabajo NO es repetir la respuesta del agente: el usuario ya la esta viendo.\n' +
123
- 'Solo aportas algo que el agente no dijo. Enfocate en:\n' +
124
- `${focusLines(options)}\n` +
125
- 'Si no hay nada que valga la pena decir, pon "speak" en false.\n\n'
126
- : '') +
127
- (wantsSummary
128
- ? 'El usuario quiere UN SOLO mensaje continuo: arrancá con un resumen muy breve de\n' +
129
- 'lo que se hizo (una o dos frases, que se pidio y que se hizo, sin copiar la respuesta)\n' +
130
- 'y, si tenes un consejo o una alerta, continua con eso en el mismo texto.\n' +
131
- 'Si no hay nada que alertar, limita el mensaje al resumen y usa "kind": "summary".\n\n'
132
- : '') +
133
- 'Hablale al usuario en segunda persona, breve (' +
134
- `${maxWords} palabras como maximo), en espanol, sin markdown ni listas. Si no hay nada\n` +
135
- 'que valga la pena decir, no hables.\n' +
136
- (options.notes ? `\nInstrucciones del usuario: ${options.notes}\n` : '') +
137
- `\nResponde SOLO con JSON: {"speak": true|false, "kind": "${
138
- wantsSummary ? 'code|explain|advice|summary' : 'code|explain|advice'
139
- }", "text": "..."}`;
140
-
141
- const parts = [`PEDIDO DEL USUARIO:\n${userInput || '(sin texto)'}`];
142
- parts.push(`RESPUESTA FINAL DEL AGENTE:\n${answer || '(sin respuesta)'}`);
143
- if (toolSummary.length) parts.push(`HERRAMIENTAS USADAS:\n${toolSummary.map((t) => `- ${t}`).join('\n')}`);
144
- if (changedFiles.length) {
145
- const files = changedFiles.map((f) => `### ${f.path}\n${f.diff}`).join('\n\n');
146
- parts.push(`ARCHIVOS MODIFICADOS (diffs):\n${files}`);
147
- }
148
-
149
- return { system, user: parts.join('\n\n').slice(0, MAX_PROMPT_CHARS) };
150
- }
151
-
152
- function truncateWords(text, maxWords) {
153
- const words = String(text ?? '').replace(/\s+/g, ' ').trim().split(' ').filter(Boolean);
154
- if (words.length <= maxWords) return words.join(' ');
155
- return `${words.slice(0, maxWords).join(' ')}…`;
156
- }
157
-
158
- /**
159
- * Consulta unica al modelo (mismo patron que el juez de eval). Devuelve
160
- * `{ speak, kind, text }` o `null` si no se pudo interpretar. Nunca tira: un
161
- * asesor roto no debe romper el run.
162
- */
163
- export async function consultAdvisor({
164
- provider,
165
- config,
166
- model,
167
- signal,
168
- userInput,
169
- answer,
170
- messages = [],
171
- options = {},
172
- } = {}) {
173
- const todo = collectToolSummary(messages);
174
- const changedFiles = collectChangedFiles(messages);
175
- const { system, user } = buildAdvisorPrompt({ userInput, answer, toolSummary: todo, changedFiles, options });
176
- const maxWords = options.maxWords ?? DEFAULT_ADVISOR.maxWords;
177
-
178
- try {
179
- const result = await provider.streamChat(config, {
180
- model,
181
- messages: [
182
- { role: 'system', content: system },
183
- { role: 'user', content: user },
184
- ],
185
- tools: [],
186
- signal,
187
- onEvent: () => {},
188
- });
189
- const { value, error } = extractJson(result?.content ?? '');
190
- if (error || !value || typeof value !== 'object') return null;
191
- const kind = KINDS.has(value.kind) ? value.kind : 'advice';
192
- const text = truncateWords(value.text, maxWords);
193
- if (value.speak !== true || !text) return { speak: false, kind, text: '' };
194
- return { speak: true, kind, text };
195
- } catch {
196
- return null;
197
- }
198
- }
@@ -1,79 +0,0 @@
1
- // Ajustes del asesor (app.json -> advisor) y una prueba manual que corre una
2
- // consulta real para ver que devuelve el modelo.
3
-
4
- import { resolveAdvisor, consultAdvisor } from '../lib/advisor.js';
5
- import { readApp, writeApp } from '../lib/core.js';
6
- import { readJson, sendJson, sendError } from '../lib/http.js';
7
-
8
- export function registerAdvisor(route, ctx) {
9
- route('GET', '/api/advisor/settings', ({ res }) => {
10
- sendJson(res, 200, resolveAdvisor(ctx.app));
11
- });
12
-
13
- route('PUT', '/api/advisor/settings', async ({ req, res }) => {
14
- const body = await readJson(req);
15
- const app = readApp();
16
- const current = resolveAdvisor(ctx.app);
17
- const next = { ...current };
18
- if ('enabled' in body) next.enabled = body.enabled === true;
19
- if ('code' in body) next.code = body.code !== false;
20
- if ('explain' in body) next.explain = body.explain !== false;
21
- if ('advice' in body) next.advice = body.advice !== false;
22
- if ('summary' in body) next.summary = body.summary === true;
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);
26
- if ('maxWords' in body) {
27
- const value = Number(body.maxWords);
28
- next.maxWords = Number.isFinite(value)
29
- ? Math.min(Math.max(Math.round(value), 10), 120)
30
- : current.maxWords;
31
- }
32
- app.advisor = next;
33
- writeApp(app);
34
- ctx.app = app;
35
- sendJson(res, 200, resolveAdvisor(app));
36
- });
37
-
38
- route('POST', '/api/advisor/test', async ({ res }) => {
39
- const options = resolveAdvisor(ctx.app);
40
- const providerId = options.provider || ctx.effectiveProviderId();
41
- const model = options.model || ctx.effectiveModel();
42
- if (!model) return sendError(res, 400, 'Elegí un proveedor y un modelo primero.');
43
- const { provider, config } = ctx.resolveProvider({
44
- ...ctx.providerSettings(providerId),
45
- apiKey: ctx.apiKeyFor(providerId),
46
- });
47
- // Turno de ejemplo con una edicion: alcanza para ver si el asesor aporta algo.
48
- const messages = [
49
- {
50
- role: 'assistant',
51
- content: '',
52
- tool_calls: [
53
- {
54
- id: 'test-1',
55
- type: 'function',
56
- function: { name: 'edit', arguments: JSON.stringify({ path: 'src/auth.js' }) },
57
- },
58
- ],
59
- },
60
- {
61
- role: 'tool',
62
- tool_call_id: 'test-1',
63
- name: 'edit',
64
- content: 'Editado src/auth.js\n- const token = "secreto123"\n+ const token = process.env.TOKEN',
65
- },
66
- ];
67
- const advice = await consultAdvisor({
68
- provider,
69
- config,
70
- model,
71
- userInput: 'Revisá el manejo del token de sesión.',
72
- answer: 'Listo, ajusté el archivo.',
73
- messages,
74
- options,
75
- });
76
- if (!advice) return sendError(res, 502, 'El asesor no devolvió una respuesta interpretable.');
77
- sendJson(res, 200, advice);
78
- });
79
- }
@@ -1,100 +0,0 @@
1
- import path from 'node:path';
2
- import { loadDataset, runDataset, summarize, createJudge } from '../lib/core.js';
3
- import { openSse, readJson, sendError, sendJson } from '../lib/http.js';
4
-
5
- function toNumber(value, fallback) {
6
- const n = Number(value);
7
- return Number.isFinite(n) ? n : fallback;
8
- }
9
-
10
- export function registerEval(route, ctx) {
11
- route('POST', '/api/eval', async ({ req, res }) => {
12
- const body = await readJson(req);
13
-
14
- let tasks = Array.isArray(body.tasks) ? body.tasks : [];
15
- if (!tasks.length && body.dataset) {
16
- try {
17
- tasks = await loadDataset(path.resolve(ctx.cwd, String(body.dataset)));
18
- } catch (error) {
19
- return sendError(res, 400, `No se pudo leer el dataset: ${error?.message ?? error}`);
20
- }
21
- }
22
- if (!tasks.length) return sendError(res, 400, 'Faltan tareas ("tasks" o "dataset").');
23
-
24
- const models = (Array.isArray(body.models) ? body.models : [body.model])
25
- .filter(Boolean)
26
- .map(String);
27
- if (!models.length) return sendError(res, 400, 'Falta "models".');
28
-
29
- const providerId = body.provider || ctx.effectiveProviderId();
30
- const trials = toNumber(body.trials, 1);
31
- const concurrency = toNumber(body.concurrency, 1);
32
- const baseUrl = body.baseUrl || ctx.providerSettings(providerId).baseUrl;
33
- const apiKey = ctx.apiKeyFor(providerId);
34
-
35
- const enabledTools = Array.isArray(body.tools) && body.tools.length ? body.tools : ctx.config.tools;
36
-
37
- const judge = body.judgeModel
38
- ? createJudge({ providerId, model: body.judgeModel, baseUrl, apiKey })
39
- : undefined;
40
-
41
- const sse = openSse(req, res);
42
- const controller = new AbortController();
43
- req.on('close', () => controller.abort());
44
-
45
- const runId = ctx.store.createRun({
46
- dataset: body.name || body.dataset || 'inline',
47
- config: { providerId, models, trials, concurrency, tools: enabledTools ?? 'all' },
48
- });
49
- sse.event('run', { runId, total: tasks.length * models.length * trials });
50
-
51
- const startedAt = Date.now();
52
- try {
53
- const results = await runDataset({
54
- tasks,
55
- models,
56
- providerId,
57
- baseUrl,
58
- apiKey,
59
- systemPrompt: ctx.systemPrompt,
60
- rootDir: ctx.cwd,
61
- trials,
62
- concurrency,
63
- enabledTools: Array.isArray(enabledTools) && enabledTools.length ? enabledTools : undefined,
64
- permissions: ctx.config.permissions,
65
- yes: body.yes ?? ctx.yes,
66
- judge,
67
- store: ctx.store,
68
- runId,
69
- signal: controller.signal,
70
- onProgress: (entry) => sse.event('result', entry),
71
- });
72
-
73
- const summary = summarize(results);
74
- summary.durationMs = Date.now() - startedAt;
75
- const status =
76
- summary.errors && summary.passed === 0
77
- ? 'failed'
78
- : summary.errors
79
- ? 'done_with_errors'
80
- : 'done';
81
- ctx.store.finishRun(runId, { status, summary });
82
- sse.event('summary', { runId, summary });
83
- } catch (error) {
84
- if (error?.name === 'AbortError') sse.event('aborted', {});
85
- else sse.event('error', { message: error?.message ?? String(error) });
86
- } finally {
87
- sse.close();
88
- }
89
- });
90
-
91
- route('GET', '/api/runs', ({ res }) => {
92
- sendJson(res, 200, { runs: ctx.store.listRuns(50) });
93
- });
94
-
95
- route('GET', '/api/runs/:id', ({ res, params }) => {
96
- const run = ctx.store.getRun(params.id);
97
- if (!run) return sendError(res, 404, 'Run no encontrado.');
98
- sendJson(res, 200, { run, results: ctx.store.getRunResults(params.id) });
99
- });
100
- }
@@ -1,249 +0,0 @@
1
- import { test } from 'node:test';
2
- import assert from 'node:assert/strict';
3
- import { mkdtemp } from 'node:fs/promises';
4
- import { tmpdir } from 'node:os';
5
- import path from 'node:path';
6
- import {
7
- resolveAdvisor,
8
- advisorShouldRun,
9
- collectChangedFiles,
10
- collectToolSummary,
11
- buildAdvisorPrompt,
12
- consultAdvisor,
13
- } from '../src/lib/advisor.js';
14
- import { createTamnoraServer } from '../src/index.js';
15
- import { setHome } from '../../core/src/index.js';
16
-
17
- const editMessages = () => [
18
- {
19
- role: 'assistant',
20
- content: '',
21
- tool_calls: [
22
- { id: 'c1', function: { name: 'edit', arguments: JSON.stringify({ path: 'src/a.js' }) } },
23
- ],
24
- },
25
- { role: 'tool', tool_call_id: 'c1', name: 'edit', content: '- const token = "secreto"\n+ const token = process.env.TOKEN' },
26
- ];
27
-
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);
39
- });
40
-
41
- test('advisorShouldRun exige tools o respuesta larga', () => {
42
- assert.equal(advisorShouldRun({ toolCalls: 0, answer: 'corto' }), false);
43
- assert.equal(advisorShouldRun({ toolCalls: 1, answer: '' }), true);
44
- assert.equal(advisorShouldRun({ toolCalls: 0, answer: 'x'.repeat(201) }), true);
45
- });
46
-
47
- test('collectChangedFiles arma path + diff de write/edit', () => {
48
- const files = collectChangedFiles(editMessages());
49
- assert.equal(files.length, 1);
50
- assert.equal(files[0].path, 'src/a.js');
51
- assert.match(files[0].diff, /secreto/);
52
- });
53
-
54
- test('collectToolSummary lista las tools del turno', () => {
55
- const summary = collectToolSummary(editMessages());
56
- assert.equal(summary.length, 1);
57
- assert.match(summary[0], /^edit:/);
58
- });
59
-
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 un solo mensaje con 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, /UN SOLO mensaje/);
82
- assert.match(system, /resumen muy breve/);
83
- assert.match(system, /code\|explain\|advice\|summary/);
84
- assert.doesNotMatch(system, /"summary":/);
85
- assert.doesNotMatch(system, /Tu trabajo NO es repetir/);
86
- });
87
-
88
- const fakeProvider = (content) => ({ streamChat: async () => ({ content }) });
89
-
90
- test('consultAdvisor interpreta speak:true', async () => {
91
- const advice = await consultAdvisor({
92
- provider: fakeProvider('{"speak":true,"kind":"code","text":"Ojo con el token en src/a.js."}'),
93
- config: {},
94
- model: 'x',
95
- messages: editMessages(),
96
- options: { code: true, explain: true, advice: true, maxWords: 40 },
97
- });
98
- assert.equal(advice.speak, true);
99
- assert.equal(advice.kind, 'code');
100
- assert.match(advice.text, /token/);
101
- });
102
-
103
- test('consultAdvisor devuelve un resumen solo como kind summary', async () => {
104
- const advice = await consultAdvisor({
105
- provider: fakeProvider('{"speak":true,"kind":"summary","text":"Revisaste el token y lo pasaste a env."}'),
106
- config: {},
107
- model: 'x',
108
- messages: editMessages(),
109
- options: { summary: true, code: false, explain: false, advice: false, maxWords: 40 },
110
- });
111
- assert.equal(advice.speak, true);
112
- assert.equal(advice.kind, 'summary');
113
- assert.match(advice.text, /Revisaste el token/);
114
- const off = await consultAdvisor({
115
- provider: fakeProvider('{"speak":false,"text":"algo"}'),
116
- config: {},
117
- model: 'x',
118
- messages: [],
119
- options: { summary: false, code: true, explain: true, advice: true },
120
- });
121
- assert.equal(off.speak, false);
122
- assert.equal(off.text, '');
123
- });
124
-
125
- test('consultAdvisor respeta speak:false, JSON invalido y errores', async () => {
126
- const off = await consultAdvisor({
127
- provider: fakeProvider('{"speak":false,"text":""}'),
128
- config: {},
129
- model: 'x',
130
- messages: [],
131
- options: {},
132
- });
133
- assert.equal(off.speak, false);
134
- assert.equal(
135
- await consultAdvisor({ provider: fakeProvider('no json'), config: {}, model: 'x', messages: [], options: {} }),
136
- null
137
- );
138
- const boom = {
139
- streamChat: async () => {
140
- throw new Error('boom');
141
- },
142
- };
143
- assert.equal(await consultAdvisor({ provider: boom, config: {}, model: 'x', messages: [], options: {} }), null);
144
- });
145
-
146
- test('consultAdvisor recorta a maxWords', async () => {
147
- const long = 'palabra '.repeat(50).trim();
148
- const advice = await consultAdvisor({
149
- provider: fakeProvider(JSON.stringify({ speak: true, kind: 'advice', text: long })),
150
- config: {},
151
- model: 'x',
152
- messages: [],
153
- options: { maxWords: 10, code: true, explain: true, advice: true },
154
- });
155
- assert.ok(advice.text.split(' ').length <= 11);
156
- assert.match(advice.text, /…$/);
157
- });
158
-
159
- test('GET/PUT /api/advisor/settings y POST /api/advisor/test con proveedor inyectado', async () => {
160
- const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-advisor-'));
161
- setHome(cwd);
162
- let reply = '{"speak":true,"kind":"code","text":"Cuidado con el secreto."}';
163
- const provider = { streamChat: async () => ({ content: reply }) };
164
- const instance = await createTamnoraServer({
165
- cwd,
166
- dbPath: path.join(cwd, 'tamnora.db'),
167
- loadMcp: false,
168
- resolveProvider: () => ({ provider, config: {} }),
169
- });
170
- instance.ctx.store.setSetting('activeProvider', 'fake');
171
- instance.ctx.store.setSetting('activeModel', 'test-model');
172
- const info = await instance.listen({ port: 0 });
173
- const base = info.url;
174
- try {
175
- const initial = await (await fetch(`${base}/api/advisor/settings`)).json();
176
- assert.equal(initial.enabled, false);
177
-
178
- const put = await fetch(`${base}/api/advisor/settings`, {
179
- method: 'PUT',
180
- headers: { 'Content-Type': 'application/json' },
181
- body: JSON.stringify({ enabled: true, code: false, summary: true, maxWords: 500 }),
182
- });
183
- const saved = await put.json();
184
- assert.equal(saved.enabled, true);
185
- assert.equal(saved.code, false);
186
- assert.equal(saved.summary, true);
187
- assert.equal(saved.maxWords, 120);
188
-
189
- const on = await (await fetch(`${base}/api/advisor/test`, { method: 'POST' })).json();
190
- assert.equal(on.speak, true);
191
- assert.match(on.text, /secreto/);
192
-
193
- reply = '{"speak":false,"text":""}';
194
- const off = await (await fetch(`${base}/api/advisor/test`, { method: 'POST' })).json();
195
- assert.equal(off.speak, false);
196
- } finally {
197
- await instance.close();
198
- }
199
- });
200
-
201
- test('el asesor emite el evento advice al cerrar el turno', async () => {
202
- const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-advice-'));
203
- setHome(cwd);
204
- let calls = 0;
205
- const provider = {
206
- id: 'fake',
207
- streamChat: async (_config, { onEvent } = {}) => {
208
- calls += 1;
209
- if (calls === 1) {
210
- const text = `Listo. ${'x'.repeat(220)}`;
211
- onEvent?.({ type: 'text', delta: text, full: text });
212
- return { content: text, reasoning: '', toolCalls: [] };
213
- }
214
- return {
215
- content: '{"speak":true,"kind":"code","text":"Revisaste la sesión. Cuidado con el token."}',
216
- reasoning: '',
217
- toolCalls: [],
218
- };
219
- },
220
- };
221
- const instance = await createTamnoraServer({
222
- cwd,
223
- dbPath: path.join(cwd, 'tamnora.db'),
224
- loadMcp: false,
225
- resolveProvider: () => ({ provider, config: {} }),
226
- });
227
- instance.ctx.app = {
228
- ...instance.ctx.app,
229
- advisor: { enabled: true, code: true, explain: true, advice: true, summary: true, notes: '', maxWords: 40 },
230
- };
231
- instance.ctx.store.setSetting('activeProvider', 'fake');
232
- instance.ctx.store.setSetting('activeModel', 'test-model');
233
- const info = await instance.listen({ port: 0 });
234
- try {
235
- const res = await fetch(`${info.url}/api/chat`, {
236
- method: 'POST',
237
- headers: { 'Content-Type': 'application/json' },
238
- body: JSON.stringify({ input: 'quién soy', model: 'test-model', provider: 'fake' }),
239
- });
240
- const body = await res.text();
241
- assert.equal(calls, 2);
242
- assert.match(body, /event: advice/);
243
- assert.match(body, /Cuidado con el token/);
244
- assert.match(body, /Revisaste la sesión/);
245
- assert.equal((body.match(/event: advice/g) ?? []).length, 1);
246
- } finally {
247
- await instance.close();
248
- }
249
- });
@@ -1,4 +0,0 @@
1
- {"id":"math","input":"¿Cuánto es 17 * 23? Respondé solo el número.","expect":{"equals":"391"}}
2
- {"id":"write-file","input":"Creá el archivo saludo.txt con el texto \"hola\".","expect":[{"toolCalled":"write"},{"fileContains":{"path":"saludo.txt","value":"hola"}}]}
3
- {"id":"json","input":"Devolvé JSON con las claves \"ok\" (true) y \"n\" (3).","expect":{"jsonSchema":{"type":"object","required":["ok","n"],"properties":{"ok":{"type":"boolean"},"n":{"type":"integer"}}}}}
4
- {"id":"regex","input":"Escribí una oración que contenga un número.","expect":{"regex":"\\d"}}