@danieltmn/tamnora 0.5.1 → 0.6.1
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 +14 -0
- package/README.md +30 -0
- package/harness/packages/core/src/config/index.js +1 -0
- package/harness/packages/core/src/index.js +1 -0
- package/harness/packages/core/src/storage/sqlite.js +108 -2
- package/harness/packages/core/src/tools/labels.js +83 -0
- package/harness/packages/core/test/labels.test.js +34 -0
- package/harness/packages/server/public/app.js +527 -111
- package/harness/packages/server/public/index.html +36 -1
- package/harness/packages/server/public/styles.css +97 -4
- package/harness/packages/server/src/lib/channels/index.js +89 -0
- package/harness/packages/server/src/lib/channels/telegram.js +532 -0
- package/harness/packages/server/src/lib/http.js +3 -2
- package/harness/packages/server/src/lib/runManager.js +102 -0
- package/harness/packages/server/src/lib/tasks.js +126 -0
- package/harness/packages/server/src/routes/channels.js +47 -0
- package/harness/packages/server/src/routes/chat.js +166 -77
- package/harness/packages/server/src/routes/sessions.js +17 -0
- package/harness/packages/server/src/server.js +15 -16
- package/harness/packages/server/test/server.test.js +213 -1
- package/harness/packages/server/test/telegram.test.js +94 -0
- package/harness/tamnora.example.json +2 -1
- package/package.json +1 -1
- package/harness/packages/server/src/lib/telegram.js +0 -267
|
@@ -6,6 +6,7 @@ import { tmpdir } from 'node:os';
|
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { createTamnoraServer } from '../src/index.js';
|
|
8
8
|
import { setHome, ensureApp, readApp, writeApp, makeUser } from '../../core/src/index.js';
|
|
9
|
+
import { completionFooter } from '../src/lib/channels/telegram.js';
|
|
9
10
|
|
|
10
11
|
const fakeProvider = {
|
|
11
12
|
id: 'fake',
|
|
@@ -141,6 +142,172 @@ test('streams a chat turn over SSE and persists the messages', async () => {
|
|
|
141
142
|
}
|
|
142
143
|
});
|
|
143
144
|
|
|
145
|
+
test('persists the run log and replays it on the stream endpoint', async () => {
|
|
146
|
+
const { instance, base } = await startServer();
|
|
147
|
+
const json = { 'Content-Type': 'application/json' };
|
|
148
|
+
try {
|
|
149
|
+
await (
|
|
150
|
+
await fetch(`${base}/api/chat`, {
|
|
151
|
+
method: 'POST',
|
|
152
|
+
headers: json,
|
|
153
|
+
body: JSON.stringify({ input: 'hola', model: 'm' }),
|
|
154
|
+
})
|
|
155
|
+
).text();
|
|
156
|
+
const list = await (await fetch(`${base}/api/sessions`)).json();
|
|
157
|
+
const id = list.sessions[0].id;
|
|
158
|
+
|
|
159
|
+
const stream = await (await fetch(`${base}/api/sessions/${id}/stream?from=0`)).text();
|
|
160
|
+
assert.match(stream, /event: start/);
|
|
161
|
+
assert.match(stream, /event: text/);
|
|
162
|
+
assert.match(stream, /event: done/);
|
|
163
|
+
assert.match(stream, /event: idle/);
|
|
164
|
+
assert.match(stream, /^id: \d+$/m);
|
|
165
|
+
} finally {
|
|
166
|
+
await instance.close();
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('keeps a run alive after the client disconnects and can stop it', async () => {
|
|
171
|
+
let release;
|
|
172
|
+
const gate = new Promise((resolve) => (release = resolve));
|
|
173
|
+
const slowProvider = {
|
|
174
|
+
id: 'slow',
|
|
175
|
+
async streamChat(_config, { onEvent, signal }) {
|
|
176
|
+
onEvent?.({ type: 'text', delta: '…', full: '…' });
|
|
177
|
+
await new Promise((resolve, reject) => {
|
|
178
|
+
if (signal?.aborted) return reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
|
|
179
|
+
signal?.addEventListener('abort', () =>
|
|
180
|
+
reject(Object.assign(new Error('aborted'), { name: 'AbortError' }))
|
|
181
|
+
);
|
|
182
|
+
gate.then(resolve);
|
|
183
|
+
});
|
|
184
|
+
return { content: 'fin', reasoning: '', toolCalls: [] };
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
const { instance, base } = await startServer({ resolveProvider: () => ({ provider: slowProvider, config: {} }) });
|
|
188
|
+
const json = { 'Content-Type': 'application/json' };
|
|
189
|
+
try {
|
|
190
|
+
const created = await (
|
|
191
|
+
await fetch(`${base}/api/sessions`, {
|
|
192
|
+
method: 'POST',
|
|
193
|
+
headers: json,
|
|
194
|
+
body: JSON.stringify({ model: 'm' }),
|
|
195
|
+
})
|
|
196
|
+
).json();
|
|
197
|
+
const id = created.session.id;
|
|
198
|
+
|
|
199
|
+
const controller = new AbortController();
|
|
200
|
+
const chat = fetch(`${base}/api/chat`, {
|
|
201
|
+
method: 'POST',
|
|
202
|
+
headers: json,
|
|
203
|
+
body: JSON.stringify({ input: 'hola', model: 'm', sessionId: id }),
|
|
204
|
+
signal: controller.signal,
|
|
205
|
+
}).catch(() => null);
|
|
206
|
+
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
207
|
+
controller.abort();
|
|
208
|
+
await chat;
|
|
209
|
+
|
|
210
|
+
const during = await (await fetch(`${base}/api/sessions/${id}`)).json();
|
|
211
|
+
assert.equal(during.running, true);
|
|
212
|
+
|
|
213
|
+
const stopped = await (
|
|
214
|
+
await fetch(`${base}/api/sessions/${id}/stop`, { method: 'POST', headers: json })
|
|
215
|
+
).json();
|
|
216
|
+
assert.equal(stopped.stopped, true);
|
|
217
|
+
|
|
218
|
+
release();
|
|
219
|
+
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
220
|
+
const after = await (await fetch(`${base}/api/sessions/${id}`)).json();
|
|
221
|
+
assert.equal(after.running, false);
|
|
222
|
+
} finally {
|
|
223
|
+
release();
|
|
224
|
+
await instance.close();
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('done reports max_rounds and the tool count when the model never answers', async () => {
|
|
229
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-loop-'));
|
|
230
|
+
await writeFile(path.join(cwd, 'a.txt'), 'hola');
|
|
231
|
+
let seq = 0;
|
|
232
|
+
const provider = {
|
|
233
|
+
id: 'loop',
|
|
234
|
+
async streamChat() {
|
|
235
|
+
seq += 1;
|
|
236
|
+
return {
|
|
237
|
+
content: '',
|
|
238
|
+
reasoning: '',
|
|
239
|
+
toolCalls: [
|
|
240
|
+
{ id: `call_${seq}`, function: { name: 'read', arguments: JSON.stringify({ path: 'a.txt' }) } },
|
|
241
|
+
],
|
|
242
|
+
};
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
|
|
246
|
+
try {
|
|
247
|
+
const sse = await (
|
|
248
|
+
await fetch(`${base}/api/chat`, {
|
|
249
|
+
method: 'POST',
|
|
250
|
+
headers: { 'Content-Type': 'application/json' },
|
|
251
|
+
body: JSON.stringify({ input: 'leé', model: 'm', maxRounds: 2 }),
|
|
252
|
+
})
|
|
253
|
+
).text();
|
|
254
|
+
assert.match(sse, /event: done/);
|
|
255
|
+
assert.match(sse, /"stopReason":"max_rounds"/);
|
|
256
|
+
const match = sse.match(/"toolCalls":(\d+)/);
|
|
257
|
+
assert.ok(match && Number(match[1]) >= 2, 'el done debe reportar las tools usadas');
|
|
258
|
+
assert.match(sse, /"durationMs":\d+/);
|
|
259
|
+
} finally {
|
|
260
|
+
await instance.close();
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('completionFooter resume el cierre del turno', () => {
|
|
265
|
+
assert.equal(completionFooter('end_turn', 1, 0, 0), '✓ terminado · 1 ronda');
|
|
266
|
+
assert.equal(completionFooter('end_turn', 3, 5, 1200), '✓ terminado · 3 rondas · 5 herramientas · 1.2s');
|
|
267
|
+
assert.match(completionFooter('max_rounds', 2, 2, 300), /^⚠ máximo de rondas · 2 rondas · 2 herramientas · 300ms$/);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test('configures Telegram from the console (masked token)', async () => {
|
|
271
|
+
const { instance, base } = await startServer({ telegram: false });
|
|
272
|
+
const json = { 'Content-Type': 'application/json' };
|
|
273
|
+
try {
|
|
274
|
+
let data = await (await fetch(`${base}/api/channels`)).json();
|
|
275
|
+
assert.equal(data.telegram.tokenSet, false);
|
|
276
|
+
assert.equal(data.telegram.transcribeModel, 'whisper-1');
|
|
277
|
+
|
|
278
|
+
let res = await fetch(`${base}/api/channels/telegram`, {
|
|
279
|
+
method: 'PUT',
|
|
280
|
+
headers: json,
|
|
281
|
+
body: JSON.stringify({ allowedChatIds: '123, 456', transcribeModel: '' }),
|
|
282
|
+
});
|
|
283
|
+
data = await res.json();
|
|
284
|
+
assert.deepEqual(data.telegram.allowedChatIds, ['123', '456']);
|
|
285
|
+
assert.equal(data.telegram.transcribeModel, 'whisper-1');
|
|
286
|
+
|
|
287
|
+
res = await fetch(`${base}/api/channels/telegram`, {
|
|
288
|
+
method: 'PUT',
|
|
289
|
+
headers: json,
|
|
290
|
+
body: JSON.stringify({ token: '123456:ABCDEFghij' }),
|
|
291
|
+
});
|
|
292
|
+
data = await res.json();
|
|
293
|
+
assert.equal(data.telegram.tokenSet, true);
|
|
294
|
+
assert.match(data.telegram.tokenHint, /ghij$/);
|
|
295
|
+
assert.doesNotMatch(JSON.stringify(data), /ABCDEF/);
|
|
296
|
+
|
|
297
|
+
// Mandar la mascara no pisa el token real.
|
|
298
|
+
res = await fetch(`${base}/api/channels/telegram`, {
|
|
299
|
+
method: 'PUT',
|
|
300
|
+
headers: json,
|
|
301
|
+
body: JSON.stringify({ token: data.telegram.tokenHint, allowedChatIds: ['1'] }),
|
|
302
|
+
});
|
|
303
|
+
data = await res.json();
|
|
304
|
+
assert.equal(data.telegram.tokenSet, true);
|
|
305
|
+
assert.match(data.telegram.tokenHint, /ghij$/);
|
|
306
|
+
} finally {
|
|
307
|
+
await instance.close();
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
|
|
144
311
|
test('manages MCP servers from the console', async () => {
|
|
145
312
|
const { instance, base } = await startServer();
|
|
146
313
|
const json = { 'Content-Type': 'application/json' };
|
|
@@ -898,13 +1065,58 @@ test('plan mode exposes only read-only tools and adds guidance', async () => {
|
|
|
898
1065
|
body: JSON.stringify({ input: 'planificá', model: 'm', mode: 'plan' }),
|
|
899
1066
|
})
|
|
900
1067
|
).text();
|
|
901
|
-
assert.deepEqual(captured.tools, ['fetch_url', 'git_diff', 'git_log', 'git_status', 'glob', 'grep', 'list', 'read']);
|
|
1068
|
+
assert.deepEqual(captured.tools, ['fetch_url', 'git_diff', 'git_log', 'git_status', 'glob', 'grep', 'list', 'read', 'task']);
|
|
902
1069
|
assert.match(captured.system, /modo PLAN/);
|
|
903
1070
|
} finally {
|
|
904
1071
|
await instance.close();
|
|
905
1072
|
}
|
|
906
1073
|
});
|
|
907
1074
|
|
|
1075
|
+
test('the agent can delegate to a subagent with the task tool', async () => {
|
|
1076
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-task-'));
|
|
1077
|
+
const provider = {
|
|
1078
|
+
id: 'multi',
|
|
1079
|
+
async streamChat(_config, { messages, onEvent }) {
|
|
1080
|
+
const isSub = messages.some((m) => m.role === 'system' && /subagente/i.test(String(m.content)));
|
|
1081
|
+
if (isSub) {
|
|
1082
|
+
onEvent?.({ type: 'text', delta: 'resumen', full: 'resumen subagente' });
|
|
1083
|
+
return { content: 'resumen subagente', reasoning: '', toolCalls: [] };
|
|
1084
|
+
}
|
|
1085
|
+
const done = messages.some((m) => m.role === 'tool' && m.name === 'task');
|
|
1086
|
+
if (done) return { content: 'listo', reasoning: '', toolCalls: [] };
|
|
1087
|
+
return {
|
|
1088
|
+
content: '',
|
|
1089
|
+
reasoning: '',
|
|
1090
|
+
toolCalls: [
|
|
1091
|
+
{ id: 't1', function: { name: 'task', arguments: JSON.stringify({ description: 'explorar', prompt: 'leé el proyecto' }) } },
|
|
1092
|
+
],
|
|
1093
|
+
};
|
|
1094
|
+
},
|
|
1095
|
+
};
|
|
1096
|
+
const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
|
|
1097
|
+
const json = { 'Content-Type': 'application/json' };
|
|
1098
|
+
try {
|
|
1099
|
+
const stream = await (
|
|
1100
|
+
await fetch(`${base}/api/chat`, {
|
|
1101
|
+
method: 'POST',
|
|
1102
|
+
headers: json,
|
|
1103
|
+
body: JSON.stringify({ input: 'delegá', model: 'm' }),
|
|
1104
|
+
})
|
|
1105
|
+
).text();
|
|
1106
|
+
assert.match(stream, /event: agent_start/);
|
|
1107
|
+
assert.match(stream, /event: agent_end/);
|
|
1108
|
+
|
|
1109
|
+
const list = await (await fetch(`${base}/api/sessions`)).json();
|
|
1110
|
+
const id = list.sessions[0].id;
|
|
1111
|
+
const agents = await (await fetch(`${base}/api/sessions/${id}/agents`)).json();
|
|
1112
|
+
assert.equal(agents.agents.length, 1);
|
|
1113
|
+
assert.equal(agents.agents[0].status, 'done');
|
|
1114
|
+
assert.match(agents.agents[0].summary, /resumen subagente/);
|
|
1115
|
+
} finally {
|
|
1116
|
+
await instance.close();
|
|
1117
|
+
}
|
|
1118
|
+
});
|
|
1119
|
+
|
|
908
1120
|
test('aggregates the token usage of a session', async () => {
|
|
909
1121
|
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-tokens-'));
|
|
910
1122
|
const provider = {
|
|
@@ -0,0 +1,94 @@
|
|
|
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
|
+
function installFetchMock() {
|
|
13
|
+
const sent = [];
|
|
14
|
+
const updates = [];
|
|
15
|
+
const original = global.fetch;
|
|
16
|
+
global.fetch = async (url, opts = {}) => {
|
|
17
|
+
const method = String(url).split('/').pop();
|
|
18
|
+
const body = opts.body ? JSON.parse(opts.body) : {};
|
|
19
|
+
if (method === 'getUpdates') {
|
|
20
|
+
const out = updates.splice(0, updates.length);
|
|
21
|
+
if (!out.length) await new Promise((resolve) => setTimeout(resolve, 20));
|
|
22
|
+
return reply({ ok: true, result: out });
|
|
23
|
+
}
|
|
24
|
+
if (method === 'sendMessage' || method === 'editMessageText') {
|
|
25
|
+
sent.push(body);
|
|
26
|
+
return reply({ ok: true, result: { message_id: sent.length } });
|
|
27
|
+
}
|
|
28
|
+
return reply({ ok: true, result: true });
|
|
29
|
+
};
|
|
30
|
+
return { sent, updates, restore: () => { global.fetch = original; } };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function makeCtx(cwd) {
|
|
34
|
+
const store = new Store(':memory:');
|
|
35
|
+
const provider = {
|
|
36
|
+
id: 'fake',
|
|
37
|
+
async streamChat(_config, { onEvent }) {
|
|
38
|
+
onEvent?.({ type: 'text', delta: 'listo', full: 'listo' });
|
|
39
|
+
return { content: 'listo', reasoning: '', toolCalls: [] };
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
return {
|
|
43
|
+
cwd,
|
|
44
|
+
config: { maxRounds: 2, permissions: {}, telegram: {} },
|
|
45
|
+
app: {},
|
|
46
|
+
store,
|
|
47
|
+
approvals: createApprovalBroker({ timeoutMs: 1000 }),
|
|
48
|
+
effectiveProviderId: () => 'fake',
|
|
49
|
+
effectiveModel: () => 'm',
|
|
50
|
+
effectiveMode: () => 'agent',
|
|
51
|
+
providerSettings: () => ({ baseUrl: 'http://127.0.0.1:1', apiKey: '' }),
|
|
52
|
+
apiKeyFor: () => '',
|
|
53
|
+
customProviderDefs: () => [],
|
|
54
|
+
resolveProvider: () => ({ provider, config: {} }),
|
|
55
|
+
getMcp: async () => null,
|
|
56
|
+
systemPromptFor: async () => 'sys',
|
|
57
|
+
agentTools: { definitions: [], handles: () => false, execute: async () => '' },
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function waitFor(fn, timeout = 3000) {
|
|
62
|
+
const start = Date.now();
|
|
63
|
+
while (Date.now() - start < timeout) {
|
|
64
|
+
if (fn()) return;
|
|
65
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
66
|
+
}
|
|
67
|
+
throw new Error('timeout esperando la condición');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
test('el canal de Telegram cierra el turno y muestra el panel', async () => {
|
|
71
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-tg-'));
|
|
72
|
+
const ctx = makeCtx(cwd);
|
|
73
|
+
const mock = installFetchMock();
|
|
74
|
+
const channel = startTelegramChannel(ctx, { token: 'x', allowedChatIds: ['7'], log: () => {} });
|
|
75
|
+
try {
|
|
76
|
+
mock.updates.push({ update_id: 1, message: { chat: { id: 7 }, text: 'hola' } });
|
|
77
|
+
await waitFor(() => mock.sent.some((m) => /✓ terminado/.test(m.text ?? '')));
|
|
78
|
+
const completion = mock.sent.find((m) => /✓ terminado/.test(m.text ?? ''));
|
|
79
|
+
assert.match(completion.text, /listo/);
|
|
80
|
+
assert.match(completion.text, /1 ronda/);
|
|
81
|
+
|
|
82
|
+
mock.updates.push({ update_id: 2, message: { chat: { id: 7 }, text: '/menu' } });
|
|
83
|
+
await waitFor(() => mock.sent.some((m) => m.reply_markup?.inline_keyboard));
|
|
84
|
+
const menu = mock.sent.find((m) => m.reply_markup?.inline_keyboard);
|
|
85
|
+
const datas = menu.reply_markup.inline_keyboard.flat().map((b) => b.callback_data);
|
|
86
|
+
assert.ok(datas.includes('mode:plan'));
|
|
87
|
+
assert.ok(datas.includes('menu:models'));
|
|
88
|
+
assert.ok(datas.includes('menu:stop'));
|
|
89
|
+
} finally {
|
|
90
|
+
channel.stop();
|
|
91
|
+
mock.restore();
|
|
92
|
+
ctx.store.close();
|
|
93
|
+
}
|
|
94
|
+
});
|
|
@@ -28,5 +28,6 @@
|
|
|
28
28
|
"allow": ["npm", "node", "npx", "pnpm", "yarn", "bun", "php", "python", "composer"]
|
|
29
29
|
},
|
|
30
30
|
"preview": { "provider": "tunnelmole" },
|
|
31
|
-
"telegram": { "token": "", "allowedChatIds": [] }
|
|
31
|
+
"telegram": { "token": "", "allowedChatIds": [], "transcribeModel": "whisper-1" },
|
|
32
|
+
"agents": { "maxRounds": 6 }
|
|
32
33
|
}
|
package/package.json
CHANGED
|
@@ -1,267 +0,0 @@
|
|
|
1
|
-
import { runAgent, createTools, resolveMode } from './core.js';
|
|
2
|
-
import { resolveExistingDir } from './workspace.js';
|
|
3
|
-
|
|
4
|
-
const API = 'https://api.telegram.org';
|
|
5
|
-
|
|
6
|
-
const HELP = [
|
|
7
|
-
'Tamnora Harness — bot',
|
|
8
|
-
'',
|
|
9
|
-
'/new — nueva sesión',
|
|
10
|
-
'/status — proveedor, modelo, modo y sesión',
|
|
11
|
-
'/model <id> — cambia el modelo',
|
|
12
|
-
'/provider <id> — cambia el proveedor',
|
|
13
|
-
'/mode plan|build — cambia el modo',
|
|
14
|
-
'/stop — cancela la respuesta en curso',
|
|
15
|
-
'/help — esta ayuda',
|
|
16
|
-
'',
|
|
17
|
-
'Cualquier otro texto se envía al agente.',
|
|
18
|
-
].join('\n');
|
|
19
|
-
|
|
20
|
-
async function tg(token, method, payload) {
|
|
21
|
-
const res = await fetch(`${API}/bot${token}/${method}`, {
|
|
22
|
-
method: 'POST',
|
|
23
|
-
headers: { 'Content-Type': 'application/json' },
|
|
24
|
-
body: JSON.stringify(payload ?? {}),
|
|
25
|
-
});
|
|
26
|
-
let data = null;
|
|
27
|
-
try {
|
|
28
|
-
data = await res.json();
|
|
29
|
-
} catch {
|
|
30
|
-
data = null;
|
|
31
|
-
}
|
|
32
|
-
if (!data?.ok) {
|
|
33
|
-
const error = new Error(data?.description || `HTTP ${res.status}`);
|
|
34
|
-
error.status = res.status;
|
|
35
|
-
throw error;
|
|
36
|
-
}
|
|
37
|
-
return data.result;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function splitMessage(text, max = 3900) {
|
|
41
|
-
const value = String(text ?? '').trim() || '(sin texto)';
|
|
42
|
-
const parts = [];
|
|
43
|
-
for (let i = 0; i < value.length; i += max) parts.push(value.slice(i, i + max));
|
|
44
|
-
return parts;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Bot de Telegram por long polling (no necesita URL publica). Los chats
|
|
51
|
-
* autorizados conversan con el mismo engine del harness; cada chat mapea a una
|
|
52
|
-
* sesion. Devuelve `{ stop }` o `null` si no hay token.
|
|
53
|
-
*/
|
|
54
|
-
export function startTelegramBot(ctx, { token, allowedChatIds = [], log = () => {} } = {}) {
|
|
55
|
-
if (!token) return null;
|
|
56
|
-
const allowed = new Set(allowedChatIds.map(String));
|
|
57
|
-
const controllers = new Map();
|
|
58
|
-
let running = true;
|
|
59
|
-
let offset = 0;
|
|
60
|
-
|
|
61
|
-
const authorized = (chatId) => allowed.size > 0 && allowed.has(String(chatId));
|
|
62
|
-
const sessionKey = (chatId) => `telegram:chat:${chatId}`;
|
|
63
|
-
|
|
64
|
-
async function send(chatId, text) {
|
|
65
|
-
let first = null;
|
|
66
|
-
for (const chunk of splitMessage(text)) {
|
|
67
|
-
const result = await tg(token, 'sendMessage', { chat_id: chatId, text: chunk, disable_web_page_preview: true });
|
|
68
|
-
if (!first) first = result;
|
|
69
|
-
}
|
|
70
|
-
return first;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
async function edit(chatId, messageId, text) {
|
|
74
|
-
const parts = splitMessage(text);
|
|
75
|
-
await tg(token, 'editMessageText', { chat_id: chatId, message_id: messageId, text: parts[0], disable_web_page_preview: true }).catch(() => null);
|
|
76
|
-
for (let i = 1; i < parts.length; i++) {
|
|
77
|
-
await tg(token, 'sendMessage', { chat_id: chatId, text: parts[i], disable_web_page_preview: true }).catch(() => null);
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function ensureSession(chatId) {
|
|
82
|
-
const key = sessionKey(chatId);
|
|
83
|
-
let sessionId = ctx.store.getSetting(key);
|
|
84
|
-
if (!sessionId || !ctx.store.getSession(sessionId)) {
|
|
85
|
-
sessionId = ctx.store.createSession({
|
|
86
|
-
title: `Telegram ${chatId}`,
|
|
87
|
-
provider: ctx.effectiveProviderId(),
|
|
88
|
-
model: ctx.effectiveModel(),
|
|
89
|
-
cwd: ctx.cwd,
|
|
90
|
-
meta: { telegramChatId: String(chatId) },
|
|
91
|
-
});
|
|
92
|
-
ctx.store.setSetting(key, sessionId);
|
|
93
|
-
}
|
|
94
|
-
return sessionId;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
async function run(chatId, input) {
|
|
98
|
-
const sessionId = ensureSession(chatId);
|
|
99
|
-
const session = ctx.store.getSession(sessionId);
|
|
100
|
-
const providerId = session.provider || ctx.effectiveProviderId();
|
|
101
|
-
const model = session.model || ctx.effectiveModel();
|
|
102
|
-
if (!model) {
|
|
103
|
-
await send(chatId, 'Falta el modelo. Usá /model <id> o configurá uno en Ajustes.');
|
|
104
|
-
return;
|
|
105
|
-
}
|
|
106
|
-
const mode = resolveMode(ctx.effectiveMode());
|
|
107
|
-
const cwd = resolveExistingDir(session.cwd || ctx.cwd);
|
|
108
|
-
const history = ctx.store.getMessages(sessionId);
|
|
109
|
-
|
|
110
|
-
let mcp = null;
|
|
111
|
-
try {
|
|
112
|
-
mcp = await ctx.getMcp();
|
|
113
|
-
} catch {
|
|
114
|
-
mcp = null;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
const local = createTools({
|
|
118
|
-
root: cwd,
|
|
119
|
-
enabled: mode.tools ?? undefined,
|
|
120
|
-
permissions: ctx.config.permissions,
|
|
121
|
-
yes: false,
|
|
122
|
-
mcp,
|
|
123
|
-
});
|
|
124
|
-
const exposeAdmin = !mode.tools;
|
|
125
|
-
const admin = ctx.agentTools;
|
|
126
|
-
const definitions = exposeAdmin ? [...local.definitions, ...admin.definitions] : local.definitions;
|
|
127
|
-
const execute = (name, args, context) =>
|
|
128
|
-
exposeAdmin && admin.handles(name)
|
|
129
|
-
? admin.execute(name, args, { approve: async () => false })
|
|
130
|
-
: local.execute(name, args, context);
|
|
131
|
-
const { provider, config } = ctx.resolveProvider({
|
|
132
|
-
...ctx.providerSettings(providerId),
|
|
133
|
-
apiKey: ctx.apiKeyFor(providerId),
|
|
134
|
-
});
|
|
135
|
-
const basePrompt = await ctx.systemPromptFor(cwd);
|
|
136
|
-
const messages = [
|
|
137
|
-
{ role: 'system', content: mode.systemPrompt ? `${basePrompt}\n\n${mode.systemPrompt}` : basePrompt },
|
|
138
|
-
...history,
|
|
139
|
-
{ role: 'user', content: input },
|
|
140
|
-
];
|
|
141
|
-
|
|
142
|
-
const controller = new AbortController();
|
|
143
|
-
controllers.set(String(chatId), controller);
|
|
144
|
-
const message = await send(chatId, '…');
|
|
145
|
-
let full = '';
|
|
146
|
-
let lastEdit = 0;
|
|
147
|
-
|
|
148
|
-
try {
|
|
149
|
-
const result = await runAgent({
|
|
150
|
-
provider,
|
|
151
|
-
config,
|
|
152
|
-
model,
|
|
153
|
-
messages,
|
|
154
|
-
tools: definitions,
|
|
155
|
-
execute,
|
|
156
|
-
signal: controller.signal,
|
|
157
|
-
maxRounds: ctx.config.maxRounds,
|
|
158
|
-
onEvent(event) {
|
|
159
|
-
if (event.type !== 'text') return;
|
|
160
|
-
full = event.full;
|
|
161
|
-
const now = Date.now();
|
|
162
|
-
if (now - lastEdit > 1200) {
|
|
163
|
-
lastEdit = now;
|
|
164
|
-
edit(chatId, message.message_id, full || '…');
|
|
165
|
-
}
|
|
166
|
-
},
|
|
167
|
-
});
|
|
168
|
-
await edit(chatId, message.message_id, result.content?.trim() || full || '(sin texto)');
|
|
169
|
-
const newMessages = result.messages.slice(1 + history.length);
|
|
170
|
-
ctx.store.addMessages(sessionId, newMessages);
|
|
171
|
-
ctx.store.touchSession(sessionId, { provider: providerId, model });
|
|
172
|
-
} catch (error) {
|
|
173
|
-
await edit(chatId, message.message_id, error?.name === 'AbortError' ? 'Cancelado.' : `Error: ${error?.message ?? error}`);
|
|
174
|
-
} finally {
|
|
175
|
-
controllers.delete(String(chatId));
|
|
176
|
-
mcp?.close?.();
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
async function handleCommand(chatId, text) {
|
|
181
|
-
const [raw, ...rest] = text.slice(1).split(/\s+/);
|
|
182
|
-
const cmd = raw.toLowerCase();
|
|
183
|
-
const arg = rest.join(' ').trim();
|
|
184
|
-
const sessionId = ctx.store.getSetting(sessionKey(chatId));
|
|
185
|
-
if (cmd === 'start' || cmd === 'help' || cmd === 'ayuda') return send(chatId, HELP);
|
|
186
|
-
if (cmd === 'new' || cmd === 'nueva') {
|
|
187
|
-
ctx.store.setSetting(sessionKey(chatId), null);
|
|
188
|
-
return send(chatId, 'Nueva sesión. Mandá tu mensaje.');
|
|
189
|
-
}
|
|
190
|
-
if (cmd === 'status') {
|
|
191
|
-
const session = sessionId ? ctx.store.getSession(sessionId) : null;
|
|
192
|
-
return send(
|
|
193
|
-
chatId,
|
|
194
|
-
`proveedor: ${session?.provider || ctx.effectiveProviderId()}\nmodelo: ${session?.model || ctx.effectiveModel() || '—'}\nmodo: ${ctx.effectiveMode()}\nsesión: ${session?.title || '—'}`
|
|
195
|
-
);
|
|
196
|
-
}
|
|
197
|
-
if (cmd === 'model') {
|
|
198
|
-
if (!arg) return send(chatId, `modelo actual: ${ctx.effectiveModel() || '—'}`);
|
|
199
|
-
ctx.store.setSetting('activeModel', arg);
|
|
200
|
-
if (sessionId) ctx.store.touchSession(sessionId, { model: arg });
|
|
201
|
-
return send(chatId, `modelo: ${arg}`);
|
|
202
|
-
}
|
|
203
|
-
if (cmd === 'provider') {
|
|
204
|
-
if (!arg) return send(chatId, `proveedor actual: ${ctx.effectiveProviderId()}`);
|
|
205
|
-
ctx.store.setSetting('activeProvider', arg);
|
|
206
|
-
if (sessionId) ctx.store.touchSession(sessionId, { provider: arg });
|
|
207
|
-
return send(chatId, `proveedor: ${arg}`);
|
|
208
|
-
}
|
|
209
|
-
if (cmd === 'mode' || cmd === 'modo') {
|
|
210
|
-
if (!arg) return send(chatId, `modo actual: ${ctx.effectiveMode()}`);
|
|
211
|
-
const id = arg.toLowerCase() === 'build' ? 'agent' : arg.toLowerCase();
|
|
212
|
-
ctx.store.setSetting('activeMode', resolveMode(id).id);
|
|
213
|
-
return send(chatId, `modo: ${resolveMode(id).label}`);
|
|
214
|
-
}
|
|
215
|
-
if (cmd === 'stop' || cmd === 'parar') {
|
|
216
|
-
controllers.get(String(chatId))?.abort();
|
|
217
|
-
return send(chatId, 'Cancelando…');
|
|
218
|
-
}
|
|
219
|
-
return send(chatId, `Comando desconocido: /${cmd}.\n\n${HELP}`);
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
async function handleMessage(message) {
|
|
223
|
-
const chatId = String(message.chat.id);
|
|
224
|
-
if (!authorized(chatId)) {
|
|
225
|
-
await send(chatId, `No autorizado. Tu chat id es ${chatId}; agregalo a telegram.allowedChatIds para habilitarlo.`);
|
|
226
|
-
return;
|
|
227
|
-
}
|
|
228
|
-
const text = String(message.text ?? '').trim();
|
|
229
|
-
if (!text) return;
|
|
230
|
-
if (text.startsWith('/')) return handleCommand(chatId, text);
|
|
231
|
-
return run(chatId, text);
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
async function loop() {
|
|
235
|
-
while (running) {
|
|
236
|
-
let updates = [];
|
|
237
|
-
try {
|
|
238
|
-
updates = await tg(token, 'getUpdates', { offset, timeout: 25, allowed_updates: ['message'] });
|
|
239
|
-
} catch (error) {
|
|
240
|
-
if (error.status === 401 || error.status === 404) {
|
|
241
|
-
log(`telegram: token inválido (${error.message}); se detiene el bot.`);
|
|
242
|
-
running = false;
|
|
243
|
-
return;
|
|
244
|
-
}
|
|
245
|
-
await sleep(3000);
|
|
246
|
-
continue;
|
|
247
|
-
}
|
|
248
|
-
for (const update of updates) {
|
|
249
|
-
offset = update.update_id + 1;
|
|
250
|
-
if (update.message?.text) {
|
|
251
|
-
handleMessage(update.message).catch((error) => log(`telegram: ${error?.message ?? error}`));
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
// Arranca en segundo plano; no bloquea el server.
|
|
258
|
-
loop().catch((error) => log(`telegram: ${error?.message ?? error}`));
|
|
259
|
-
|
|
260
|
-
return {
|
|
261
|
-
stop() {
|
|
262
|
-
running = false;
|
|
263
|
-
for (const controller of controllers.values()) controller.abort();
|
|
264
|
-
controllers.clear();
|
|
265
|
-
},
|
|
266
|
-
};
|
|
267
|
-
}
|