@danieltmn/tamnora 0.7.1 → 0.9.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 +95 -0
- package/README.md +9 -0
- package/SECURITY.md +11 -6
- package/harness/packages/cli/src/cli.js +1 -1
- package/harness/packages/cli/src/commands/bridge.js +180 -119
- package/harness/packages/cli/src/commands/run.js +1 -1
- package/harness/packages/cli/src/commands/server.js +26 -50
- package/harness/packages/cli/src/commands/tui.js +1 -1
- package/harness/packages/cli/src/lib/daemon.js +92 -40
- package/harness/packages/cli/test/release.test.js +9 -0
- package/harness/packages/core/src/agent/modes.js +1 -1
- package/harness/packages/core/src/agent/runAgent.js +3 -0
- package/harness/packages/core/src/git/index.js +177 -0
- package/harness/packages/core/src/index.js +16 -1
- package/harness/packages/core/src/open.js +43 -0
- package/harness/packages/core/src/pid.js +35 -0
- package/harness/packages/core/src/procs/kill.js +95 -0
- package/harness/packages/core/src/tools/executors.js +16 -0
- package/harness/packages/core/src/tools/labels.js +2 -0
- package/harness/packages/core/src/tools/permissions.js +1 -0
- package/harness/packages/core/src/tools/schema.js +17 -0
- package/harness/packages/core/src/tts/edge.js +232 -0
- package/harness/packages/core/src/tunnel.js +180 -19
- package/harness/packages/core/test/open.test.js +19 -0
- package/harness/packages/core/test/procs.test.js +50 -0
- package/harness/packages/core/test/tools.test.js +12 -0
- package/harness/packages/core/test/tts.test.js +41 -0
- package/harness/packages/server/public/app.js +4715 -3669
- package/harness/packages/server/public/index.html +94 -5
- package/harness/packages/server/public/landing/index.html +5 -5
- package/harness/packages/server/public/reconcile.js +121 -0
- package/harness/packages/server/public/relayClient.js +170 -0
- package/harness/packages/server/public/runStream.js +207 -0
- package/harness/packages/server/public/styles.css +196 -23
- package/harness/packages/server/src/auth.js +29 -5
- package/harness/packages/server/src/lib/agentRun.js +77 -0
- package/harness/packages/server/src/lib/announcer.js +92 -0
- package/harness/packages/server/src/lib/approvals.js +25 -3
- package/harness/packages/server/src/lib/channels/telegram.js +85 -20
- package/harness/packages/server/src/lib/ffmpeg.js +48 -0
- package/harness/packages/server/src/lib/procs.js +3 -18
- package/harness/packages/server/src/lib/runManager.js +24 -2
- package/harness/packages/server/src/lib/sessionView.js +43 -0
- package/harness/packages/server/src/lib/tasks.js +4 -9
- package/harness/packages/server/src/lib/ttsSettings.js +42 -0
- package/harness/packages/server/src/lib/workspace.js +37 -1
- package/harness/packages/server/src/routes/chat.js +51 -47
- package/harness/packages/server/src/routes/events.js +35 -0
- package/harness/packages/server/src/routes/files.js +22 -26
- package/harness/packages/server/src/routes/folders.js +9 -17
- package/harness/packages/server/src/routes/git.js +35 -0
- package/harness/packages/server/src/routes/procs.js +25 -15
- package/harness/packages/server/src/routes/sessions.js +12 -19
- package/harness/packages/server/src/routes/shell.js +19 -0
- package/harness/packages/server/src/routes/state.js +32 -0
- package/harness/packages/server/src/routes/tts.js +82 -0
- package/harness/packages/server/src/server.js +25 -1
- package/harness/packages/server/test/announcer.test.js +51 -0
- package/harness/packages/server/test/reconcile.test.js +146 -0
- package/harness/packages/server/test/relay.test.js +219 -0
- package/harness/packages/server/test/runStream.test.js +245 -0
- package/harness/packages/server/test/security.test.js +252 -0
- package/harness/packages/server/test/server.test.js +617 -1
- package/harness/packages/server/test/telegram-voz.test.js +156 -0
- package/harness/packages/server/test/tts.test.js +133 -0
- package/package.json +1 -1
|
@@ -0,0 +1,156 @@
|
|
|
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 { Store } from '../../core/src/index.js';
|
|
7
|
+
import { createApprovalBroker } from '../src/lib/approvals.js';
|
|
8
|
+
import { startTelegramChannel } from '../src/lib/channels/telegram.js';
|
|
9
|
+
|
|
10
|
+
const reply = (data) => ({ ok: true, json: async () => data });
|
|
11
|
+
|
|
12
|
+
const FAKE_FRAME = new TextEncoder().encode('Path:audio\r\nFFF364MP3').buffer;
|
|
13
|
+
|
|
14
|
+
/** WebSocket falso: entrega un frame de audio y cierra con turn.end. */
|
|
15
|
+
class FakeWebSocket {
|
|
16
|
+
constructor() {
|
|
17
|
+
this.listeners = new Map();
|
|
18
|
+
this.binaryType = 'arraybuffer';
|
|
19
|
+
setTimeout(() => {
|
|
20
|
+
this.fire('open', {});
|
|
21
|
+
this.fire('message', FAKE_FRAME);
|
|
22
|
+
this.fire('message', 'X-RequestId:1\r\nPath:turn.end\r\n');
|
|
23
|
+
}, 0);
|
|
24
|
+
}
|
|
25
|
+
addEventListener(type, fn) {
|
|
26
|
+
if (!this.listeners.has(type)) this.listeners.set(type, new Set());
|
|
27
|
+
this.listeners.get(type).add(fn);
|
|
28
|
+
}
|
|
29
|
+
removeEventListener() {}
|
|
30
|
+
send() {}
|
|
31
|
+
close() {
|
|
32
|
+
this.fire('close', {});
|
|
33
|
+
}
|
|
34
|
+
fire(type, arg) {
|
|
35
|
+
for (const fn of this.listeners.get(type) ?? []) fn({ data: arg, message: 'fake' });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Mock de fetch que registra los envios multipart (sendVoice/sendAudio). */
|
|
40
|
+
function installVozFetchMock() {
|
|
41
|
+
const sent = [];
|
|
42
|
+
const multipart = [];
|
|
43
|
+
const updates = [];
|
|
44
|
+
const original = global.fetch;
|
|
45
|
+
global.fetch = async (url, opts = {}) => {
|
|
46
|
+
const method = String(url).split('/').pop();
|
|
47
|
+
if (opts.body instanceof FormData) {
|
|
48
|
+
multipart.push({ method, chatId: String(opts.body.get('chat_id') ?? '') });
|
|
49
|
+
return reply({ ok: true, result: true });
|
|
50
|
+
}
|
|
51
|
+
const body = opts.body ? JSON.parse(opts.body) : {};
|
|
52
|
+
if (method === 'getUpdates') {
|
|
53
|
+
const out = updates.splice(0, updates.length);
|
|
54
|
+
if (!out.length) await new Promise((resolve) => setTimeout(resolve, 20));
|
|
55
|
+
return reply({ ok: true, result: out });
|
|
56
|
+
}
|
|
57
|
+
if (method === 'sendMessage' || method === 'editMessageText') {
|
|
58
|
+
sent.push(body);
|
|
59
|
+
return reply({ ok: true, result: { message_id: sent.length } });
|
|
60
|
+
}
|
|
61
|
+
return reply({ ok: true, result: true });
|
|
62
|
+
};
|
|
63
|
+
return { sent, updates, multipart, restore: () => { global.fetch = original; } };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function makeCtx(cwd, app = {}) {
|
|
67
|
+
const store = new Store(':memory:');
|
|
68
|
+
const provider = {
|
|
69
|
+
id: 'fake',
|
|
70
|
+
async streamChat(_config, { onEvent }) {
|
|
71
|
+
onEvent?.({ type: 'text', delta: 'listo', full: 'listo' });
|
|
72
|
+
return { content: 'listo', reasoning: '', toolCalls: [] };
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
return {
|
|
76
|
+
cwd,
|
|
77
|
+
config: { maxRounds: 2, permissions: {}, telegram: {} },
|
|
78
|
+
app,
|
|
79
|
+
store,
|
|
80
|
+
approvals: createApprovalBroker({ timeoutMs: 1000 }),
|
|
81
|
+
effectiveProviderId: () => 'fake',
|
|
82
|
+
effectiveModel: () => 'm',
|
|
83
|
+
effectiveMode: () => 'agent',
|
|
84
|
+
providerSettings: () => ({ baseUrl: 'http://127.0.0.1:1', apiKey: '' }),
|
|
85
|
+
apiKeyFor: () => '',
|
|
86
|
+
customProviderDefs: () => [],
|
|
87
|
+
resolveProvider: () => ({ provider, config: {} }),
|
|
88
|
+
getMcp: async () => null,
|
|
89
|
+
systemPromptFor: async () => 'sys',
|
|
90
|
+
agentTools: { definitions: [], handles: () => false, execute: async () => '' },
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function waitFor(fn, timeout = 3000) {
|
|
95
|
+
const start = Date.now();
|
|
96
|
+
while (Date.now() - start < timeout) {
|
|
97
|
+
if (fn()) return;
|
|
98
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
99
|
+
}
|
|
100
|
+
throw new Error('timeout esperando la condición');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
test('con la voz activada, el turno de Telegram termina con una nota de voz', async () => {
|
|
104
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-tgvoz-'));
|
|
105
|
+
const ctx = makeCtx(cwd, { tts: { enabled: true, voice: 'es-AR-ElenaNeural', rate: '+0%', announce: { end: true } } });
|
|
106
|
+
const mock = installVozFetchMock();
|
|
107
|
+
const original = global.WebSocket;
|
|
108
|
+
global.WebSocket = FakeWebSocket;
|
|
109
|
+
const channel = startTelegramChannel(ctx, { token: 'x', allowedChatIds: ['7'], log: () => {} });
|
|
110
|
+
try {
|
|
111
|
+
mock.updates.push({ update_id: 1, message: { chat: { id: 7 }, text: 'hola' } });
|
|
112
|
+
await waitFor(() => mock.multipart.length > 0);
|
|
113
|
+
const nota = mock.multipart[0];
|
|
114
|
+
assert.equal(nota.chatId, '7');
|
|
115
|
+
assert.match(nota.method, /^send(Voice|Audio)$/);
|
|
116
|
+
} finally {
|
|
117
|
+
global.WebSocket = original;
|
|
118
|
+
channel.stop();
|
|
119
|
+
mock.restore();
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('con la voz apagada, el turno de Telegram termina sin audios', async () => {
|
|
124
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-tgvoz-'));
|
|
125
|
+
const ctx = makeCtx(cwd, {});
|
|
126
|
+
const mock = installVozFetchMock();
|
|
127
|
+
const channel = startTelegramChannel(ctx, { token: 'x', allowedChatIds: ['7'], log: () => {} });
|
|
128
|
+
try {
|
|
129
|
+
mock.updates.push({ update_id: 1, message: { chat: { id: 7 }, text: 'hola' } });
|
|
130
|
+
await waitFor(() => mock.sent.some((m) => /✓ terminado/.test(m.text ?? '')));
|
|
131
|
+
assert.equal(mock.multipart.length, 0);
|
|
132
|
+
} finally {
|
|
133
|
+
channel.stop();
|
|
134
|
+
mock.restore();
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('la tool speak manda una nota de voz aunque la narración esté apagada', async () => {
|
|
139
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-tgspeak-'));
|
|
140
|
+
const ctx = makeCtx(cwd, {});
|
|
141
|
+
const mock = installVozFetchMock();
|
|
142
|
+
const original = global.WebSocket;
|
|
143
|
+
global.WebSocket = FakeWebSocket;
|
|
144
|
+
const channel = startTelegramChannel(ctx, { token: 'x', allowedChatIds: ['7'], log: () => {} });
|
|
145
|
+
try {
|
|
146
|
+
ctx.store.setSetting('telegram:session:sess-1', '7');
|
|
147
|
+
channel.notify('sess-1', { type: 'tool_call', data: { name: 'speak', args: { texto: 'Hola, esto es una prueba' } } });
|
|
148
|
+
await waitFor(() => mock.multipart.length > 0);
|
|
149
|
+
assert.equal(mock.multipart[0].chatId, '7');
|
|
150
|
+
assert.match(mock.multipart[0].method, /^send(Voice|Audio)$/);
|
|
151
|
+
} finally {
|
|
152
|
+
global.WebSocket = original;
|
|
153
|
+
channel.stop();
|
|
154
|
+
mock.restore();
|
|
155
|
+
}
|
|
156
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
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 { createTamnoraServer } from '../src/index.js';
|
|
7
|
+
import { setHome } from '../../core/src/index.js';
|
|
8
|
+
|
|
9
|
+
const FAKE_MP3 = 'FFF364MP3DEPRUEBA';
|
|
10
|
+
|
|
11
|
+
/** Frame binario exacto (sin el pool de Buffer, que arrastra bytes ajenos). */
|
|
12
|
+
const FAKE_FRAME = new TextEncoder().encode(`Path:audio\r\n${FAKE_MP3}`).buffer;
|
|
13
|
+
|
|
14
|
+
/** WebSocket falso: entrega un frame de audio y cierra con turn.end. */
|
|
15
|
+
class FakeWebSocket {
|
|
16
|
+
constructor() {
|
|
17
|
+
this.listeners = new Map();
|
|
18
|
+
this.binaryType = 'arraybuffer';
|
|
19
|
+
setTimeout(() => {
|
|
20
|
+
this.fire('open', {});
|
|
21
|
+
this.fire('message', FAKE_FRAME);
|
|
22
|
+
this.fire('message', 'X-RequestId:1\r\nPath:turn.end\r\n');
|
|
23
|
+
}, 0);
|
|
24
|
+
}
|
|
25
|
+
addEventListener(type, fn) {
|
|
26
|
+
if (!this.listeners.has(type)) this.listeners.set(type, new Set());
|
|
27
|
+
this.listeners.get(type).add(fn);
|
|
28
|
+
}
|
|
29
|
+
removeEventListener() {}
|
|
30
|
+
send() {}
|
|
31
|
+
close() {
|
|
32
|
+
this.fire('close', {});
|
|
33
|
+
}
|
|
34
|
+
fire(type, arg) {
|
|
35
|
+
for (const fn of this.listeners.get(type) ?? []) fn({ data: arg, message: 'fake' });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** WebSocket falso que falla: para probar el 502 de la ruta. */
|
|
40
|
+
class BrokenWebSocket {
|
|
41
|
+
constructor() {
|
|
42
|
+
this.listeners = new Map();
|
|
43
|
+
setTimeout(() => this.fire('error', {}), 0);
|
|
44
|
+
}
|
|
45
|
+
addEventListener(type, fn) {
|
|
46
|
+
if (!this.listeners.has(type)) this.listeners.set(type, new Set());
|
|
47
|
+
this.listeners.get(type).add(fn);
|
|
48
|
+
}
|
|
49
|
+
removeEventListener() {}
|
|
50
|
+
send() {}
|
|
51
|
+
close() {}
|
|
52
|
+
fire(type, arg) {
|
|
53
|
+
for (const fn of this.listeners.get(type) ?? []) fn({ data: arg, message: 'boom' });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function startServer() {
|
|
58
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-tts-'));
|
|
59
|
+
setHome(cwd);
|
|
60
|
+
const instance = await createTamnoraServer({
|
|
61
|
+
cwd,
|
|
62
|
+
dbPath: path.join(cwd, 'tamnora.db'),
|
|
63
|
+
loadMcp: false,
|
|
64
|
+
resolveProvider: () => ({ provider: { id: 'fake' }, config: {} }),
|
|
65
|
+
});
|
|
66
|
+
const info = await instance.listen({ port: 0 });
|
|
67
|
+
return { instance, base: info.url };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
test('los ajustes de voz exponen defaults y sanitizan lo guardado', async () => {
|
|
71
|
+
const { instance, base } = await startServer();
|
|
72
|
+
const original = global.WebSocket;
|
|
73
|
+
global.WebSocket = FakeWebSocket;
|
|
74
|
+
try {
|
|
75
|
+
const initial = await (await fetch(`${base}/api/tts/settings`)).json();
|
|
76
|
+
assert.equal(initial.enabled, false);
|
|
77
|
+
assert.equal(initial.voice, 'es-AR-ElenaNeural');
|
|
78
|
+
assert.equal(initial.announce.start, true);
|
|
79
|
+
assert.equal(initial.announce.tools, true);
|
|
80
|
+
assert.equal(initial.announce.end, true);
|
|
81
|
+
|
|
82
|
+
const put = await fetch(`${base}/api/tts/settings`, {
|
|
83
|
+
method: 'PUT',
|
|
84
|
+
headers: { 'Content-Type': 'application/json' },
|
|
85
|
+
body: JSON.stringify({ enabled: true, voice: '<script>', rate: '150%' }),
|
|
86
|
+
});
|
|
87
|
+
const saved = await put.json();
|
|
88
|
+
assert.equal(saved.enabled, true);
|
|
89
|
+
assert.equal(saved.voice, 'es-AR-ElenaNeural');
|
|
90
|
+
assert.equal(saved.rate, '+100%');
|
|
91
|
+
|
|
92
|
+
const reread = await (await fetch(`${base}/api/tts/settings`)).json();
|
|
93
|
+
assert.equal(reread.enabled, true);
|
|
94
|
+
assert.equal(reread.rate, '+100%');
|
|
95
|
+
} finally {
|
|
96
|
+
global.WebSocket = original;
|
|
97
|
+
await instance.close();
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('la sintesis exige texto y entrega MP3', async () => {
|
|
102
|
+
const { instance, base } = await startServer();
|
|
103
|
+
const original = global.WebSocket;
|
|
104
|
+
global.WebSocket = FakeWebSocket;
|
|
105
|
+
try {
|
|
106
|
+
const falta = await fetch(`${base}/api/tts`);
|
|
107
|
+
assert.equal(falta.status, 400);
|
|
108
|
+
|
|
109
|
+
const res = await fetch(`${base}/api/tts?texto=${encodeURIComponent('hola')}`);
|
|
110
|
+
assert.equal(res.status, 200);
|
|
111
|
+
assert.equal(res.headers.get('content-type'), 'audio/mpeg');
|
|
112
|
+
const body = Buffer.from(await res.arrayBuffer());
|
|
113
|
+
assert.equal(body.toString('utf8'), FAKE_MP3);
|
|
114
|
+
} finally {
|
|
115
|
+
global.WebSocket = original;
|
|
116
|
+
await instance.close();
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('un fallo del servicio de voz se ve como 502, no como crash', async () => {
|
|
121
|
+
const { instance, base } = await startServer();
|
|
122
|
+
const original = global.WebSocket;
|
|
123
|
+
global.WebSocket = BrokenWebSocket;
|
|
124
|
+
try {
|
|
125
|
+
const res = await fetch(`${base}/api/tts?texto=${encodeURIComponent('hola')}`);
|
|
126
|
+
assert.equal(res.status, 502);
|
|
127
|
+
const data = await res.json();
|
|
128
|
+
assert.match(data.error, /No pude sintetizar/);
|
|
129
|
+
} finally {
|
|
130
|
+
global.WebSocket = original;
|
|
131
|
+
await instance.close();
|
|
132
|
+
}
|
|
133
|
+
});
|
package/package.json
CHANGED