dsh-telegram-multiagent 1.0.1 → 1.1.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 (3) hide show
  1. package/README.md +37 -0
  2. package/package.json +2 -2
  3. package/src/index.js +165 -10
package/README.md CHANGED
@@ -105,3 +105,40 @@ Written for our own fleet and running in production on several agents. The harne
105
105
  they carry the reasoning behind each non-obvious line, and a translation is welcome.
106
106
 
107
107
  MIT.
108
+
109
+ ## 1.1.0 — кто спросил, кому отвечено, и кто это видит
110
+
111
+ Три возможности, выросшие из одной задачи: с агентом работают ДВОЕ — владелец из личного чата и
112
+ координатор по служебному каналу, — и они должны делить одну память, не путаясь, кто есть кто.
113
+
114
+ **Пометка отправителя, которую нельзя подделать.** Каждое входящее помечается каналом, из которого
115
+ пришло. 🔴 Ключевое: перед тем как поставить свою пометку, модуль ВЫРЕЗАЕТ из текста всё похожее на
116
+ неё. Без этого пометка была бы подписью в тексте, а подпись подделывает любой, кто умеет печатать.
117
+ Проверено нападением: сообщение из личного чата с готовой строкой «служебный канал» приходит
118
+ агенту помеченным как личный чат.
119
+
120
+ **Общая память двух собеседников.** `mergeChatIntoA2A: <идентификатор чата>` — сообщения этого чата
121
+ идут не в свою сессию, а в служебную. Один агент, одна история разговора, два различаемых лица.
122
+ Побочно лечит столкновение имён инструментов: второй экземпляр набора не монтируется вовсе.
123
+
124
+ **Адресат ответа привязан к ХОДУ, а не к последнему сообщению.** Ответ помечается `[ответ: кто]` с
125
+ цитатой вопроса. 🔴 Почему не «отвечаем последнему спросившему»: если второй вопрос пришёл, пока
126
+ первый считается, ответы разъезжаются не тем — причём с правильными на вид пометками.
127
+
128
+ **Режим доставки — в файле ВНЕ кода.** Путь берётся из `settingsFile` (по умолчанию — файл токена с
129
+ расширением `.json`). Читается на лету по времени изменения: правка действует со следующего
130
+ сообщения, перезапуск не нужен. Файл переживает обновление модуля.
131
+
132
+ ```json
133
+ { "deliveryMode": "personal" }
134
+ ```
135
+ | режим | кто что видит |
136
+ |---|---|
137
+ | `personal` (умолчание) | каждый видит только свои вопросы и ответы |
138
+ | `broadcast` | оба видят весь обмен, чужое помечено «адресовано не вам» |
139
+ | `owner-all` | владелец видит всё, координатор только своё |
140
+
141
+ 🔴 Умолчание выбрано самым тихим намеренно: испорченный или недоступный файл настроек НЕ должен
142
+ внезапно раскрыть переписку в чужой канал. Ошибка чтения = `personal`, и о ней говорится в журнал.
143
+
144
+ Копируются и вопросы, и ответы: половина разговора без второй половины нечитаема.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-telegram-multiagent",
3
- "version": "1.0.1",
4
- "description": "Telegram channel for DeepSeek Harness — one shared module serving several agents on a machine, each with its own bot, token file and allow-list.",
3
+ "version": "1.1.0",
4
+ "description": "Telegram channel for DeepSeek Harness — one shared module serving several agents, with unforgeable sender marks, merged owner+coordinator memory, and a delivery mode you switch in a file outside the code.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "files": [
package/src/index.js CHANGED
@@ -99,6 +99,7 @@ export function apply(ctx, config = {}) {
99
99
  // Имя агента — только для журнала: при нескольких ботах на машине нужно
100
100
  // видеть, чья строка. К логике отношения не имеет.
101
101
  const who = config.agentName || 'telegram';
102
+ const who_ = config.agentName || 'агент'; // для текстов, видимых собеседнику
102
103
  const log = (m) => console.error(`[${who}] ${m}`);
103
104
 
104
105
  const token = readTokenFrom(config, log);
@@ -114,6 +115,92 @@ export function apply(ctx, config = {}) {
114
115
 
115
116
  const tg = new TelegramClient(token, log);
116
117
  const chats = new Map(); // chatId → { handle, sessionId }
118
+ // 🔴 ОТКУДА ПРИШЁЛ ПОСЛЕДНИЙ ВОПРОС (20.08.2026). Владелец захотел общаться с
119
+ // ОДНИМ экземпляром агента, а не с двумя копиями: раньше чат в мессенджере и
120
+ // межагентский канал были РАЗНЫМИ сессиями, то есть буквально двумя агентами
121
+ // с разной памятью разговора. Теперь их можно свести в одну сессию — но тогда
122
+ // ответ надо возвращать туда, откуда пришёл вопрос, иначе ответ координатору
123
+ // улетит в чат владельца. Ключ — сессия, значение — 'a2a' или 'tg'.
124
+ const lastOrigin = new Map();
125
+
126
+ // 🔴 СЛИЯНИЕ КАНАЛОВ В ОДНУ ПАМЯТЬ (20.08.2026, решение Александра).
127
+ // mergeChatIntoA2A: <идентификатор чата> — сообщения этого чата попадают НЕ в
128
+ // свою сессию `telegram-<чат>`, а в служебную. Тогда владелец и координатор
129
+ // говорят с ОДНИМ агентом и одной памятью разговора, а различает он их по
130
+ // пометке канала (её ставит код доставки, подделать нельзя).
131
+ // Побочно это лечит столкновение: второй экземпляр набора не монтируется
132
+ // вовсе, а значит имя инструмента памяти не может оказаться занятым.
133
+ const MERGE_CHAT = config.mergeChatIntoA2A ? String(config.mergeChatIntoA2A) : null;
134
+ // Куда отвечать в мессенджер, если сессия общая: sessionId → числовой чат.
135
+ const tgChatFor = new Map();
136
+
137
+ // 🔴 КОМУ АДРЕСОВАН ОТВЕТ (21.08.2026, задача Александра).
138
+ // Пометка на ВХОДЕ решает «кто спросил», но не решает «кому отвечено»: когда в
139
+ // одной памяти сидят двое, ответ без адреса читается обоими как свой. И хуже:
140
+ // маршрут «отвечаем последнему спросившему» ломается, если второй вопрос
141
+ // пришёл, пока первый ещё считается, — ответ уедет не тому.
142
+ // Поэтому происхождение привязано к ХОДУ, а не к последнему сообщению: на
143
+ // входе кладём в очередь, на turn/start снимаем, ответ метим тем, что сняли.
144
+ // ── НАСТРОЙКА ДОСТАВКИ, ОТДЕЛЬНЫМ ФАЙЛОМ (21.08.2026, задача Александра)
145
+ //
146
+ // Файл живёт ВНЕ модуля и переживает его обновление: код можно переписать,
147
+ // перевыпустить, поставить заново — настройка останется. Читается на лету,
148
+ // по времени изменения: поправил файл — следующий ответ уже по-новому,
149
+ // перезапуск не нужен.
150
+ //
151
+ // { "deliveryMode": "personal" | "broadcast" | "owner-all" }
152
+ // personal — каждый видит только свои ответы (по умолчанию);
153
+ // broadcast — оба видят всё, с пометкой кому адресовано;
154
+ // owner-all — владелец видит всё, координатор только своё.
155
+ //
156
+ // 🔴 Умолчание выбрано самым тихим: сломанный или пустой файл НЕ должен
157
+ // внезапно раскрывать переписку в чужой канал. Ошибка чтения = personal,
158
+ // и о ней говорим в журнал ГРОМКО, а не молча.
159
+ const SETTINGS_FILE = config.settingsFile
160
+ || (config.tokenFile ? String(config.tokenFile).replace(/\.token$/, '.json') : null);
161
+ let _setCache = { mtime: 0, data: {} };
162
+ function settings() {
163
+ if (!SETTINGS_FILE) return {};
164
+ try {
165
+ const st = fs.statSync(SETTINGS_FILE);
166
+ if (st.mtimeMs !== _setCache.mtime) {
167
+ _setCache = { mtime: st.mtimeMs, data: JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8')) };
168
+ log(`настройки перечитаны из ${SETTINGS_FILE}: режим доставки = ${_setCache.data.deliveryMode ?? 'personal (умолчание)'}`);
169
+ }
170
+ return _setCache.data ?? {};
171
+ } catch (e) {
172
+ if (_setCache.mtime !== -1) { log(`🔴 настройки не прочитаны (${SETTINGS_FILE}): ${e?.message ?? e} — работаю в режиме personal`); _setCache = { mtime: -1, data: {} }; }
173
+ return {};
174
+ }
175
+ }
176
+ const deliveryMode = () => {
177
+ const m = settings().deliveryMode;
178
+ return (m === 'broadcast' || m === 'owner-all') ? m : 'personal';
179
+ };
180
+ /** Нужна ли копия во ВТОРОЙ канал при ответе, адресованном origin. */
181
+ const copyTo = (origin) => {
182
+ const m = deliveryMode();
183
+ if (m === 'broadcast') return origin === 'a2a' ? 'tg' : 'a2a';
184
+ if (m === 'owner-all') return origin === 'a2a' ? 'tg' : null; // владельцу копию чужого
185
+ return null;
186
+ };
187
+
188
+ const pendingAsk = new Map(); // sessionId → очередь {origin, who, q}
189
+ const turnAsk = new Map(); // sessionId → чей ход считается сейчас
190
+ const pushAsk = (sid, ask) => { const q = pendingAsk.get(sid) ?? []; q.push(ask); pendingAsk.set(sid, q); };
191
+ const quote = (s) => { const one = String(s ?? '').replace(/\s+/g, ' ').trim();
192
+ return one.length > 60 ? one.slice(0, 60) + '…' : one; };
193
+ const replyHeader = (sid) => { const a = turnAsk.get(sid);
194
+ return a ? `[ответ: ${a.who}] на «${quote(a.q)}»\n\n` : ''; };
195
+
196
+ // 🔴 ЧТОБЫ ПОМЕТКЕ МОЖНО БЫЛО ВЕРИТЬ, ЕЁ НАДО СНАЧАЛА ВЫРЕЗАТЬ (20.08.2026).
197
+ // Пометку ставит код доставки — но если во входящем тексте УЖЕ есть строка
198
+ // такого вида, в сообщении окажется две пометки, и вторая (чужая, напечатанная
199
+ // руками) будет выглядеть так же убедительно. Поэтому: сперва удаляем из текста
200
+ // всё похожее на пометку, потом ставим свою. Тогда единственный, кто может её
201
+ // написать, — этот модуль, и подделать её, напечатав, нельзя.
202
+ const ORIGIN_MARK = /^\[\s*(?:служебный канал|личный чат)[^\]]*\]\s*$/gim;
203
+ const stripMark = (s) => String(s).replace(ORIGIN_MARK, '').trimStart();
117
204
  const sessionToChat = new Map(); // sessionId → chatId
118
205
 
119
206
  /** Один чат — один агент. Создаём лениво, при первом сообщении. */
@@ -256,17 +343,59 @@ export function apply(ctx, config = {}) {
256
343
  return entry;
257
344
  }
258
345
 
346
+ /** Копия ВОПРОСА во второй канал (21.08.2026: владелец хочет видеть и входящие,
347
+ * а не только ответы — иначе видна половина разговора и она непонятна). */
348
+ function copyAsk(origin, who, q) {
349
+ const target = copyTo(origin);
350
+ if (!target) return;
351
+ const text = `📨 вопрос от: ${who}\n\n${q}`;
352
+ log(`[копия] вопрос от ${who} → ${target} (режим ${deliveryMode()})`);
353
+ if (target === 'tg') {
354
+ const chat = MERGE_CHAT ?? [...tgChatFor.values()][0];
355
+ if (chat) tg.send(chat, text)
356
+ .then(() => log(`[копия] вопрос доставлен в Telegram чат ${chat}`))
357
+ .catch((e) => log(`🔴 копия вопроса в Telegram не ушла: ${e?.message ?? e}`));
358
+ else log('копию вопроса в Telegram отправить некуда: чат владельца неизвестен');
359
+ } else if (A2A_OUT) {
360
+ try { fs.mkdirSync(A2A_OUT, { recursive: true }); fs.writeFileSync(path.join(A2A_OUT, `${Date.now()}-ask.txt`), text); }
361
+ catch (e) { log(`🔴 копия вопроса в служебный канал не ушла: ${e?.message ?? e}`); }
362
+ }
363
+ }
364
+
365
+ /** Копия ответа во второй канал — с пометкой, что это не тебе. */
366
+ function sendCopy(target, sid, body) {
367
+ const head = replyHeader(sid).trim();
368
+ const text = `📄 копия (адресовано не вам)\n${head}\n\n${body}`;
369
+ log(`[копия] ответ → ${target} (режим ${deliveryMode()})`);
370
+ if (target === 'tg') {
371
+ const chat = MERGE_CHAT ?? [...tgChatFor.values()][0];
372
+ if (chat) tg.send(chat, text)
373
+ .then(() => log(`[копия] ответ доставлен в Telegram чат ${chat}`))
374
+ .catch((e) => log(`🔴 копия в Telegram не ушла: ${e?.message ?? e}`));
375
+ else log('копию в Telegram отправить некуда: чат владельца неизвестен');
376
+ } else if (A2A_OUT) {
377
+ try { fs.mkdirSync(A2A_OUT, { recursive: true }); fs.writeFileSync(path.join(A2A_OUT, `${Date.now()}-copy.txt`), text); }
378
+ catch (e) { log(`🔴 копия в служебный канал не ушла: ${e?.message ?? e}`); }
379
+ }
380
+ }
381
+
259
382
  // ── ВЫХОД: события сессии → сообщения в Telegram
260
383
  ctx.on('session/event', (session, event) => {
261
384
  // 🔴 DEBUG 2026-08-18: все типы событий
262
385
  log(`[event] type=${event.type} session.id=${session?.id} knownSessions=${[...sessionToChat.keys()].join('|')}`);
263
- const chatId = sessionToChat.get(String(session?.id ?? ''));
386
+ const sid = String(session?.id ?? '');
387
+ // 🔴 При слиянии sessionToChat указывает на КЛЮЧ служебной сессии, а не на
388
+ // числовой чат — отправка по нему молча не дойдёт. Настоящий чат берём из
389
+ // карты, заполняемой на входе из мессенджера.
390
+ const chatId = tgChatFor.get(sid) ?? sessionToChat.get(sid);
264
391
  if (chatId === undefined) {
265
392
  if (event.type === 'assistant/message') log(`[event] chatId undefined для session ${session?.id} — игнорирую`);
266
393
  return;
267
394
  }
268
395
  try {
269
396
  if (event.type === 'turn/start') {
397
+ const q = pendingAsk.get(sid) ?? [];
398
+ if (q.length) turnAsk.set(sid, q.shift());
270
399
  void tg.typing(chatId);
271
400
  } else if (event.type === 'turn/end' && event.data?.reason?.kind === 'error') {
272
401
  // 🔴 18.08.2026: ход может оборваться с внятной ошибкой, и она приходит
@@ -285,14 +414,16 @@ export function apply(ctx, config = {}) {
285
414
  } else {
286
415
  void tg.send(chatId, `Не смог выполнить ход: ${why}`);
287
416
  }
288
- } else if (event.type === 'assistant/message' && chatId === A2A_CHAT) {
289
- // ответ для координатора в файл, Telegram тут ни при чём
417
+ } else if (event.type === 'assistant/message'
418
+ && (turnAsk.get(sid)?.origin ?? lastOrigin.get(sid) ?? (chatId === A2A_CHAT ? 'a2a' : 'tg')) === 'a2a') {
419
+ // ответ координатору — в файл, Telegram тут ни при чём
290
420
  const blocks = event.data?.message?.content ?? [];
291
421
  const text = blocks.filter((b) => b?.type === 'text').map((b) => b.text).join('\n').trim();
292
422
  if (text) {
293
423
  try {
294
424
  fs.mkdirSync(A2A_OUT, { recursive: true });
295
- fs.writeFileSync(path.join(A2A_OUT, `${Date.now()}.txt`), text);
425
+ fs.writeFileSync(path.join(A2A_OUT, `${Date.now()}.txt`), replyHeader(sid) + text);
426
+ { const c = copyTo('a2a'); if (c) sendCopy(c, sid, text); }
296
427
  log(`[a2a] ответ координатору записан (${text.length} знаков)`);
297
428
  } catch (e) { log(`[a2a] не смог записать ответ: ${e?.message ?? e}`); }
298
429
  }
@@ -306,7 +437,8 @@ export function apply(ctx, config = {}) {
306
437
  .trim();
307
438
  if (text) {
308
439
  log(`[event] отправляю в Telegram chatId=${chatId} (${text.length} знаков)`);
309
- tg.send(chatId, text).catch((e) => log(`🔴 send к chatId=${chatId} не удался: ${e?.message ?? e}`));
440
+ { const c = copyTo('tg'); if (c) sendCopy(c, sid, text); }
441
+ tg.send(chatId, replyHeader(sid) + text).catch((e) => log(`🔴 send к chatId=${chatId} не удался: ${e?.message ?? e}`));
310
442
  }
311
443
  }
312
444
  } catch (e) {
@@ -332,7 +464,10 @@ export function apply(ctx, config = {}) {
332
464
  let tmpAudio = null;
333
465
  try {
334
466
  tmpAudio = await downloadTelegramFile(fileId);
335
- // внешняя команда расшифровки, задаётся настройкой transcribeCommand
467
+ // transcribe-local-shared наша цепочка: sage-corrector → transcribe-whispercpp-core → whisper-warm.service
468
+ // 🔴 Внешняя команда расшифровки — НАСТРОЙКА, а не зашитый путь: у каждого
469
+ // своя цепочка. Не задана — голосовые просто не расшифровываются, и об этом
470
+ // говорится в журнал, а не молча игнорируется.
336
471
  if (!config.transcribeCommand) {
337
472
  log('голосовые не расшифровываются: не задан config.transcribeCommand');
338
473
  return null;
@@ -376,7 +511,7 @@ export function apply(ctx, config = {}) {
376
511
  // Отказываем И поднимаем тревогу владельцу — молча отказывать нельзя:
377
512
  // сам факт попытки это событие безопасности, а не бытовая мелочь.
378
513
  const who = msg.from ?? {};
379
- const alert = `🔴 Чужой написал агенту\n` +
514
+ const alert = `🔴 Чужой написал агенту ${who_}\n` +
380
515
  `id: ${userId}\n` +
381
516
  `имя: ${who.first_name ?? '?'} ${who.last_name ?? ''}`.trim() + `\n` +
382
517
  `ник: ${who.username ? '@' + who.username : 'нет'}\n` +
@@ -392,7 +527,7 @@ export function apply(ctx, config = {}) {
392
527
 
393
528
  // Команды обрабатываем сами, до агента.
394
529
  if (text === '/start' || text === '/help') {
395
- await tg.send(chatId, 'агент на связи. Пишите задачу обычным сообщением.\n' +
530
+ await tg.send(chatId, `${who_} на связи. Пишите задачу обычным сообщением.\n` +
396
531
  '/new — начать разговор заново.');
397
532
  return;
398
533
  }
@@ -407,13 +542,23 @@ export function apply(ctx, config = {}) {
407
542
  return;
408
543
  }
409
544
 
410
- const { handle } = await agentFor(chatId);
545
+ // При включённом слиянии владелец попадает в ту же сессию, что и служебный
546
+ // канал: одна память на двоих. Ответ при этом обязан вернуться в мессенджер,
547
+ // поэтому запоминаем настоящий чат отдельно.
548
+ const routeKey = (MERGE_CHAT && String(chatId) === MERGE_CHAT) ? A2A_CHAT : chatId;
549
+ const { handle, sessionId } = await agentFor(routeKey);
550
+ tgChatFor.set(sessionId, chatId);
411
551
  // 🔴 ТОЛЬКО send(), НЕ followup(). Проверено на установленной версии 0.1.0-rc.7:
412
552
  // followup объявлен в описании типов, но В КОДЕ ЕГО НЕТ — вызов молча не делает
413
553
  // ничего, и снаружи это выглядит как «бот принял сообщение и замолчал».
414
554
  // Правильный вызов подсмотрен в самом продукте: this.send(input, "next-turn", true).
415
555
  // next-turn — обычный следующий ход;
416
556
  // true — разбудить исполнителя, иначе сообщение будет ждать вечно.
557
+ lastOrigin.set(sessionId, 'tg');
558
+ pushAsk(sessionId, { origin: 'tg', who: msg.from?.first_name ?? 'владелец', q: text });
559
+ copyAsk('tg', msg.from?.first_name ?? 'владелец', text);
560
+ // Симметрично: сообщение из мессенджера помечается как пришедшее из чата.
561
+ text = `[личный чат, от ${msg.from?.first_name ?? 'владельца'}]\n${stripMark(text)}`;
417
562
  const userMsg = platform.createUserMessage({
418
563
  content: [{ type: 'text', text }],
419
564
  source: { kind: 'user' },
@@ -476,7 +621,17 @@ export function apply(ctx, config = {}) {
476
621
  }
477
622
  if (!text) continue;
478
623
  try {
479
- const { handle } = await agentFor(A2A_CHAT);
624
+ const { handle, sessionId: a2aSessionId } = await agentFor(A2A_CHAT);
625
+ lastOrigin.set(a2aSessionId, 'a2a');
626
+ pushAsk(a2aSessionId, { origin: 'a2a', who: 'координатор', q: text });
627
+ copyAsk('a2a', 'координатор', text);
628
+ // 🔴 ПОМЕТКУ СТАВИТ КАНАЛ, А НЕ ОТПРАВИТЕЛЬ (20.08.2026). Когда личный
629
+ // чат владельца и служебный канал сведены в ОДНУ сессию, агент не может
630
+ // отличить, кто говорит: подпись в тексте подделывается тривиально.
631
+ // Наш агент на этом верно упёрся — отказался выполнять просьбу,
632
+ // подписанную чужим именем, пришедшую не из своего канала. Значит
633
+ // происхождение обязан сообщать код доставки, которому подделать нечем.
634
+ text = `[служебный канал, от координатора]\n${stripMark(text)}`;
480
635
  handle.agent.send(platform.createUserMessage({
481
636
  content: [{ type: 'text', text }],
482
637
  source: { kind: 'user' },