@danieltmn/tamnora 0.9.3 → 0.9.5
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 +30 -0
- package/README.md +51 -16
- package/harness/packages/cli/src/commands/doctor.js +14 -0
- package/harness/packages/cli/src/commands/update.js +91 -21
- package/harness/packages/core/src/agent/modes.js +14 -1
- package/harness/packages/core/src/agent/runAgent.js +23 -2
- package/harness/packages/core/src/browser/index.js +289 -0
- package/harness/packages/core/src/config/home.js +5 -0
- package/harness/packages/core/src/index.js +9 -2
- package/harness/packages/core/src/tools/executors.js +96 -0
- package/harness/packages/core/src/tools/labels.js +4 -0
- package/harness/packages/core/src/tools/permissions.js +4 -0
- package/harness/packages/core/src/tools/schema.js +47 -0
- package/harness/packages/core/src/version.js +81 -7
- package/harness/packages/core/test/agent.test.js +30 -1
- package/harness/packages/core/test/browser.test.js +18 -0
- package/harness/packages/core/test/tools.test.js +16 -1
- package/harness/packages/core/test/version.test.js +34 -0
- package/harness/packages/server/public/app.js +244 -8
- package/harness/packages/server/public/index.html +57 -3
- package/harness/packages/server/public/landing/index.html +14 -1
- package/harness/packages/server/public/markdown.js +54 -0
- package/harness/packages/server/public/reconcile.js +3 -1
- package/harness/packages/server/public/styles.css +48 -0
- package/harness/packages/server/src/lib/advisor.js +177 -0
- package/harness/packages/server/src/lib/agentRun.js +4 -3
- package/harness/packages/server/src/lib/channels/telegram.js +130 -25
- package/harness/packages/server/src/lib/ttsSettings.js +2 -2
- package/harness/packages/server/src/lib/update.js +82 -0
- package/harness/packages/server/src/routes/advisor.js +76 -0
- package/harness/packages/server/src/routes/chat.js +30 -0
- package/harness/packages/server/src/routes/files.js +25 -1
- package/harness/packages/server/src/routes/tts.js +1 -1
- package/harness/packages/server/src/routes/update.js +43 -0
- package/harness/packages/server/src/server.js +26 -2
- package/harness/packages/server/test/advisor.test.js +205 -0
- package/harness/packages/server/test/markdown.test.js +43 -1
- package/harness/packages/server/test/server.test.js +50 -14
- package/harness/packages/server/test/telegram-features.test.js +25 -4
- package/harness/packages/server/test/update.test.js +94 -0
- package/harness/scripts/self-update.mjs +123 -0
- package/package.json +1 -1
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
toolResultPayload,
|
|
18
18
|
} from '../lib/agentRun.js';
|
|
19
19
|
import { fraseInicio, fraseFin, fraseError, createFiltroTools } from '../lib/announcer.js';
|
|
20
|
+
import { resolveAdvisor, advisorShouldRun, consultAdvisor } from '../lib/advisor.js';
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* Emit streaming partials at most once per `ms` window, keeping only the
|
|
@@ -318,6 +319,35 @@ export function registerChat(route, ctx) {
|
|
|
318
319
|
durationMs,
|
|
319
320
|
say: fraseFin({ stopReason: result.stopReason, rounds: result.rounds, toolCalls, durationMs }),
|
|
320
321
|
});
|
|
322
|
+
|
|
323
|
+
// El asesor va despues del `done` pero antes del `finally`, asi el run
|
|
324
|
+
// sigue vivo y el evento llega/persiste. Puede quedarse callado.
|
|
325
|
+
const advisorOptions = resolveAdvisor(ctx.app);
|
|
326
|
+
if (
|
|
327
|
+
result.stopReason !== 'aborted' &&
|
|
328
|
+
advisorOptions.enabled &&
|
|
329
|
+
advisorShouldRun({ toolCalls, answer: result.content })
|
|
330
|
+
) {
|
|
331
|
+
const advice = await consultAdvisor({
|
|
332
|
+
provider,
|
|
333
|
+
config,
|
|
334
|
+
model,
|
|
335
|
+
signal: ctx.runs.signal(sessionId),
|
|
336
|
+
userInput: input,
|
|
337
|
+
answer: result.content,
|
|
338
|
+
messages: newMessages,
|
|
339
|
+
options: advisorOptions,
|
|
340
|
+
});
|
|
341
|
+
if (advice?.speak) {
|
|
342
|
+
ctx.store.addMessage(sessionId, {
|
|
343
|
+
role: 'assistant',
|
|
344
|
+
name: 'advisor',
|
|
345
|
+
content: advice.text,
|
|
346
|
+
mode: advice.kind,
|
|
347
|
+
});
|
|
348
|
+
ctx.runs.emit(sessionId, 'advice', { text: advice.text, kind: advice.kind, say: advice.text });
|
|
349
|
+
}
|
|
350
|
+
}
|
|
321
351
|
} catch (error) {
|
|
322
352
|
partials.flush();
|
|
323
353
|
if (error?.name === 'AbortError') ctx.runs.emit(sessionId, 'aborted', { say: fraseFin({ stopReason: 'aborted' }) });
|
|
@@ -2,7 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import fsp from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { readJson, sendJson, sendError } from '../lib/http.js';
|
|
5
|
-
import { staticServerChip } from '../lib/core.js';
|
|
5
|
+
import { staticServerChip, screenshotsDir } from '../lib/core.js';
|
|
6
6
|
import { isInside, localOk, requireLocal, resolveDirOr400 } from '../lib/workspace.js';
|
|
7
7
|
|
|
8
8
|
const MAX_PREVIEW = 200 * 1024;
|
|
@@ -144,4 +144,28 @@ export function registerFiles(route, ctx) {
|
|
|
144
144
|
port: proc.port,
|
|
145
145
|
});
|
|
146
146
|
});
|
|
147
|
+
|
|
148
|
+
// Capturas del tool `screenshot`: viven en la casa, no en el workspace.
|
|
149
|
+
route('GET', '/api/screenshots/:name', ({ req, res, params }) => {
|
|
150
|
+
const local = localOk(req);
|
|
151
|
+
if (!local && !ctx.authEnabled) {
|
|
152
|
+
return sendError(res, 403, 'Las capturas solo están disponibles en conexiones locales.');
|
|
153
|
+
}
|
|
154
|
+
const name = String(params.name ?? '');
|
|
155
|
+
if (!/^[a-z0-9][a-z0-9-]*\.png$/i.test(name)) return sendError(res, 400, 'Nombre de captura inválido.');
|
|
156
|
+
const file = path.join(screenshotsDir(), name);
|
|
157
|
+
let info;
|
|
158
|
+
try {
|
|
159
|
+
info = fs.statSync(file);
|
|
160
|
+
} catch {
|
|
161
|
+
return sendError(res, 404, 'La captura no existe.');
|
|
162
|
+
}
|
|
163
|
+
if (!info.isFile()) return sendError(res, 404, 'La captura no existe.');
|
|
164
|
+
res.writeHead(200, { 'Content-Type': 'image/png', 'Content-Length': info.size, 'Cache-Control': 'no-store' });
|
|
165
|
+
if (req.method === 'HEAD') {
|
|
166
|
+
res.end();
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
fs.createReadStream(file).pipe(res);
|
|
170
|
+
});
|
|
147
171
|
}
|
|
@@ -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']) {
|
|
42
|
+
for (const key of ['start', 'tools', 'end', 'advice', 'answer']) {
|
|
43
43
|
if (key in body.announce) announce[key] = body.announce[key] === true;
|
|
44
44
|
}
|
|
45
45
|
next.announce = announce;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Alerta de version nueva y actualizacion con un click desde la consola.
|
|
2
|
+
//
|
|
3
|
+
// `GET /api/update` es solo informacion (cacheada). `POST /api/update` es
|
|
4
|
+
// privilegiado: **solo loopback y sin tunel** (`requireLocal`), igual que
|
|
5
|
+
// shell/git/carpetas. El server responde y se apaga; un script detached espera
|
|
6
|
+
// su muerte, instala la version nueva y lo vuelve a levantar.
|
|
7
|
+
|
|
8
|
+
import { sendJson, sendError } from '../lib/http.js';
|
|
9
|
+
import { requireLocal } from '../lib/workspace.js';
|
|
10
|
+
import { checkUpdate, spawnSelfUpdate } from '../lib/update.js';
|
|
11
|
+
|
|
12
|
+
export function registerUpdate(route, ctx) {
|
|
13
|
+
route('GET', '/api/update', async ({ res, query }) => {
|
|
14
|
+
const force = query.get('force') === '1';
|
|
15
|
+
sendJson(res, 200, await checkUpdate({ force }));
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
route('POST', '/api/update', async ({ req, res }) => {
|
|
19
|
+
if (!requireLocal(req, res, 'Actualizar solo esta permitido en la conexion local.')) return;
|
|
20
|
+
|
|
21
|
+
const info = await checkUpdate({ force: true });
|
|
22
|
+
if (!info.latest) return sendError(res, 502, info.error || 'No pude consultar la ultima version.');
|
|
23
|
+
if (!info.updateAvailable) {
|
|
24
|
+
return sendJson(res, 200, { ok: true, upToDate: true, current: info.current, latest: info.latest });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const spawnUpdater = ctx.spawnUpdater ?? spawnSelfUpdate;
|
|
28
|
+
const started = spawnUpdater({
|
|
29
|
+
pid: process.pid,
|
|
30
|
+
port: ctx.serverInfo?.port,
|
|
31
|
+
host: ctx.serverInfo?.host,
|
|
32
|
+
cwd: ctx.cwd,
|
|
33
|
+
target: info.latest,
|
|
34
|
+
});
|
|
35
|
+
if (!started.ok) return sendError(res, 500, started.error);
|
|
36
|
+
|
|
37
|
+
sendJson(res, 202, { ok: true, updating: true, from: info.current, to: info.latest });
|
|
38
|
+
// Soltar los archivos antes de que npm los reemplace.
|
|
39
|
+
setTimeout(() => {
|
|
40
|
+
Promise.resolve(ctx.shutdown?.()).catch(() => process.exit(0));
|
|
41
|
+
}, 800);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
@@ -39,6 +39,8 @@ import { registerChannels } from './routes/channels.js';
|
|
|
39
39
|
import { registerProfile } from './routes/profile.js';
|
|
40
40
|
import { registerTts } from './routes/tts.js';
|
|
41
41
|
import { registerTranscribe } from './routes/transcribe.js';
|
|
42
|
+
import { registerUpdate } from './routes/update.js';
|
|
43
|
+
import { registerAdvisor } from './routes/advisor.js';
|
|
42
44
|
import { registerProcs } from './routes/procs.js';
|
|
43
45
|
import { registerShell } from './routes/shell.js';
|
|
44
46
|
import { registerPreview } from './routes/preview.js';
|
|
@@ -186,6 +188,13 @@ export async function createTamnoraServer({
|
|
|
186
188
|
mergedMcpServers,
|
|
187
189
|
procs,
|
|
188
190
|
procsEnabled,
|
|
191
|
+
// Se completa en `listen()` con el puerto/host reales (util para reiniciar
|
|
192
|
+
// el server tras una actualizacion).
|
|
193
|
+
serverInfo: {
|
|
194
|
+
port: port ?? config.server?.port ?? 4517,
|
|
195
|
+
host: host ?? config.server?.host ?? '127.0.0.1',
|
|
196
|
+
url: '',
|
|
197
|
+
},
|
|
189
198
|
};
|
|
190
199
|
|
|
191
200
|
ctx.systemPromptFor = createPromptBuilder({
|
|
@@ -234,6 +243,8 @@ export async function createTamnoraServer({
|
|
|
234
243
|
registerProfile(route, ctx);
|
|
235
244
|
registerTts(route, ctx);
|
|
236
245
|
registerTranscribe(route, ctx);
|
|
246
|
+
registerAdvisor(route, ctx);
|
|
247
|
+
registerUpdate(route, ctx);
|
|
237
248
|
registerProcs(route, ctx);
|
|
238
249
|
registerShell(route, ctx);
|
|
239
250
|
const preview = registerPreview(route, ctx);
|
|
@@ -320,11 +331,13 @@ export async function createTamnoraServer({
|
|
|
320
331
|
options.host ?? host ?? config.server?.host ?? '127.0.0.1',
|
|
321
332
|
() => {
|
|
322
333
|
const address = server.address();
|
|
323
|
-
|
|
334
|
+
const info = {
|
|
324
335
|
host: address.address,
|
|
325
336
|
port: address.port,
|
|
326
337
|
url: `http://${address.address === '::' ? '127.0.0.1' : address.address}:${address.port}`,
|
|
327
|
-
}
|
|
338
|
+
};
|
|
339
|
+
ctx.serverInfo = info;
|
|
340
|
+
resolve(info);
|
|
328
341
|
}
|
|
329
342
|
);
|
|
330
343
|
});
|
|
@@ -354,5 +367,16 @@ export async function createTamnoraServer({
|
|
|
354
367
|
}
|
|
355
368
|
};
|
|
356
369
|
|
|
370
|
+
// Apagado ordenado pedido desde una ruta (actualizacion con un click): cierra
|
|
371
|
+
// el server y termina el proceso para que npm pueda reemplazar los archivos.
|
|
372
|
+
ctx.shutdown = async () => {
|
|
373
|
+
try {
|
|
374
|
+
await close();
|
|
375
|
+
} catch {
|
|
376
|
+
/* ya cerrado */
|
|
377
|
+
}
|
|
378
|
+
process.exit(0);
|
|
379
|
+
};
|
|
380
|
+
|
|
357
381
|
return { server, config, store, ctx, listen, close, staticDir: staticDir ?? DEFAULT_PUBLIC_DIR };
|
|
358
382
|
}
|
|
@@ -0,0 +1,205 @@
|
|
|
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
|
+
const saved = resolveAdvisor({ advisor: { enabled: true, notes: 'x', maxWords: 999 } });
|
|
31
|
+
assert.equal(saved.enabled, true);
|
|
32
|
+
assert.equal(saved.code, true);
|
|
33
|
+
assert.equal(saved.maxWords, 120);
|
|
34
|
+
const off = resolveAdvisor({ advisor: { code: false, explain: false, advice: false, maxWords: 5 } });
|
|
35
|
+
assert.equal(off.maxWords, 10);
|
|
36
|
+
assert.equal(off.code, false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('advisorShouldRun exige tools o respuesta larga', () => {
|
|
40
|
+
assert.equal(advisorShouldRun({ toolCalls: 0, answer: 'corto' }), false);
|
|
41
|
+
assert.equal(advisorShouldRun({ toolCalls: 1, answer: '' }), true);
|
|
42
|
+
assert.equal(advisorShouldRun({ toolCalls: 0, answer: 'x'.repeat(201) }), true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('collectChangedFiles arma path + diff de write/edit', () => {
|
|
46
|
+
const files = collectChangedFiles(editMessages());
|
|
47
|
+
assert.equal(files.length, 1);
|
|
48
|
+
assert.equal(files[0].path, 'src/a.js');
|
|
49
|
+
assert.match(files[0].diff, /secreto/);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('collectToolSummary lista las tools del turno', () => {
|
|
53
|
+
const summary = collectToolSummary(editMessages());
|
|
54
|
+
assert.equal(summary.length, 1);
|
|
55
|
+
assert.match(summary[0], /^edit:/);
|
|
56
|
+
});
|
|
57
|
+
|
|
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/);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const fakeProvider = (content) => ({ streamChat: async () => ({ content }) });
|
|
74
|
+
|
|
75
|
+
test('consultAdvisor interpreta speak:true', async () => {
|
|
76
|
+
const advice = await consultAdvisor({
|
|
77
|
+
provider: fakeProvider('{"speak":true,"kind":"code","text":"Ojo con el token en src/a.js."}'),
|
|
78
|
+
config: {},
|
|
79
|
+
model: 'x',
|
|
80
|
+
messages: editMessages(),
|
|
81
|
+
options: { code: true, explain: true, advice: true, maxWords: 40 },
|
|
82
|
+
});
|
|
83
|
+
assert.equal(advice.speak, true);
|
|
84
|
+
assert.equal(advice.kind, 'code');
|
|
85
|
+
assert.match(advice.text, /token/);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('consultAdvisor respeta speak:false, JSON invalido y errores', async () => {
|
|
89
|
+
const off = await consultAdvisor({
|
|
90
|
+
provider: fakeProvider('{"speak":false,"text":""}'),
|
|
91
|
+
config: {},
|
|
92
|
+
model: 'x',
|
|
93
|
+
messages: [],
|
|
94
|
+
options: {},
|
|
95
|
+
});
|
|
96
|
+
assert.equal(off.speak, false);
|
|
97
|
+
assert.equal(
|
|
98
|
+
await consultAdvisor({ provider: fakeProvider('no json'), config: {}, model: 'x', messages: [], options: {} }),
|
|
99
|
+
null
|
|
100
|
+
);
|
|
101
|
+
const boom = {
|
|
102
|
+
streamChat: async () => {
|
|
103
|
+
throw new Error('boom');
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
assert.equal(await consultAdvisor({ provider: boom, config: {}, model: 'x', messages: [], options: {} }), null);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('consultAdvisor recorta a maxWords', async () => {
|
|
110
|
+
const long = 'palabra '.repeat(50).trim();
|
|
111
|
+
const advice = await consultAdvisor({
|
|
112
|
+
provider: fakeProvider(JSON.stringify({ speak: true, kind: 'advice', text: long })),
|
|
113
|
+
config: {},
|
|
114
|
+
model: 'x',
|
|
115
|
+
messages: [],
|
|
116
|
+
options: { maxWords: 10, code: true, explain: true, advice: true },
|
|
117
|
+
});
|
|
118
|
+
assert.ok(advice.text.split(' ').length <= 11);
|
|
119
|
+
assert.match(advice.text, /…$/);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('GET/PUT /api/advisor/settings y POST /api/advisor/test con proveedor inyectado', async () => {
|
|
123
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-advisor-'));
|
|
124
|
+
setHome(cwd);
|
|
125
|
+
let reply = '{"speak":true,"kind":"code","text":"Cuidado con el secreto."}';
|
|
126
|
+
const provider = { streamChat: async () => ({ content: reply }) };
|
|
127
|
+
const instance = await createTamnoraServer({
|
|
128
|
+
cwd,
|
|
129
|
+
dbPath: path.join(cwd, 'tamnora.db'),
|
|
130
|
+
loadMcp: false,
|
|
131
|
+
resolveProvider: () => ({ provider, config: {} }),
|
|
132
|
+
});
|
|
133
|
+
instance.ctx.store.setSetting('activeProvider', 'fake');
|
|
134
|
+
instance.ctx.store.setSetting('activeModel', 'test-model');
|
|
135
|
+
const info = await instance.listen({ port: 0 });
|
|
136
|
+
const base = info.url;
|
|
137
|
+
try {
|
|
138
|
+
const initial = await (await fetch(`${base}/api/advisor/settings`)).json();
|
|
139
|
+
assert.equal(initial.enabled, false);
|
|
140
|
+
|
|
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);
|
|
149
|
+
assert.equal(saved.maxWords, 120);
|
|
150
|
+
|
|
151
|
+
const on = await (await fetch(`${base}/api/advisor/test`, { method: 'POST' })).json();
|
|
152
|
+
assert.equal(on.speak, true);
|
|
153
|
+
assert.match(on.text, /secreto/);
|
|
154
|
+
|
|
155
|
+
reply = '{"speak":false,"text":""}';
|
|
156
|
+
const off = await (await fetch(`${base}/api/advisor/test`, { method: 'POST' })).json();
|
|
157
|
+
assert.equal(off.speak, false);
|
|
158
|
+
} finally {
|
|
159
|
+
await instance.close();
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('el asesor emite el evento advice al cerrar el turno', async () => {
|
|
164
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-advice-'));
|
|
165
|
+
setHome(cwd);
|
|
166
|
+
let calls = 0;
|
|
167
|
+
const provider = {
|
|
168
|
+
id: 'fake',
|
|
169
|
+
streamChat: async (_config, { onEvent } = {}) => {
|
|
170
|
+
calls += 1;
|
|
171
|
+
if (calls === 1) {
|
|
172
|
+
const text = `Listo. ${'x'.repeat(220)}`;
|
|
173
|
+
onEvent?.({ type: 'text', delta: text, full: text });
|
|
174
|
+
return { content: text, reasoning: '', toolCalls: [] };
|
|
175
|
+
}
|
|
176
|
+
return { content: '{"speak":true,"kind":"code","text":"Cuidado con el token."}', reasoning: '', toolCalls: [] };
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
const instance = await createTamnoraServer({
|
|
180
|
+
cwd,
|
|
181
|
+
dbPath: path.join(cwd, 'tamnora.db'),
|
|
182
|
+
loadMcp: false,
|
|
183
|
+
resolveProvider: () => ({ provider, config: {} }),
|
|
184
|
+
});
|
|
185
|
+
instance.ctx.app = {
|
|
186
|
+
...instance.ctx.app,
|
|
187
|
+
advisor: { enabled: true, code: true, explain: true, advice: true, notes: '', maxWords: 40 },
|
|
188
|
+
};
|
|
189
|
+
instance.ctx.store.setSetting('activeProvider', 'fake');
|
|
190
|
+
instance.ctx.store.setSetting('activeModel', 'test-model');
|
|
191
|
+
const info = await instance.listen({ port: 0 });
|
|
192
|
+
try {
|
|
193
|
+
const res = await fetch(`${info.url}/api/chat`, {
|
|
194
|
+
method: 'POST',
|
|
195
|
+
headers: { 'Content-Type': 'application/json' },
|
|
196
|
+
body: JSON.stringify({ input: 'quién soy', model: 'test-model', provider: 'fake' }),
|
|
197
|
+
});
|
|
198
|
+
const body = await res.text();
|
|
199
|
+
assert.equal(calls, 2);
|
|
200
|
+
assert.match(body, /event: advice/);
|
|
201
|
+
assert.match(body, /Cuidado con el token/);
|
|
202
|
+
} finally {
|
|
203
|
+
await instance.close();
|
|
204
|
+
}
|
|
205
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import { renderMarkdown, highlight } from '../public/markdown.js';
|
|
3
|
+
import { renderMarkdown, highlight, speechText } from '../public/markdown.js';
|
|
4
4
|
|
|
5
5
|
test('escapes raw HTML from model output', () => {
|
|
6
6
|
const html = renderMarkdown('<script>alert(1)</script>');
|
|
@@ -67,3 +67,45 @@ test('a lone pipe line without a delimiter row stays a paragraph', () => {
|
|
|
67
67
|
test('highlight escapes code content', () => {
|
|
68
68
|
assert.equal(highlight('a < b', 'bash'), 'a < b');
|
|
69
69
|
});
|
|
70
|
+
|
|
71
|
+
test('speechText saca bloques de codigo y tablas', () => {
|
|
72
|
+
const md = [
|
|
73
|
+
'Te dejo el resumen.',
|
|
74
|
+
'',
|
|
75
|
+
'```js',
|
|
76
|
+
'const x = 1;',
|
|
77
|
+
'```',
|
|
78
|
+
'',
|
|
79
|
+
'Y una tabla:',
|
|
80
|
+
'',
|
|
81
|
+
'| a | b |',
|
|
82
|
+
'| - | - |',
|
|
83
|
+
'| 1 | 2 |',
|
|
84
|
+
'',
|
|
85
|
+
'Fin.',
|
|
86
|
+
].join('\n');
|
|
87
|
+
const spoken = speechText(md);
|
|
88
|
+
assert.doesNotMatch(spoken, /const x/);
|
|
89
|
+
assert.doesNotMatch(spoken, /1 \| 2/);
|
|
90
|
+
assert.match(spoken, /Te dejo el resumen\./);
|
|
91
|
+
assert.match(spoken, /Fin\./);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('speechText limpia marcas de markdown y aplasta espacios', () => {
|
|
95
|
+
const spoken = speechText('# Título\n\n**Nombre:** Daniel\n\n- uno\n- dos');
|
|
96
|
+
assert.equal(spoken, 'Título Nombre: Daniel uno dos');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('speechText mantiene el texto de inline code', () => {
|
|
100
|
+
assert.equal(speechText('Corré `npm test` y listo.'), 'Corré npm test y listo.');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('speechText recorta en un limite con maxChars', () => {
|
|
104
|
+
const spoken = speechText(`${'hola '.repeat(40)}`, { maxChars: 50 });
|
|
105
|
+
assert.ok(spoken.length <= 51);
|
|
106
|
+
assert.match(spoken, /…$/);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('speechText devuelve vacio si solo hay codigo', () => {
|
|
110
|
+
assert.equal(speechText('```\nsolo codigo\n```'), '');
|
|
111
|
+
});
|
|
@@ -6,7 +6,7 @@ import { mkdtemp, mkdir, writeFile, readFile, stat } from 'node:fs/promises';
|
|
|
6
6
|
import { tmpdir } from 'node:os';
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { createTamnoraServer } from '../src/index.js';
|
|
9
|
-
import { setHome, ensureApp, readApp, writeApp, makeUser } from '../../core/src/index.js';
|
|
9
|
+
import { setHome, ensureApp, readApp, writeApp, makeUser, screenshotsDir } from '../../core/src/index.js';
|
|
10
10
|
import { completionFooter } from '../src/lib/channels/telegram.js';
|
|
11
11
|
|
|
12
12
|
const fakeProvider = {
|
|
@@ -1792,7 +1792,21 @@ test('plan mode exposes only read-only tools and adds guidance', async () => {
|
|
|
1792
1792
|
body: JSON.stringify({ input: 'planificá', model: 'm', mode: 'plan' }),
|
|
1793
1793
|
})
|
|
1794
1794
|
).text();
|
|
1795
|
-
assert.deepEqual(captured.tools, [
|
|
1795
|
+
assert.deepEqual(captured.tools, [
|
|
1796
|
+
'console_errors',
|
|
1797
|
+
'fetch_url',
|
|
1798
|
+
'git_diff',
|
|
1799
|
+
'git_log',
|
|
1800
|
+
'git_status',
|
|
1801
|
+
'glob',
|
|
1802
|
+
'grep',
|
|
1803
|
+
'list',
|
|
1804
|
+
'preview_url',
|
|
1805
|
+
'read',
|
|
1806
|
+
'screenshot',
|
|
1807
|
+
'speak',
|
|
1808
|
+
'task',
|
|
1809
|
+
]);
|
|
1796
1810
|
assert.match(captured.system, /modo PLAN/);
|
|
1797
1811
|
} finally {
|
|
1798
1812
|
await instance.close();
|
|
@@ -2026,16 +2040,38 @@ test('truncates a session from a message id', async () => {
|
|
|
2026
2040
|
const firstId = detail.messages[0].id;
|
|
2027
2041
|
assert.ok(firstId);
|
|
2028
2042
|
|
|
2029
|
-
const res = await fetch(`${base}/api/sessions/${sessionId}/truncate`, {
|
|
2030
|
-
method: 'POST',
|
|
2031
|
-
headers: { 'Content-Type': 'application/json' },
|
|
2032
|
-
body: JSON.stringify({ fromId: firstId }),
|
|
2033
|
-
});
|
|
2034
|
-
assert.equal(res.status, 200);
|
|
2035
|
-
|
|
2036
|
-
detail = await (await fetch(`${base}/api/sessions/${sessionId}`)).json();
|
|
2037
|
-
assert.equal(detail.messages.length, 0);
|
|
2038
|
-
} finally {
|
|
2039
|
-
await instance.close();
|
|
2040
|
-
}
|
|
2043
|
+
const res = await fetch(`${base}/api/sessions/${sessionId}/truncate`, {
|
|
2044
|
+
method: 'POST',
|
|
2045
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2046
|
+
body: JSON.stringify({ fromId: firstId }),
|
|
2047
|
+
});
|
|
2048
|
+
assert.equal(res.status, 200);
|
|
2049
|
+
|
|
2050
|
+
detail = await (await fetch(`${base}/api/sessions/${sessionId}`)).json();
|
|
2051
|
+
assert.equal(detail.messages.length, 0);
|
|
2052
|
+
} finally {
|
|
2053
|
+
await instance.close();
|
|
2054
|
+
}
|
|
2055
|
+
});
|
|
2056
|
+
|
|
2057
|
+
test('serves browser screenshots from the home dir, confined to png names', async () => {
|
|
2058
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-shots-'));
|
|
2059
|
+
const { instance, base } = await startServer({ cwd });
|
|
2060
|
+
try {
|
|
2061
|
+
const dir = screenshotsDir();
|
|
2062
|
+
await mkdir(dir, { recursive: true });
|
|
2063
|
+
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
2064
|
+
await writeFile(path.join(dir, 'abc-123.png'), png);
|
|
2065
|
+
|
|
2066
|
+
const ok = await fetch(`${base}/api/screenshots/abc-123.png`);
|
|
2067
|
+
assert.equal(ok.status, 200);
|
|
2068
|
+
assert.equal(ok.headers.get('content-type'), 'image/png');
|
|
2069
|
+
assert.equal((await ok.arrayBuffer()).byteLength, png.length);
|
|
2070
|
+
|
|
2071
|
+
assert.equal((await fetch(`${base}/api/screenshots/bad.txt`)).status, 400);
|
|
2072
|
+
assert.equal((await fetch(`${base}/api/screenshots/missing.png`)).status, 404);
|
|
2073
|
+
} finally {
|
|
2074
|
+
await instance.close();
|
|
2075
|
+
}
|
|
2041
2076
|
});
|
|
2077
|
+
|
|
@@ -6,7 +6,7 @@ import path from 'node:path';
|
|
|
6
6
|
import { Store } from '../../core/src/index.js';
|
|
7
7
|
import { createApprovalBroker } from '../src/lib/approvals.js';
|
|
8
8
|
import { createPresence } from '../src/lib/presence.js';
|
|
9
|
-
import { startTelegramChannel } from '../src/lib/channels/telegram.js';
|
|
9
|
+
import { startTelegramChannel, describePermission } from '../src/lib/channels/telegram.js';
|
|
10
10
|
|
|
11
11
|
const reply = (data) => ({ ok: true, json: async () => data });
|
|
12
12
|
|
|
@@ -227,15 +227,36 @@ test('notify de permiso incluye el proyecto y los botones', async () => {
|
|
|
227
227
|
log: () => {},
|
|
228
228
|
});
|
|
229
229
|
try {
|
|
230
|
-
channel.notify(sessionId, {
|
|
231
|
-
|
|
232
|
-
|
|
230
|
+
channel.notify(sessionId, {
|
|
231
|
+
type: 'permission',
|
|
232
|
+
data: { askId: 'ask-1', sessionId, name: 'bash', args: { command: 'npm test' } },
|
|
233
|
+
});
|
|
234
|
+
await waitFor(() => mock.sent.some((m) => /Permiso:/.test(m.text ?? '')));
|
|
235
|
+
const msg = mock.sent.find((m) => /Permiso:/.test(m.text ?? ''));
|
|
233
236
|
assert.match(msg.text, new RegExp(`\\[${path.basename(cwd)}\\]`));
|
|
237
|
+
assert.match(msg.text, /ejecutar un comando/);
|
|
238
|
+
assert.match(msg.text, /npm test/);
|
|
234
239
|
const datas = msg.reply_markup.inline_keyboard.flat().map((b) => b.callback_data);
|
|
235
240
|
assert.ok(datas.includes('perm:ask-1:allow'));
|
|
241
|
+
assert.ok(datas.includes('perm:ask-1:all'));
|
|
242
|
+
assert.ok(datas.includes('perm:ask-1:deny'));
|
|
236
243
|
} finally {
|
|
237
244
|
channel.stop();
|
|
238
245
|
mock.restore();
|
|
239
246
|
ctx.store.close();
|
|
240
247
|
}
|
|
241
248
|
});
|
|
249
|
+
|
|
250
|
+
test('describePermission explica la accion sin volcar el JSON', () => {
|
|
251
|
+
assert.deepEqual(describePermission('bash', { command: 'npm test' }), {
|
|
252
|
+
action: 'ejecutar un comando en el proyecto',
|
|
253
|
+
detail: 'npm test',
|
|
254
|
+
});
|
|
255
|
+
const write = describePermission('write', { path: 'nota.txt', content: 'hola mundo' });
|
|
256
|
+
assert.match(write.action, /escribir el archivo nota\.txt/);
|
|
257
|
+
assert.match(write.detail, /Contenido: 10 B/);
|
|
258
|
+
assert.doesNotMatch(write.detail, /hola mundo/);
|
|
259
|
+
assert.match(describePermission('edit', { path: 'a.js', oldString: 'const a = 1;' }).detail, /const a = 1;/);
|
|
260
|
+
assert.match(describePermission('screenshot', { url: 'http://127.0.0.1:5173' }).detail, /127\.0\.0\.1/);
|
|
261
|
+
assert.match(describePermission('desconocida', { x: 1 }).action, /desconocida/);
|
|
262
|
+
});
|