dsh-telegram-multiagent 1.2.2 → 1.3.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/README.md +107 -1
- package/cordis.patch.yml +10 -0
- package/package.json +3 -2
- package/src/index.js +389 -18
- package/test/extract.mjs +44 -0
- package/test/test-goal-control.mjs +212 -0
package/README.md
CHANGED
|
@@ -49,8 +49,10 @@ see "Where to put the row" below:
|
|
|
49
49
|
| `a2aDir` | no | Directory for a file-based agent-to-agent channel (`in/`, `out/`). Omit and no such channel exists. |
|
|
50
50
|
| `a2aSession` | no | Session id used for that channel. Default `a2a`. |
|
|
51
51
|
| `transcribeCommand` | no | External command for voice messages: `<cmd> <audio-file> auto` → transcript on stdout. Omit and voice is politely refused. |
|
|
52
|
+
| `goalUsers` | no | Telegram user ids allowed to run `/goal`. **Empty (default) = the command is refused for everyone.** Not inherited from `allowedUsers` on purpose: talking to an agent and starting a paid autonomous loop are different rights. |
|
|
53
|
+
| `goalA2ASenders` | no | Sender names allowed to run `/goal` from the agent-to-agent channel. The sender names itself in the first line of the file (`From: <name>`) — this is bookkeeping, not authentication; see 1.3.0 below. |
|
|
52
54
|
|
|
53
|
-
##
|
|
55
|
+
## Five things that cost us a day
|
|
54
56
|
|
|
55
57
|
Each of these fails **while looking like success**. They are commented inline in the source; this is
|
|
56
58
|
the short version.
|
|
@@ -74,6 +76,17 @@ session id makes the persistence layer abort *every* turn with an id-collision e
|
|
|
74
76
|
"accepted the message and went quiet" — the turn honestly starts and dies in milliseconds. It works
|
|
75
77
|
until the first restart, which is what makes it nasty. Use `resume()` when the session exists.
|
|
76
78
|
|
|
79
|
+
**5. `ctx.get()` returns nothing *quietly* while a service is still starting.** cordis hands back
|
|
80
|
+
`undefined` without throwing until the provider's fiber is active, so "this service is not in the
|
|
81
|
+
build" and "this service is thirty milliseconds away" arrive as the same value. Code written as
|
|
82
|
+
`const x = ctx.get('x'); if (x) {...}` then takes the "feature absent" branch and says nothing — the
|
|
83
|
+
agent is assembled, answers, and never mentions what it lost. This bit us three times in three days:
|
|
84
|
+
the agent factory (3), session persistence (4, the whole resume block was skipped), and the agent
|
|
85
|
+
preset — where a neighbouring agent's toolset dropped from 33 tools to 3 with no error and no log
|
|
86
|
+
line. Wait for the service with a stated deadline, and when the deadline passes, say so *and name
|
|
87
|
+
the consequence*. One helper for all of them: a fix applied to the instance instead of the class
|
|
88
|
+
guarantees a relapse, and the relapse looks like a new illness.
|
|
89
|
+
|
|
77
90
|
## Where to put the row
|
|
78
91
|
|
|
79
92
|
In the `web` profile the common-plane tools are **disabled on purpose**; the toolset comes from the
|
|
@@ -160,3 +173,96 @@ MIT.
|
|
|
160
173
|
|
|
161
174
|
**Урок общий:** канал доставки без повтора — это тихая потеря. Отказ, о котором никто не узнал,
|
|
162
175
|
дороже отказа громкого: его невозможно даже сосчитать.
|
|
176
|
+
|
|
177
|
+
## 1.3.0 — постановка цели прямо из канала
|
|
178
|
+
|
|
179
|
+
🔴 **Зачем это вообще нужно.** Если цикл агента ведёт внешний движок — например, агент работает
|
|
180
|
+
через подписку, а не через родной цикл платформы, — родные инструменты платформы до модели **не
|
|
181
|
+
доходят вовсе**. Доходит только то, что имеет форму сообщения. Значит поставить агенту цель изнутри
|
|
182
|
+
разговора нельзя: руками в веб-интерфейсе можно, а из чата нет. Здесь мы даём постановку из любого
|
|
183
|
+
канала: сервису целей не нужен инструмент модели, он доступен коду плагина напрямую.
|
|
184
|
+
|
|
185
|
+
Команды одинаковы в обоих каналах:
|
|
186
|
+
|
|
187
|
+
```
|
|
188
|
+
/goal показать состояние цели
|
|
189
|
+
/goal <замысел> поставить цель
|
|
190
|
+
/goal stop снять цель
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
**Кто вправе — двумя отдельными списками, оба по умолчанию ПУСТЫЕ, то есть команда отказывает
|
|
194
|
+
всем.** Право не наследуется от `allowedUsers` сознательно: «с кем я разговариваю» и «кто вправе
|
|
195
|
+
завести дорогой автономный цикл» — разные вопросы, а в `allowedUsers` завтра добавят гостя.
|
|
196
|
+
|
|
197
|
+
```yaml
|
|
198
|
+
goalUsers: [123456789] # из Telegram — по числовому id
|
|
199
|
+
goalA2ASenders: ["coordinator"] # из служебного канала — по имени в заголовке
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
🔴 **Чем за это платят, сказать обязано само описание.** Проверки платформы
|
|
203
|
+
(`currentInitiator`, `hasDirectHumanInput`, права на корневого агента) живут в её инструменте целей
|
|
204
|
+
и на этом пути **не участвуют**. Плагин не обходит политику — он пишет её заново, и две строки выше
|
|
205
|
+
есть вся политика целиком. Ставя `goalUsers`, вы соглашаетесь именно на это.
|
|
206
|
+
|
|
207
|
+
**Счётчик постановок: не более трёх за скользящий час на канал.** Предел раундов у платформы стоит
|
|
208
|
+
на ЦЕЛЬ, а не на агента: новая цель — новый счётчик, и поток целей ничем не ограничен. А каталог
|
|
209
|
+
входящих принадлежит самому агенту, то есть в автономном раунде он способен положить себе файл
|
|
210
|
+
`/goal …` и продлить цикл собственными руками.
|
|
211
|
+
|
|
212
|
+
🔴 **Где счётчик НЕ применяется — назвать важнее, чем сам счётчик.** Он защищает от РАЗГОНА:
|
|
213
|
+
от петли, в которую механизм вошёл сам, от ошибки в коде, от лавины повторов. Он **не** защищает от
|
|
214
|
+
намеренного действия агента с правами администратора: такой агент снимет и счётчик, и предел
|
|
215
|
+
раундов одной командой. Изоляция здесь **поведенческая**, технической границы против самого агента
|
|
216
|
+
не существует. Настоящий сторож живёт снаружи машины. Снятию цели (`/goal stop`) счётчик не мешает
|
|
217
|
+
никогда: механизм, включаемый снаружи и невыключаемый, хуже отсутствия механизма.
|
|
218
|
+
|
|
219
|
+
**Заголовок отправителя — учёт, а не защита.** Служебный канал файловый, и вид источника получает
|
|
220
|
+
любое сообщение в нём; правом «всем, кто пишет в канал» вы раздали бы дорогие циклы кому попало.
|
|
221
|
+
Поэтому отправитель называет себя первой строкой файла:
|
|
222
|
+
|
|
223
|
+
```
|
|
224
|
+
From: coordinator
|
|
225
|
+
/goal разобрать вчерашние отказы и доложить
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Имя подделает любой, кто может положить файл в каталог входящих. Настоящая граница здесь — права
|
|
229
|
+
ОС на этот каталог. Нужна проверка сильнее самоназвания — это общий секрет в файле с правами, и
|
|
230
|
+
заводить его надо отдельным решением, а не походя.
|
|
231
|
+
|
|
232
|
+
**Об исходе цели плагин сообщает сам.** Доводчик платформы при упоре в предел раундов переводит
|
|
233
|
+
цель в `blocked` — и делает это молча: снаружи автономный цикл просто перестаёт просыпаться, что
|
|
234
|
+
неотличимо от поломки. Плагин говорит об исходе ровно один раз и в тот канал, из которого цель
|
|
235
|
+
ставили.
|
|
236
|
+
|
|
237
|
+
**Служебный маркер цели вырезается из всего, что уходит наружу.** Его дело — остановить цикл, а не
|
|
238
|
+
попасть человеку в чат. Вырезается маркер последней строки; маркер в середине текста оставлен
|
|
239
|
+
видимым нарочно — он и так отвергнут, и пусть будет заметен читателю.
|
|
240
|
+
|
|
241
|
+
### Ожидание сервисов: где оно есть и где его нет — решение по каждому месту
|
|
242
|
+
|
|
243
|
+
`ctx.get()` возвращает `undefined` **молча**, пока волокно поставщика не активно. «Сервиса нет в
|
|
244
|
+
сборке» и «сервис ещё поднимается» приходят одним и тем же значением — и код, написанный как
|
|
245
|
+
`const x = ctx.get('x'); if (x)`, тихо уходит по ветке «этой возможности просто нет».
|
|
246
|
+
|
|
247
|
+
| место | ожидание | почему |
|
|
248
|
+
|---|---|---|
|
|
249
|
+
| заведение агента (пресет, модель, персистенция) | **есть**, до 30 с | идёт вплотную к подъёму платформы, отказ молчалив и разрушителен |
|
|
250
|
+
| `/goal` → сервисы целей, маркера, моста | **есть**, коротко | ответ «сервис не подключён» был бы диагнозом, которого код поставить не может; но на том конце живой собеседник, и полминуты молчания в чате читаются как «бот умер» |
|
|
251
|
+
| вырезание маркера из исходящего | **нет** | место синхронное, а отказ не молчалив: маркер уедет в чат видимым текстом |
|
|
252
|
+
| сообщение об исходе цели | **нет** | сюда попадают только сессии, где цель уже поставлена, — значит пустота означает не «ещё не поднялся», а «сервис пропал», и это надо сказать, а не переждать |
|
|
253
|
+
|
|
254
|
+
Замечено на живом: порядок готовности волокон **меняется от подъёма к подъёму**. В одном подъёме
|
|
255
|
+
ждала фабрика агентов, в другом — набор пресета, причём гонка была в обоих. Значит судить о гонке
|
|
256
|
+
по соседнему сервису нельзя, и ожидание ставится по устройству места, а не по тому, где однажды
|
|
257
|
+
видели примету.
|
|
258
|
+
|
|
259
|
+
### Чего эта версия НЕ делает
|
|
260
|
+
|
|
261
|
+
- **не даёт модели инструмент постановки цели** — команда приходит от человека или от координатора
|
|
262
|
+
через канал, модель её не вызывает и в заголовке запроса плагин невидим;
|
|
263
|
+
- **не заменяет права платформы** — она их здесь не применяет вовсе (см. выше);
|
|
264
|
+
- **не защищает от агента с правами администратора** — вся защита поведенческая;
|
|
265
|
+
- **не проверяет подлинность отправителя** в служебном канале — только самоназвание;
|
|
266
|
+
- **не переносит цель через перезапуск процесса**: счётчик постановок обнуляется, а связь
|
|
267
|
+
«сессия → куда сообщить об исходе» живёт в памяти процесса. Сама цель хранится платформой и
|
|
268
|
+
переживает перезапуск, а вот сообщение об её исходе после перезапуска не придёт.
|
package/cordis.patch.yml
CHANGED
|
@@ -17,3 +17,13 @@
|
|
|
17
17
|
# Empty means everyone can talk to an agent that usually has shell
|
|
18
18
|
# access. Set it.
|
|
19
19
|
allowedUsers: []
|
|
20
|
+
# Goal control from the channel (1.3.0). BOTH lists are empty by
|
|
21
|
+
# default, which means the /goal command is refused for everyone —
|
|
22
|
+
# deliberately: starting an autonomous goal loop costs money, so the
|
|
23
|
+
# right is granted by name, never inherited from allowedUsers.
|
|
24
|
+
# Telegram user ids allowed to run /goal:
|
|
25
|
+
goalUsers: []
|
|
26
|
+
# Names allowed to run /goal from the agent-to-agent channel. The
|
|
27
|
+
# sender names itself in the first line of the file: "From: <name>".
|
|
28
|
+
# This is bookkeeping, not authentication — see README.
|
|
29
|
+
goalA2ASenders: []
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-telegram-multiagent",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Telegram channel for DeepSeek Harness — one shared module serving several agents, with unforgeable sender marks, merged owner+coordinator memory,
|
|
3
|
+
"version": "1.3.0",
|
|
4
|
+
"description": "Telegram channel for DeepSeek Harness — one shared module serving several agents, with unforgeable sender marks, merged owner+coordinator memory, a delivery mode you switch in a file outside the code, and goal control from the channel for agents whose loop runs outside the platform.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"files": [
|
|
8
8
|
"src",
|
|
9
|
+
"test",
|
|
9
10
|
"cordis.patch.yml",
|
|
10
11
|
"README.md"
|
|
11
12
|
],
|
package/src/index.js
CHANGED
|
@@ -123,7 +123,7 @@ export function apply(ctx, config = {}) {
|
|
|
123
123
|
// улетит в чат владельца. Ключ — сессия, значение — 'a2a' или 'tg'.
|
|
124
124
|
const lastOrigin = new Map();
|
|
125
125
|
|
|
126
|
-
// 🔴 СЛИЯНИЕ КАНАЛОВ В ОДНУ ПАМЯТЬ (20.08.2026, решение
|
|
126
|
+
// 🔴 СЛИЯНИЕ КАНАЛОВ В ОДНУ ПАМЯТЬ (20.08.2026, решение владельца).
|
|
127
127
|
// mergeChatIntoA2A: <идентификатор чата> — сообщения этого чата попадают НЕ в
|
|
128
128
|
// свою сессию `telegram-<чат>`, а в служебную. Тогда владелец и координатор
|
|
129
129
|
// говорят с ОДНИМ агентом и одной памятью разговора, а различает он их по
|
|
@@ -134,14 +134,14 @@ export function apply(ctx, config = {}) {
|
|
|
134
134
|
// Куда отвечать в мессенджер, если сессия общая: sessionId → числовой чат.
|
|
135
135
|
const tgChatFor = new Map();
|
|
136
136
|
|
|
137
|
-
// 🔴 КОМУ АДРЕСОВАН ОТВЕТ (21.08.2026, задача
|
|
137
|
+
// 🔴 КОМУ АДРЕСОВАН ОТВЕТ (21.08.2026, задача владельца).
|
|
138
138
|
// Пометка на ВХОДЕ решает «кто спросил», но не решает «кому отвечено»: когда в
|
|
139
139
|
// одной памяти сидят двое, ответ без адреса читается обоими как свой. И хуже:
|
|
140
140
|
// маршрут «отвечаем последнему спросившему» ломается, если второй вопрос
|
|
141
141
|
// пришёл, пока первый ещё считается, — ответ уедет не тому.
|
|
142
142
|
// Поэтому происхождение привязано к ХОДУ, а не к последнему сообщению: на
|
|
143
143
|
// входе кладём в очередь, на turn/start снимаем, ответ метим тем, что сняли.
|
|
144
|
-
// ── НАСТРОЙКА ДОСТАВКИ, ОТДЕЛЬНЫМ ФАЙЛОМ (21.08.2026, задача
|
|
144
|
+
// ── НАСТРОЙКА ДОСТАВКИ, ОТДЕЛЬНЫМ ФАЙЛОМ (21.08.2026, задача владельца)
|
|
145
145
|
//
|
|
146
146
|
// Файл живёт ВНЕ модуля и переживает его обновление: код можно переписать,
|
|
147
147
|
// перевыпустить, поставить заново — настройка останется. Читается на лету,
|
|
@@ -264,6 +264,78 @@ export function apply(ctx, config = {}) {
|
|
|
264
264
|
throw new Error(`фабрика агентов не появилась за ${tries} с`);
|
|
265
265
|
}
|
|
266
266
|
|
|
267
|
+
/**
|
|
268
|
+
* Дождаться сервиса платформы, не заперев канал навсегда.
|
|
269
|
+
*
|
|
270
|
+
* 🔴 ctx.get() МОЛЧИТ, КОГДА СЕРВИСА ЕЩЁ НЕТ, и это опаснее исключения.
|
|
271
|
+
* cordis возвращает undefined БЕЗ ошибки, пока волокно поставщика не активно
|
|
272
|
+
* (ReflectService._getImpl: `if (strict && impl.fiber.state !== 2) return;`).
|
|
273
|
+
* Значит «сервиса нет в сборке» и «сервис ещё поднимается» приходят одним и
|
|
274
|
+
* тем же значением, и код, написанный как `const x = ctx.get('x'); if (x)`,
|
|
275
|
+
* тихо уходит по ветке «этой возможности просто нет». Снаружи это выглядит
|
|
276
|
+
* штатной работой: агент собирается, отвечает и молчит о том, чего лишился.
|
|
277
|
+
*
|
|
278
|
+
* Поймано трижды за три дня, каждый раз дороже предыдущего:
|
|
279
|
+
* 19.08.2026 — фабрика агентов (см. withFactoryRetry выше);
|
|
280
|
+
* 22.08.2026 — sessionPersistence: блок продолжения истории пропускался
|
|
281
|
+
* целиком, агент создавался поверх существующего журнала, и
|
|
282
|
+
* КАЖДЫЙ ход умирал с «id collision» за считаные миллисекунды;
|
|
283
|
+
* 22.08.2026 — agentPresets: набор инструментов у модели упал с 33 до 3,
|
|
284
|
+
* без ошибки и без строки в журнале.
|
|
285
|
+
* Общий вывод, ради которого функция ОДНА, а не три: лечение, применённое к
|
|
286
|
+
* экземпляру, а не ко всем местам класса, гарантирует рецидив.
|
|
287
|
+
*
|
|
288
|
+
* ГРАНИЦА ОЖИДАНИЯ — назвать обязательно, иначе защита сама станет отказом:
|
|
289
|
+
* ждём не дольше `tries` секунд и только при заведении агента. Не дождались —
|
|
290
|
+
* говорим ГРОМКО, называем последствие и идём дальше. Канал не запирается там,
|
|
291
|
+
* где сервиса в сборке нет вовсе (это законная сборка, а не поломка).
|
|
292
|
+
*/
|
|
293
|
+
async function waitService(name, tag, consequence, tries = 30) {
|
|
294
|
+
for (let i = 0; i < tries; i++) {
|
|
295
|
+
const found = ctx.get(name);
|
|
296
|
+
if (found) {
|
|
297
|
+
if (i > 0) log(`${tag} сервис ${name} появился через ${i} с ожидания`);
|
|
298
|
+
return found;
|
|
299
|
+
}
|
|
300
|
+
if (i === 0) log(`${tag} сервис ${name} ещё не активен — жду платформу`);
|
|
301
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
302
|
+
}
|
|
303
|
+
log(`${tag} 🔴 сервис ${name} не появился за ${tries} с — ${consequence}`);
|
|
304
|
+
return undefined;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* 🔴 РЕШЕНИЕ ПО КАЖДОМУ ctx.get() В ЭТОМ ФАЙЛЕ. Проверять надо КАЖДЫЙ, а не
|
|
309
|
+
* тот, на котором обожглись, — иначе рецидив, и рецидив будет выглядеть новой
|
|
310
|
+
* болезнью, потому что рядом лежит доказательство «мы это уже лечили».
|
|
311
|
+
*
|
|
312
|
+
* ПРИЗНАК, по которому решается, а не «кажется безопасным»: ожидание нужно
|
|
313
|
+
* там, где место может сработать ДО подъёма сервиса И отказ при этом
|
|
314
|
+
* МОЛЧАЛИВ ИЛИ ЛОЖЕН. Одного условия мало.
|
|
315
|
+
*
|
|
316
|
+
* Отдельно замечено на живом: порядок готовности волокон МЕНЯЕТСЯ от подъёма
|
|
317
|
+
* к подъёму. В одном подъёме ждала фабрика агентов, в другом — набор пресета,
|
|
318
|
+
* причём гонка была в обоих. Значит судить о гонке по соседнему сервису
|
|
319
|
+
* нельзя: соседний в этот раз мог успеть. Ожидание ставится по устройству
|
|
320
|
+
* места, а не по тому, где однажды видели примету.
|
|
321
|
+
*
|
|
322
|
+
* 1) agentFor → agentPresets, agentDefaultModel, sessionPersistence.
|
|
323
|
+
* ОЖИДАНИЕ ЕСТЬ, предел 30 с: заведение агента идёт вплотную к подъёму
|
|
324
|
+
* платформы, а отказ здесь молчалив и разрушителен.
|
|
325
|
+
* 2) goalCommand → goals, goalMarker, mcpBridge. ОЖИДАНИЕ ЕСТЬ, предел
|
|
326
|
+
* короткий (GOAL_WAIT_TRIES): по undefined невозможно отличить «сервиса
|
|
327
|
+
* нет в сборке» от «ещё поднимается», а ответ «не подключён» — это
|
|
328
|
+
* диагноз, которого код поставить не может. Ждём мало, потому что на том
|
|
329
|
+
* конце живой собеседник.
|
|
330
|
+
* 3) stripGoalMarker → goalMarker. ОЖИДАНИЯ НЕТ намеренно: место
|
|
331
|
+
* СИНХРОННОЕ (обработчик события сессии не async), а отказ не молчалив —
|
|
332
|
+
* маркер уезжает в чат видимым текстом.
|
|
333
|
+
* 4) обработчик goal/change → goals. ОЖИДАНИЯ НЕТ намеренно: сюда попадают
|
|
334
|
+
* только те сессии, где цель уже поставлена через goalCommand, а тот без
|
|
335
|
+
* сервиса целей не проходит вовсе. Значит пустота здесь означает не «ещё
|
|
336
|
+
* не поднялся», а «сервис ПРОПАЛ», и это надо не переждать, а сказать.
|
|
337
|
+
*/
|
|
338
|
+
|
|
267
339
|
async function agentFor(chatId) {
|
|
268
340
|
const key = String(chatId);
|
|
269
341
|
const found = chats.get(key);
|
|
@@ -275,9 +347,11 @@ export function apply(ctx, config = {}) {
|
|
|
275
347
|
// в запрос к модели не попадает поле tools вообще, и агент физически не
|
|
276
348
|
// может вызвать ни один инструмент, хотя они смонтированы (проверено
|
|
277
349
|
// 2026-08-18 по событию request/header в журнале сессии).
|
|
278
|
-
const presets =
|
|
350
|
+
const presets = await waitService('agentPresets', `[${key}]`,
|
|
351
|
+
'агент будет собран БЕЗ ИНСТРУМЕНТОВ');
|
|
279
352
|
const presetId = presets ? (await presets.resolve(config.preset)).id : undefined;
|
|
280
|
-
const defaultModel =
|
|
353
|
+
const defaultModel = await waitService('agentDefaultModel', `[${key}]`,
|
|
354
|
+
'модель берётся из настроек плагина, а не из выбора платформы');
|
|
281
355
|
const selection = defaultModel?.currentSelection?.() ?? {
|
|
282
356
|
provider: config.provider,
|
|
283
357
|
model: config.model,
|
|
@@ -285,6 +359,9 @@ export function apply(ctx, config = {}) {
|
|
|
285
359
|
const setupFn = async (agentCtx) => {
|
|
286
360
|
platform.installModelSelection(agentCtx, { current: selection, assembled: undefined });
|
|
287
361
|
if (presets && presetId) await presets.mount(agentCtx, presetId);
|
|
362
|
+
// Молча остаться без инструментов нельзя: снаружи это выглядит как
|
|
363
|
+
// «агент отупел», а причина — несмонтированный пресет.
|
|
364
|
+
else log(`[${key}] 🔴 пресет НЕ смонтирован (presets=${!!presets}, presetId=${presetId})`);
|
|
288
365
|
};
|
|
289
366
|
|
|
290
367
|
// 🔴 СЕССИЮ, КОТОРАЯ УЖЕ ЛЕЖИТ НА ДИСКЕ, НАДО ПРОДОЛЖАТЬ, А НЕ СОЗДАВАТЬ ЗАНОВО.
|
|
@@ -299,16 +376,36 @@ export function apply(ctx, config = {}) {
|
|
|
299
376
|
// события turn/end (раньше плагин печатал только тип события).
|
|
300
377
|
// Правильный путь — ctx.agents.resume(): «загрузить сохранённую сессию и
|
|
301
378
|
// продолжить агента на ней» (dsh-agent/lib/index.js, resume()).
|
|
302
|
-
const persistence =
|
|
379
|
+
const persistence = await waitService('sessionPersistence', `[${key}]`,
|
|
380
|
+
'сохранённую сессию продолжить нечем, история будет потеряна');
|
|
303
381
|
let handle;
|
|
304
382
|
if (persistence) {
|
|
305
|
-
let
|
|
383
|
+
let known;
|
|
306
384
|
try {
|
|
307
|
-
|
|
385
|
+
known = (await persistence.list()).map((h) => h.id);
|
|
308
386
|
} catch (e) {
|
|
309
|
-
log(
|
|
387
|
+
log(`🔴 [${key}] не смог перечислить сохранённые сессии: ${e?.message ?? e}`);
|
|
388
|
+
}
|
|
389
|
+
// 🔴 УХОДИТЬ В create() МОЛЧА НЕЛЬЗЯ — ЭТО ВТОРОЙ ТИХИЙ ПУТЬ В ЭТОЙ ЖЕ
|
|
390
|
+
// ФУНКЦИИ. Снаружи он неотличим от честного первого запуска, а на деле
|
|
391
|
+
// означает create() поверх существующего журнала и «id collision» на
|
|
392
|
+
// каждом ходе. Поэтому список сообщаем ЧИСЛОМ и именами.
|
|
393
|
+
if (known !== undefined && !known.includes(sessionId)) {
|
|
394
|
+
// Пустой список — честный первый запуск, тревожить нечем. Тревога там,
|
|
395
|
+
// где сохранённые сессии ЕСТЬ, а нашей среди них нет.
|
|
396
|
+
if (known.length === 0) log(`[${key}] сохранённых сессий на диске нет — ${sessionId} заводится впервые`);
|
|
397
|
+
else {
|
|
398
|
+
// ⚠️, а не 🔴, СОЗНАТЕЛЬНО: по одному списку два исхода неразличимы —
|
|
399
|
+
// либо это первый разговор в новом чате (законно и обычно), либо
|
|
400
|
+
// журнал сессии потерян (беда). Отличить их отсюда нечем, поэтому
|
|
401
|
+
// строка называет ФАКТЫ и оба прочтения, а не ставит диагноз.
|
|
402
|
+
// Кричать 🔴 на законном событии — это защита, которая приучает
|
|
403
|
+
// не читать журнал.
|
|
404
|
+
const spisok = known.slice(0, 12).join(', ') + (known.length > 12 ? `, …и ещё ${known.length - 12}` : '');
|
|
405
|
+
log(`⚠️ [${key}] на диске ${known.length} сохранённых сессий, ${sessionId} среди них НЕТ: либо это первый разговор в чате, либо история потеряна — по одному списку не отличить. Продолжать нечего, создаю заново. Найдено: ${spisok}`);
|
|
406
|
+
}
|
|
310
407
|
}
|
|
311
|
-
if (
|
|
408
|
+
if (known !== undefined && known.includes(sessionId)) {
|
|
312
409
|
try {
|
|
313
410
|
handle = await ctx.agents.resume({
|
|
314
411
|
resumeSessionId: sessionId,
|
|
@@ -379,11 +476,240 @@ export function apply(ctx, config = {}) {
|
|
|
379
476
|
}
|
|
380
477
|
}
|
|
381
478
|
|
|
479
|
+
// ── ПОСТАНОВКА ЦЕЛИ ИЗ КАНАЛА (21.08.2026, задача владельца).
|
|
480
|
+
//
|
|
481
|
+
// ЗАЧЕМ. Когда цикл ведёт внешний движок (агент работает через подписку, а не
|
|
482
|
+
// через родной цикл платформы), инструменты платформы до модели НЕ ДОХОДЯТ
|
|
483
|
+
// вовсе — доходит только то, что имеет форму СООБЩЕНИЯ. Значит поставить цель
|
|
484
|
+
// изнутри разговора нельзя, а руками в веб-интерфейсе — можно. Здесь мы даём
|
|
485
|
+
// постановку из любого канала: сервис целей доступен КОДУ модуля как
|
|
486
|
+
// ctx.get('goals'), инструмент модели для этого не нужен.
|
|
487
|
+
//
|
|
488
|
+
// КТО ВПРАВЕ. Отдельные настройки, обе по умолчанию ВЫКЛЮЧЕНЫ:
|
|
489
|
+
// goalUsers: [<числовые id>] — кто ставит цель из Telegram
|
|
490
|
+
// goalA2ASenders: ["<имя>", …] — кто вправе из служебного канала
|
|
491
|
+
// Право НЕ наследуется от allowedUsers сознательно: allowedUsers — это
|
|
492
|
+
// «с кем я вообще разговариваю», и завтра туда добавят гостя. Право завести
|
|
493
|
+
// дорогой автономный цикл шире права поговорить.
|
|
494
|
+
//
|
|
495
|
+
// 🔴 ЧЕМ ЗА ЭТО ПЛАТИМ, назвать обязательно: проверки платформы
|
|
496
|
+
// (currentInitiator, hasDirectHumanInput, roots) живут в её инструменте целей
|
|
497
|
+
// и на ЭТОМ пути не участвуют. Мы не обходим политику — мы пишем её заново,
|
|
498
|
+
// и две строки ниже есть вся политика целиком. Пустые списки = постановка
|
|
499
|
+
// отключена; это умолчание выбрано нарочно.
|
|
500
|
+
const goalUsers = new Set((config.goalUsers ?? []).map(Number));
|
|
501
|
+
// 🔴 ПРАВО ИЗ СЛУЖЕБНОГО КАНАЛА — ПОИМЁННО, А НЕ «КАНАЛУ ЦЕЛИКОМ». Признак
|
|
502
|
+
// источника получает ЛЮБОЕ сообщение служебного канала, а каталог обмена
|
|
503
|
+
// бывает общим на несколько машин: правом «всем, кто пишет в канал» мы
|
|
504
|
+
// раздали бы возможность заводить дорогие циклы кому попало.
|
|
505
|
+
// Отправитель называет себя первой строкой файла: From: <имя>
|
|
506
|
+
// Нет заголовка или имени нет в списке — отказ, и он произносится вслух.
|
|
507
|
+
const goalA2ASenders = new Set(config.goalA2ASenders ?? []);
|
|
508
|
+
// sessionId → { channel, agent } — куда сообщить об исходе цели.
|
|
509
|
+
const goalOrigin = new Map();
|
|
510
|
+
// канал → метки времени постановок, для счётчика ниже.
|
|
511
|
+
const goalStarts = new Map();
|
|
512
|
+
|
|
513
|
+
// 🔴 СЧЁТЧИК ПОСТАНОВОК: не более трёх за скользящий час на канал.
|
|
514
|
+
//
|
|
515
|
+
// ОТ ЧЕГО ЗАЩИЩАЕТ: от СЛУЧАЙНОСТИ и РАЗГОНА — от петли, в которую механизм
|
|
516
|
+
// вошёл сам, от ошибки в коде, от лавины повторов. Предел раундов у платформы
|
|
517
|
+
// стоит на ЦЕЛЬ, а не на агента: новая цель — новый счётчик, и поток целей
|
|
518
|
+
// ничем не ограничен. Каталог входящих принадлежит самому агенту, то есть в
|
|
519
|
+
// автономном раунде он способен положить себе файл «/goal …» и продлить цикл.
|
|
520
|
+
//
|
|
521
|
+
// 🔴 ГДЕ НЕ ПРИМЕНЯЕТСЯ: счётчик НЕ защищает от намеренного действия агента,
|
|
522
|
+
// у которого есть права администратора на этой машине, — такой агент снимет
|
|
523
|
+
// и его, и предел раундов одной командой. Технической границы против самого
|
|
524
|
+
// агента здесь не существует, изоляция ПОВЕДЕНЧЕСКАЯ. Настоящий сторож —
|
|
525
|
+
// снаружи, на другой машине, вне досягаемости этого процесса.
|
|
526
|
+
//
|
|
527
|
+
// ГРАНИЦЫ САМОГО СЧЁТЧИКА: снятию цели (/goal stop) он не мешает никогда —
|
|
528
|
+
// механизм, включаемый снаружи и невыключаемый, хуже отсутствия механизма;
|
|
529
|
+
// обнуляется перезапуском процесса; ничего не чинит сам, только отказывает.
|
|
530
|
+
const GOAL_MAX_PER_HOUR = 3;
|
|
531
|
+
|
|
532
|
+
// Сколько секунд ждать сервис на пути КОМАНДЫ (не на заведении агента).
|
|
533
|
+
// Пять, а не тридцать: см. пояснение в goalCommand ниже.
|
|
534
|
+
const GOAL_WAIT_TRIES = 5;
|
|
535
|
+
|
|
536
|
+
function goalRateOk(channel) {
|
|
537
|
+
const now = Date.now();
|
|
538
|
+
const marks = (goalStarts.get(channel) ?? []).filter((t) => now - t < 3600000);
|
|
539
|
+
goalStarts.set(channel, marks);
|
|
540
|
+
return marks.length < GOAL_MAX_PER_HOUR;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// 🔴 ПРИЗНАК СОБСТВЕННОЙ ИСПРАВНОСТИ. Права на постановку цели заголовком
|
|
544
|
+
// запроса к модели НЕ проверяются в принципе: плагин не даёт модели ни одного
|
|
545
|
+
// инструмента и в заголовке невидим. Печатаем ЧИСЛА, а не «загрузился»: длина
|
|
546
|
+
// списка прав отличает верную настройку от пустого умолчания, при котором
|
|
547
|
+
// постановка отказывает молча и выглядит поломкой. Сами идентификаторы не
|
|
548
|
+
// печатаем — в журнал уходит устройство, а не список людей.
|
|
549
|
+
log(`[goal] смонтирован: goalUsers=${goalUsers.size} goalA2ASenders=${goalA2ASenders.size} `
|
|
550
|
+
+ `предел постановок=${GOAL_MAX_PER_HOUR}/час`);
|
|
551
|
+
|
|
552
|
+
async function sayTo(channel, chatId, text) {
|
|
553
|
+
if (channel === 'a2a') {
|
|
554
|
+
if (!A2A_OUT) { log('[goal] 🔴 ответ в служебный канал не ушёл: каталог обмена не задан'); return; }
|
|
555
|
+
try {
|
|
556
|
+
fs.mkdirSync(A2A_OUT, { recursive: true });
|
|
557
|
+
fs.writeFileSync(path.join(A2A_OUT, `${Date.now()}-goal.txt`), text);
|
|
558
|
+
} catch (e) { log(`[goal] 🔴 ответ в служебный канал не ушёл: ${e?.message ?? e}`); }
|
|
559
|
+
} else {
|
|
560
|
+
try { await tg.send(chatId, text); } catch (e) { log(`[goal] 🔴 ответ в чат не ушёл: ${e?.message ?? e}`); }
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Отделить заголовок отправителя от текста служебного сообщения.
|
|
566
|
+
*
|
|
567
|
+
* 🔴 ЧЕСТНАЯ ГРАНИЦА, назвать её обязательно: это УЧЁТ, А НЕ ЗАЩИТА.
|
|
568
|
+
* Отправитель называет себя сам, и подделать имя может любой, кто способен
|
|
569
|
+
* положить файл в каталог входящих. Настоящая граница здесь — права ОС на
|
|
570
|
+
* этот каталог: писать в него должны только сам агент и доставка. Нужна
|
|
571
|
+
* проверка сильнее самоназвания — это общий секрет в файле с правами, и
|
|
572
|
+
* заводить его нужно отдельным решением, а не походя.
|
|
573
|
+
*/
|
|
574
|
+
function splitSender(raw) {
|
|
575
|
+
const nl = raw.indexOf('\n');
|
|
576
|
+
const head = (nl === -1 ? raw : raw.slice(0, nl)).trim();
|
|
577
|
+
const m = /^From:\s*(\S+)$/.exec(head);
|
|
578
|
+
if (!m) return { sender: undefined, text: raw };
|
|
579
|
+
return { sender: m[1], text: nl === -1 ? '' : raw.slice(nl + 1).trimStart() };
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Убрать маркер цели из текста, уходящего наружу.
|
|
584
|
+
*
|
|
585
|
+
* 🔴 ЗДЕСЬ ОЖИДАНИЯ СЕРВИСА НЕТ, И ЭТО РЕШЕНИЕ, А НЕ ПРОПУСК. Место
|
|
586
|
+
* СИНХРОННОЕ (зовётся из обработчика session/event, который не async), и
|
|
587
|
+
* отказ здесь НЕ молчалив: маркер уезжает в чат видимым текстом, читатель
|
|
588
|
+
* увидит его сам. Ждать по 30 с на каждом исходящем сообщении было бы
|
|
589
|
+
* лечением хуже болезни — см. общее решение по ctx.get() выше.
|
|
590
|
+
*/
|
|
591
|
+
function stripGoalMarker(raw) {
|
|
592
|
+
const marker = ctx.get('goalMarker');
|
|
593
|
+
return marker ? marker.strip(raw) : raw;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Разбор команды. Возвращает текст ответа; отправку делает вызывающий.
|
|
597
|
+
// Отказы называем вслух: молчаливый отказ здесь неотличим от «агент не понял».
|
|
598
|
+
async function goalCommand(text, channel, agent) {
|
|
599
|
+
const rest = text.slice('/goal'.length).trim();
|
|
600
|
+
// 🔴 ЖДЁМ СЕРВИС, А НЕ ОТКАЗЫВАЕМ СРАЗУ — и ждём КОРОТКО. Без ожидания
|
|
601
|
+
// ответ «сервис не подключён» был бы диагнозом, которого код поставить не
|
|
602
|
+
// может: undefined одинаково означает и «нет в сборке», и «ещё поднимается».
|
|
603
|
+
// Предел короткий (а не 30 с, как при заведении агента) потому, что на том
|
|
604
|
+
// конце ждёт живой собеседник: полминуты молчания в чате читаются как
|
|
605
|
+
// «бот умер», и лечение снова оказалось бы хуже болезни.
|
|
606
|
+
const goals = await waitService('goals', '[goal]', 'команда постановки цели невыполнима', GOAL_WAIT_TRIES);
|
|
607
|
+
if (!goals) return 'Сервис целей на этом агенте не подключён.';
|
|
608
|
+
let current;
|
|
609
|
+
try { current = goals.get(agent); }
|
|
610
|
+
catch (e) { log(`[goal] 🔴 состояние цели не прочитано: ${e?.message ?? e}`); return `Не смог прочитать состояние цели: ${e?.message ?? e}`; }
|
|
611
|
+
|
|
612
|
+
if (rest === '') {
|
|
613
|
+
if (!current) return 'Цели нет.';
|
|
614
|
+
return `Цель ${current.id}: ${current.phase}, раунд ${current.roundsStarted} из ${current.maxGoalRounds}.`
|
|
615
|
+
+ (current.blockedReason ? `\nПричина остановки: ${current.blockedReason}` : '')
|
|
616
|
+
+ `\nЗамысел: ${String(current.objective).slice(0, 200)}`;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
if (rest === 'stop') {
|
|
620
|
+
// Снятие счётчиком НЕ ограничено — см. границы выше.
|
|
621
|
+
if (!current) return 'Цели нет, снимать нечего.';
|
|
622
|
+
try {
|
|
623
|
+
goals.clear(agent, { id: current.id, revision: current.revision });
|
|
624
|
+
goalOrigin.delete(String(agent.session?.id ?? ''));
|
|
625
|
+
log(`[goal] снята ${current.id} из ${channel}`);
|
|
626
|
+
return `Цель ${current.id} снята.`;
|
|
627
|
+
} catch (e) {
|
|
628
|
+
log(`[goal] 🔴 снятие не удалось: ${e?.message ?? e}`);
|
|
629
|
+
return `Не смог снять цель: ${e?.message ?? e}`;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Постановка. Предел раундов командой НЕ задаётся: он живёт в настройке
|
|
634
|
+
// платформы и не должен подниматься тем же путём, каким запускается
|
|
635
|
+
// дорогой цикл.
|
|
636
|
+
if (!goalRateOk(channel)) {
|
|
637
|
+
log(`[goal] ОТКАЗ ${channel}: исчерпан предел ${GOAL_MAX_PER_HOUR} постановок за час`);
|
|
638
|
+
return `Предел ${GOAL_MAX_PER_HOUR} постановок цели за час исчерпан. Зовите человека.`;
|
|
639
|
+
}
|
|
640
|
+
try {
|
|
641
|
+
let view = goals.create(agent, { objective: rest });
|
|
642
|
+
// 🔴 НАСТАВЛЕНИЕ ПРО МАРКЕР КЛАДЁМ В САМ ЗАМЫСЕЛ. Промпт автоматического
|
|
643
|
+
// раунда несёт только замысел и номер раунда — ни идентификатора цели, ни
|
|
644
|
+
// ревизии в нём нет, а без них модель не сложит маркер. Идентификатор
|
|
645
|
+
// известен только после create, поэтому edit вторым шагом; edit поднимает
|
|
646
|
+
// ревизию на единицу, её и называем в наставлении.
|
|
647
|
+
const marker = await waitService('goalMarker', '[goal]',
|
|
648
|
+
'цель будет поставлена без наставления о маркере', GOAL_WAIT_TRIES);
|
|
649
|
+
if (marker) {
|
|
650
|
+
// 🔴 ИМЕНА ИНСТРУМЕНТОВ БЕРЁМ У МОСТА, А НЕ ПИШЕМ ПО ПАМЯТИ. Протокол
|
|
651
|
+
// MCP добавляет к базовому имени приставку mcp__<сервер>__; наставление,
|
|
652
|
+
// называющее инструмент БЕЗ неё, отсылает модель к несуществующему
|
|
653
|
+
// имени, и выглядит это как «мост не работает». Нет моста — нет и имён:
|
|
654
|
+
// тогда наставление честно говорит, что инструментов нет, и остаётся
|
|
655
|
+
// только пометка.
|
|
656
|
+
const bridge = await waitService('mcpBridge', '[goal]',
|
|
657
|
+
'в наставлении останется только пометка, без имён инструментов', GOAL_WAIT_TRIES);
|
|
658
|
+
const goalTools = bridge?.modelToolName
|
|
659
|
+
? { update: bridge.modelToolName('update_goal'), get: bridge.modelToolName('get_goal') }
|
|
660
|
+
: undefined;
|
|
661
|
+
log(`[goal] наставление: ${goalTools ? `инструмент ${goalTools.update}, пометка запасной линией` : 'моста нет, только пометка'}`);
|
|
662
|
+
view = goals.edit(agent, { id: view.id, revision: view.revision },
|
|
663
|
+
{ objective: `${rest}\n\n${marker.guidance(view.id, view.revision + 1, goalTools)}` });
|
|
664
|
+
} else {
|
|
665
|
+
log('[goal] ⚠️ сервис goalMarker не смонтирован: цель поставлена, но закрыть её сама модель не сможет — цикл дойдёт до предела раундов');
|
|
666
|
+
}
|
|
667
|
+
goalStarts.get(channel).push(Date.now());
|
|
668
|
+
goalOrigin.set(String(agent.session?.id ?? ''), { channel, agent });
|
|
669
|
+
log(`[goal] поставлена ${view.id} из ${channel}, предел ${view.maxGoalRounds} раундов: ${rest.slice(0, 60)}`);
|
|
670
|
+
return `Цель ${view.id} поставлена, предел ${view.maxGoalRounds} раундов.`;
|
|
671
|
+
} catch (e) {
|
|
672
|
+
const code = e?.code ?? '';
|
|
673
|
+
log(`[goal] 🔴 постановка не удалась (${code}): ${e?.message ?? e}`);
|
|
674
|
+
if (code === 'GOAL_ALREADY_EXISTS') return 'Цель уже поставлена. Сначала /goal stop.';
|
|
675
|
+
return `Не смог поставить цель: ${e?.message ?? e}`;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
382
679
|
// ── ВЫХОД: события сессии → сообщения в Telegram
|
|
383
680
|
ctx.on('session/event', (session, event) => {
|
|
384
681
|
// 🔴 DEBUG 2026-08-18: все типы событий
|
|
385
682
|
log(`[event] type=${event.type} session.id=${session?.id} knownSessions=${[...sessionToChat.keys()].join('|')}`);
|
|
386
683
|
const sid = String(session?.id ?? '');
|
|
684
|
+
// 🔴 ИСХОД ЦЕЛИ СООБЩАЕМ САМИ. Доводчик при упоре в предел раундов переводит
|
|
685
|
+
// цель в blocked — и делает это молча: снаружи автономный цикл просто
|
|
686
|
+
// перестаёт просыпаться. Молчаливый останов неотличим от поломки, поэтому
|
|
687
|
+
// говорим в тот канал, из которого цель ставили, и ровно один раз.
|
|
688
|
+
// Блок стоит ДО проверки chatId нарочно: у служебного канала числового чата
|
|
689
|
+
// нет вовсе, и ранний выход съел бы событие.
|
|
690
|
+
if (event.type === 'goal/change') {
|
|
691
|
+
const origin = goalOrigin.get(sid);
|
|
692
|
+
if (origin) {
|
|
693
|
+
try {
|
|
694
|
+
// Ожидания сервиса здесь нет намеренно — см. решение по ctx.get выше:
|
|
695
|
+
// сюда попадают только сессии, где цель уже поставлена, значит пустота
|
|
696
|
+
// означает не «ещё не поднялся», а «пропал», и это стоит строки.
|
|
697
|
+
const goalsSvc = ctx.get('goals');
|
|
698
|
+
if (!goalsSvc) {
|
|
699
|
+
log(`[goal] 🔴 сервис целей исчез — исход цели по сессии ${sid} не сообщён`);
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
const view = goalsSvc.get(origin.agent);
|
|
703
|
+
if (view && view.phase !== 'active') {
|
|
704
|
+
goalOrigin.delete(sid);
|
|
705
|
+
void sayTo(origin.channel, tgChatFor.get(sid) ?? sessionToChat.get(sid),
|
|
706
|
+
`Цель ${view.id}: ${view.phase}`
|
|
707
|
+
+ (view.blockedReason ? `, причина: ${view.blockedReason}` : '')
|
|
708
|
+
+ `. Раундов пройдено ${view.roundsStarted} из ${view.maxGoalRounds}.`);
|
|
709
|
+
}
|
|
710
|
+
} catch (e) { log(`[goal] исход цели не прочитан: ${e?.message ?? e}`); }
|
|
711
|
+
}
|
|
712
|
+
}
|
|
387
713
|
// 🔴 При слиянии sessionToChat указывает на КЛЮЧ служебной сессии, а не на
|
|
388
714
|
// числовой чат — отправка по нему молча не дойдёт. Настоящий чат берём из
|
|
389
715
|
// карты, заполняемой на входе из мессенджера.
|
|
@@ -418,7 +744,12 @@ export function apply(ctx, config = {}) {
|
|
|
418
744
|
&& (turnAsk.get(sid)?.origin ?? lastOrigin.get(sid) ?? (chatId === A2A_CHAT ? 'a2a' : 'tg')) === 'a2a') {
|
|
419
745
|
// ответ координатору — в файл, Telegram тут ни при чём
|
|
420
746
|
const blocks = event.data?.message?.content ?? [];
|
|
421
|
-
|
|
747
|
+
// 🔴 МАРКЕР ВЫРЕЗАЕМ ИЗ ВСЕГО, ЧТО УХОДИТ НАРУЖУ — из доставляемого
|
|
748
|
+
// текста и из копий. Он служебный: его дело остановить цикл, а не
|
|
749
|
+
// попасть человеку в чат. Вырезается только маркер последней строки;
|
|
750
|
+
// маркер в середине оставляем видимым нарочно — он и так отвергнут,
|
|
751
|
+
// и пусть будет заметен читателю.
|
|
752
|
+
const text = stripGoalMarker(blocks.filter((b) => b?.type === 'text').map((b) => b.text).join('\n')).trim();
|
|
422
753
|
if (text) {
|
|
423
754
|
try {
|
|
424
755
|
fs.mkdirSync(A2A_OUT, { recursive: true });
|
|
@@ -430,11 +761,11 @@ export function apply(ctx, config = {}) {
|
|
|
430
761
|
} else if (event.type === 'assistant/message') {
|
|
431
762
|
// Берём только видимый текст: рассуждения модели наружу не отдаём.
|
|
432
763
|
const blocks = event.data?.message?.content ?? [];
|
|
433
|
-
|
|
764
|
+
// Маркер цели наружу не отдаём — см. пояснение веткой выше.
|
|
765
|
+
const text = stripGoalMarker(blocks
|
|
434
766
|
.filter((b) => b?.type === 'text')
|
|
435
767
|
.map((b) => b.text)
|
|
436
|
-
.join('\n')
|
|
437
|
-
.trim();
|
|
768
|
+
.join('\n')).trim();
|
|
438
769
|
if (text) {
|
|
439
770
|
log(`[event] отправляю в Telegram chatId=${chatId} (${text.length} знаков)`);
|
|
440
771
|
{ const c = copyTo('tg'); if (c) sendCopy(c, sid, text); }
|
|
@@ -462,13 +793,12 @@ export function apply(ctx, config = {}) {
|
|
|
462
793
|
return tmp;
|
|
463
794
|
}
|
|
464
795
|
|
|
465
|
-
//
|
|
796
|
+
// Расшифровывает голосовое сообщение внешней командой из настройки.
|
|
466
797
|
// Возвращает строку с текстом или null при ошибке.
|
|
467
798
|
async function transcribeVoice(fileId) {
|
|
468
799
|
let tmpAudio = null;
|
|
469
800
|
try {
|
|
470
801
|
tmpAudio = await downloadTelegramFile(fileId);
|
|
471
|
-
// transcribe-local-shared — наша цепочка: sage-corrector → transcribe-whispercpp-core → whisper-warm.service
|
|
472
802
|
// 🔴 Внешняя команда расшифровки — НАСТРОЙКА, а не зашитый путь: у каждого
|
|
473
803
|
// своя цепочка. Не задана — голосовые просто не расшифровываются, и об этом
|
|
474
804
|
// говорится в журнал, а не молча игнорируется.
|
|
@@ -546,6 +876,25 @@ export function apply(ctx, config = {}) {
|
|
|
546
876
|
return;
|
|
547
877
|
}
|
|
548
878
|
|
|
879
|
+
// Постановка цели — тоже команда канала, разбираем до передачи агенту.
|
|
880
|
+
if (text === '/goal' || text.startsWith('/goal ')) {
|
|
881
|
+
// Право проверяем ДО создания агента: отказ не должен заводить сессию.
|
|
882
|
+
if (!goalUsers.has(Number(userId))) {
|
|
883
|
+
log(`[goal] ОТКАЗ ${userId}: не в списке goalUsers`);
|
|
884
|
+
await tg.send(chatId, 'Постановка цели из этого канала вам не разрешена.');
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
const key = (MERGE_CHAT && String(chatId) === MERGE_CHAT) ? A2A_CHAT : chatId;
|
|
888
|
+
try {
|
|
889
|
+
const { handle: gh } = await agentFor(key);
|
|
890
|
+
await sayTo('tg', chatId, await goalCommand(text, 'tg', gh.agent));
|
|
891
|
+
} catch (e) {
|
|
892
|
+
log(`[goal] 🔴 команда не выполнена: ${e?.message ?? e}`);
|
|
893
|
+
await tg.send(chatId, `Не смог выполнить команду: ${e?.message ?? e}`);
|
|
894
|
+
}
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
|
|
549
898
|
// При включённом слиянии владелец попадает в ту же сессию, что и служебный
|
|
550
899
|
// канал: одна память на двоих. Ответ при этом обязан вернуться в мессенджер,
|
|
551
900
|
// поэтому запоминаем настоящий чат отдельно.
|
|
@@ -649,10 +998,32 @@ export function apply(ctx, config = {}) {
|
|
|
649
998
|
// который нам не по правам (например 600 root:root), молча оставался
|
|
650
999
|
// лежать во входящих. Снаружи это «агент не отвечает», а на деле
|
|
651
1000
|
// мы даже не смогли прочитать вопрос. Поймано 18.08.2026.
|
|
652
|
-
log(`[a2a] 🔴 не
|
|
1001
|
+
log(`[a2a] 🔴 не смог прочитать ${f}: ${e?.message ?? e} (права: файл должен принадлежать владельцу агента и быть читаемым, chmod 644)`);
|
|
653
1002
|
continue;
|
|
654
1003
|
}
|
|
655
1004
|
if (!text) continue;
|
|
1005
|
+
// Заголовок отправителя отделяем ВСЕГДА: он служебный и в разговоре лишний.
|
|
1006
|
+
const { sender, text: body } = splitSender(text);
|
|
1007
|
+
if (sender !== undefined) text = body;
|
|
1008
|
+
if (!text) continue;
|
|
1009
|
+
if (text === '/goal' || text.startsWith('/goal ')) {
|
|
1010
|
+
if (!goalA2ASenders.has(sender)) {
|
|
1011
|
+
log(`[goal] ОТКАЗ служебному каналу: отправитель ${sender ?? '(не назвался)'} не в списке goalA2ASenders`);
|
|
1012
|
+
await sayTo('a2a', null, sender === undefined
|
|
1013
|
+
? 'Постановка цели из служебного канала требует заголовка первой строкой: "From: <имя>".'
|
|
1014
|
+
: `Отправитель ${sender} не вправе ставить цель.`);
|
|
1015
|
+
} else {
|
|
1016
|
+
try {
|
|
1017
|
+
const { handle: gh } = await agentFor(A2A_CHAT);
|
|
1018
|
+
await sayTo('a2a', null, await goalCommand(text, 'a2a', gh.agent));
|
|
1019
|
+
} catch (e) {
|
|
1020
|
+
log(`[goal] 🔴 команда не выполнена: ${e?.message ?? e}`);
|
|
1021
|
+
await sayTo('a2a', null, `Не смог выполнить команду: ${e?.message ?? e}`);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
try { fs.unlinkSync(full); } catch { /* уже убрали */ }
|
|
1025
|
+
continue;
|
|
1026
|
+
}
|
|
656
1027
|
try {
|
|
657
1028
|
const { handle, sessionId: a2aSessionId } = await agentFor(A2A_CHAT);
|
|
658
1029
|
lastOrigin.set(a2aSessionId, 'a2a');
|
|
@@ -664,7 +1035,7 @@ export function apply(ctx, config = {}) {
|
|
|
664
1035
|
// Наш агент на этом верно упёрся — отказался выполнять просьбу,
|
|
665
1036
|
// подписанную чужим именем, пришедшую не из своего канала. Значит
|
|
666
1037
|
// происхождение обязан сообщать код доставки, которому подделать нечем.
|
|
667
|
-
text = `[служебный канал, от
|
|
1038
|
+
text = `[служебный канал, от координатора${sender ? ` ${sender}` : ''}]\n${stripMark(text)}`;
|
|
668
1039
|
handle.agent.send(platform.createUserMessage({
|
|
669
1040
|
content: [{ type: 'text', text }],
|
|
670
1041
|
source: { kind: 'user' },
|
package/test/extract.mjs
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Вырезает тело именованной функции ИЗ ФАЙЛА пакета — стенд обязан проверять
|
|
2
|
+
// установленный код, а не его пересказ. Поиск по ИМЕНИ, не по номеру строки:
|
|
3
|
+
// номер строки протухает от первой же правки выше по файлу.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
|
|
6
|
+
export function readSource(file) {
|
|
7
|
+
return fs.readFileSync(file, 'utf-8');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function extractFunction(src, name) {
|
|
11
|
+
const re = new RegExp(`(?:^|\\n)\\s*(?:async\\s+)?function\\s+${name}\\s*\\(`);
|
|
12
|
+
const m = re.exec(src);
|
|
13
|
+
if (!m) throw new Error(`функция ${name} не найдена в исходнике`);
|
|
14
|
+
// 🔴 Начало берём от `async`, если оно есть: обрезав его, стенд получил бы
|
|
15
|
+
// обычную функцию с await внутри и упал бы с «await is only valid in async
|
|
16
|
+
// functions» — то есть покраснел бы на исправном коде.
|
|
17
|
+
const fnAt = src.indexOf('function', m.index);
|
|
18
|
+
const asyncAt = src.lastIndexOf('async', fnAt);
|
|
19
|
+
const start = (asyncAt !== -1 && /^async\s+function$/.test(src.slice(asyncAt, fnAt + 8).replace(/\s+/g, ' ').trim().replace(/ $/, '')))
|
|
20
|
+
? asyncAt : fnAt;
|
|
21
|
+
const open = src.indexOf('{', src.indexOf('(', start));
|
|
22
|
+
let depth = 0, i = open, inStr = null, esc = false;
|
|
23
|
+
for (; i < src.length; i++) {
|
|
24
|
+
const c = src[i];
|
|
25
|
+
if (inStr) {
|
|
26
|
+
if (esc) { esc = false; continue; }
|
|
27
|
+
if (c === '\\') { esc = true; continue; }
|
|
28
|
+
if (c === inStr) inStr = null;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (c === '"' || c === "'" || c === '`') { inStr = c; continue; }
|
|
32
|
+
if (c === '{') depth++;
|
|
33
|
+
else if (c === '}') { depth--; if (depth === 0) break; }
|
|
34
|
+
}
|
|
35
|
+
if (depth !== 0) throw new Error(`не удалось сбалансировать скобки у ${name}`);
|
|
36
|
+
return src.slice(start, i + 1);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function extractConst(src, name) {
|
|
40
|
+
const re = new RegExp(`\\n\\s*const\\s+${name}\\s*=\\s*([^;\\n]+);`);
|
|
41
|
+
const m = re.exec(src);
|
|
42
|
+
if (!m) throw new Error(`константа ${name} не найдена`);
|
|
43
|
+
return m[1].trim();
|
|
44
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Приёмка постановки цели из канала (1.3.0).
|
|
4
|
+
*
|
|
5
|
+
* Правила, по которым он написан:
|
|
6
|
+
* — текст проверяемых функций берётся ИЗ ФАЙЛА пакета, а не пишется заново;
|
|
7
|
+
* — рядом с вердиктом печатается сырьё, на котором он основан;
|
|
8
|
+
* — код возврата: 0 всё сошлось, 1 расхождение, 2 стенд не смог проверить.
|
|
9
|
+
*
|
|
10
|
+
* Гоняется и на распакованном тарболе: `node test/test-goal-control.mjs`.
|
|
11
|
+
*/
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { readSource, extractFunction, extractConst } from './extract.mjs';
|
|
16
|
+
|
|
17
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const PKG = path.resolve(HERE, '..');
|
|
19
|
+
const SRC_FILE = path.join(PKG, 'src', 'index.js');
|
|
20
|
+
|
|
21
|
+
let ok = 0, bad = 0, blind = 0;
|
|
22
|
+
const pass = (t, raw) => { ok++; console.log(` ok ${t}${raw !== undefined ? ` [${raw}]` : ''}`); };
|
|
23
|
+
const fail = (t, raw) => { bad++; console.log(` FAIL ${t}${raw !== undefined ? ` [${raw}]` : ''}`); };
|
|
24
|
+
const blin = (t, raw) => { blind++; console.log(` ?? ${t} — СТЕНД НЕ СМОГ ПРОВЕРИТЬ${raw !== undefined ? ` [${raw}]` : ''}`); };
|
|
25
|
+
const eq = (t, got, want) => (String(got) === String(want) ? pass(t, `got=${got}`) : fail(t, `got=${got} want=${want}`));
|
|
26
|
+
|
|
27
|
+
let src;
|
|
28
|
+
try { src = readSource(SRC_FILE); }
|
|
29
|
+
catch (e) { console.error(`СТЕНД СЛЕП: не читается ${SRC_FILE}: ${e.message}`); process.exit(2); }
|
|
30
|
+
|
|
31
|
+
// ── 0. Манифест не обещает того, чего нет на диске ────────────────────────────
|
|
32
|
+
console.log('0. целость манифеста');
|
|
33
|
+
try {
|
|
34
|
+
const pj = JSON.parse(fs.readFileSync(path.join(PKG, 'package.json'), 'utf-8'));
|
|
35
|
+
const promised = new Set([...(pj.files ?? []), pj.main, pj.dsh?.bundle?.patch].filter(Boolean)
|
|
36
|
+
.map((f) => f.replace(/^\.\//, '')));
|
|
37
|
+
for (const f of promised) {
|
|
38
|
+
fs.existsSync(path.join(PKG, f))
|
|
39
|
+
? pass(`манифест обещает ${f} — файл на месте`)
|
|
40
|
+
: fail(`манифест обещает ${f}, а его на диске НЕТ`);
|
|
41
|
+
}
|
|
42
|
+
eq('версия пакета', pj.version, '1.3.0');
|
|
43
|
+
} catch (e) { blin('манифест', e.message); }
|
|
44
|
+
|
|
45
|
+
// ── 1. Счётчик постановок ─────────────────────────────────────────────────────
|
|
46
|
+
console.log('1. счётчик постановок');
|
|
47
|
+
try {
|
|
48
|
+
const MAX = Number(extractConst(src, 'GOAL_MAX_PER_HOUR'));
|
|
49
|
+
eq('предел из файла', MAX, 3);
|
|
50
|
+
const goalStarts = new Map();
|
|
51
|
+
const goalRateOk = new Function('goalStarts', 'GOAL_MAX_PER_HOUR',
|
|
52
|
+
`${extractFunction(src, 'goalRateOk')}; return goalRateOk;`)(goalStarts, MAX);
|
|
53
|
+
let allowed = 0;
|
|
54
|
+
for (let i = 0; i < 5; i++) if (goalRateOk('tg')) { allowed++; goalStarts.get('tg').push(Date.now()); }
|
|
55
|
+
eq(`из пяти подряд пропущено ${MAX}`, allowed, MAX);
|
|
56
|
+
// старые метки выпадают из скользящего часа
|
|
57
|
+
goalStarts.set('tg', [Date.now() - 3600001, Date.now() - 3600001, Date.now() - 3600001]);
|
|
58
|
+
eq('метки старше часа не считаются', goalRateOk('tg'), true);
|
|
59
|
+
// счётчик отдельный на канал
|
|
60
|
+
goalStarts.set('tg', [Date.now(), Date.now(), Date.now()]);
|
|
61
|
+
eq('канал tg исчерпан', goalRateOk('tg'), false);
|
|
62
|
+
eq('канал a2a при этом свободен', goalRateOk('a2a'), true);
|
|
63
|
+
} catch (e) { blin('счётчик', e.message); }
|
|
64
|
+
|
|
65
|
+
// ── 2. Заголовок отправителя ──────────────────────────────────────────────────
|
|
66
|
+
console.log('2. заголовок отправителя');
|
|
67
|
+
try {
|
|
68
|
+
const splitSender = new Function(`${extractFunction(src, 'splitSender')}; return splitSender;`)();
|
|
69
|
+
let r = splitSender('From: coordinator\n/goal сделать дело');
|
|
70
|
+
eq('имя разобрано', r.sender, 'coordinator');
|
|
71
|
+
eq('заголовок отрезан', r.text, '/goal сделать дело');
|
|
72
|
+
r = splitSender('просто текст без заголовка');
|
|
73
|
+
eq('без заголовка — имени нет', r.sender, undefined);
|
|
74
|
+
eq('без заголовка текст цел', r.text, 'просто текст без заголовка');
|
|
75
|
+
r = splitSender('From: coordinator');
|
|
76
|
+
eq('заголовок без тела — пустой текст', r.text, '');
|
|
77
|
+
r = splitSender('From: two words\nтело');
|
|
78
|
+
eq('имя с пробелом заголовком не считается', r.sender, undefined);
|
|
79
|
+
} catch (e) { blin('splitSender', e.message); }
|
|
80
|
+
|
|
81
|
+
// ── 3. Ожидание сервиса ───────────────────────────────────────────────────────
|
|
82
|
+
console.log('3. ожидание сервиса');
|
|
83
|
+
try {
|
|
84
|
+
const lines = [];
|
|
85
|
+
const log = (m) => lines.push(m);
|
|
86
|
+
const mk = (ctx) => new Function('ctx', 'log', `${extractFunction(src, 'waitService')}; return waitService;`)(ctx, log);
|
|
87
|
+
|
|
88
|
+
let calls = 0;
|
|
89
|
+
const late = mk({ get: () => (++calls >= 3 ? { real: true } : undefined) });
|
|
90
|
+
const t0 = Date.now();
|
|
91
|
+
const got = await late('goals', '[t]', 'последствие названо', 5);
|
|
92
|
+
const spent = Date.now() - t0;
|
|
93
|
+
got ? pass(`сервис дождались за ${spent} мс`, `попыток=${calls}`) : fail('сервис не дождались, хотя он появился');
|
|
94
|
+
lines.some((l) => l.includes('ещё не активен')) ? pass('о начале ожидания сказано') : fail('ожидание молчаливое');
|
|
95
|
+
|
|
96
|
+
lines.length = 0;
|
|
97
|
+
const never = mk({ get: () => undefined });
|
|
98
|
+
const none = await never('goals', '[t]', 'ПОСЛЕДСТВИЕ-МЕТКА', 2);
|
|
99
|
+
eq('не дождались — undefined', none, undefined);
|
|
100
|
+
lines.some((l) => l.includes('ПОСЛЕДСТВИЕ-МЕТКА'))
|
|
101
|
+
? pass('последствие названо в журнале', lines[lines.length - 1])
|
|
102
|
+
: fail('последствие НЕ названо', lines.join(' | '));
|
|
103
|
+
|
|
104
|
+
lines.length = 0;
|
|
105
|
+
const now = mk({ get: () => ({ real: true }) });
|
|
106
|
+
await now('goals', '[t]', 'последствие', 5);
|
|
107
|
+
lines.length === 0 ? pass('сервис готов сразу — лишних строк нет') : fail('печатает, когда ждать не пришлось', lines.join(' | '));
|
|
108
|
+
} catch (e) { blin('waitService', e.message); }
|
|
109
|
+
|
|
110
|
+
// ── 4. Команда целиком, на подставных сервисах ───────────────────────────────
|
|
111
|
+
console.log('4. команда /goal на подставных сервисах');
|
|
112
|
+
try {
|
|
113
|
+
const lines = [];
|
|
114
|
+
const log = (m) => lines.push(m);
|
|
115
|
+
const goalStarts = new Map();
|
|
116
|
+
const goalOrigin = new Map();
|
|
117
|
+
const agent = { session: { id: 'sess-1' } };
|
|
118
|
+
let created = null;
|
|
119
|
+
const goals = {
|
|
120
|
+
get: () => created,
|
|
121
|
+
create: (_a, { objective }) => (created = { id: 'g1', revision: 1, phase: 'active', roundsStarted: 0, maxGoalRounds: 12, objective }),
|
|
122
|
+
edit: (_a, _k, { objective }) => (created = { ...created, revision: created.revision + 1, objective }),
|
|
123
|
+
clear: () => { created = null; },
|
|
124
|
+
};
|
|
125
|
+
const marker = { guidance: (id, rev, tools) => `НАСТАВЛЕНИЕ ${id}/${rev} ${tools ? tools.update : 'без-инструментов'}` };
|
|
126
|
+
const bridge = { modelToolName: (n) => `mcp__srv__${n}` };
|
|
127
|
+
const services = { goals, goalMarker: marker, mcpBridge: bridge };
|
|
128
|
+
const ctx = { get: (n) => services[n] };
|
|
129
|
+
|
|
130
|
+
const src4 = `${extractFunction(src, 'waitService')}\n${extractFunction(src, 'goalRateOk')}\n${extractFunction(src, 'goalCommand')}\n; return goalCommand;`;
|
|
131
|
+
const goalCommand = new Function('ctx', 'log', 'goalStarts', 'goalOrigin', 'GOAL_MAX_PER_HOUR', 'GOAL_WAIT_TRIES', src4)(
|
|
132
|
+
ctx, log, goalStarts, goalOrigin, Number(extractConst(src, 'GOAL_MAX_PER_HOUR')), 1);
|
|
133
|
+
|
|
134
|
+
let r = await goalCommand('/goal', 'tg', agent);
|
|
135
|
+
eq('цели нет — так и сказано', r, 'Цели нет.');
|
|
136
|
+
|
|
137
|
+
r = await goalCommand('/goal разобрать отказы', 'tg', agent);
|
|
138
|
+
r.includes('поставлена') ? pass('цель поставлена', r) : fail('цель не поставлена', r);
|
|
139
|
+
created.objective.includes('НАСТАВЛЕНИЕ g1/2 mcp__srv__update_goal')
|
|
140
|
+
? pass('наставление сложено с именем инструмента ОТ МОСТА', created.objective.split('\n').pop())
|
|
141
|
+
: fail('наставление без имени инструмента моста', created.objective);
|
|
142
|
+
eq('происхождение цели запомнено', goalOrigin.get('sess-1')?.channel, 'tg');
|
|
143
|
+
|
|
144
|
+
r = await goalCommand('/goal', 'tg', agent);
|
|
145
|
+
r.startsWith('Цель g1: active') ? pass('состояние показано', r.split('\n')[0]) : fail('состояние не показано', r);
|
|
146
|
+
|
|
147
|
+
r = await goalCommand('/goal stop', 'tg', agent);
|
|
148
|
+
eq('цель снята', r, 'Цель g1 снята.');
|
|
149
|
+
eq('происхождение забыто', goalOrigin.has('sess-1'), false);
|
|
150
|
+
|
|
151
|
+
// предел постановок
|
|
152
|
+
goalStarts.set('a2a', [Date.now(), Date.now(), Date.now()]);
|
|
153
|
+
r = await goalCommand('/goal ещё одна', 'a2a', agent);
|
|
154
|
+
r.includes('Предел') ? pass('четвёртая за час отвергнута', r) : fail('предел не сработал', r);
|
|
155
|
+
|
|
156
|
+
// 🔴 снятие пределом НЕ ограничено — механизм, который нельзя выключить, хуже отсутствия
|
|
157
|
+
created = { id: 'g2', revision: 1, phase: 'active', roundsStarted: 1, maxGoalRounds: 12, objective: 'x' };
|
|
158
|
+
r = await goalCommand('/goal stop', 'a2a', agent);
|
|
159
|
+
eq('снятие при исчерпанном пределе разрешено', r, 'Цель g2 снята.');
|
|
160
|
+
|
|
161
|
+
// сервиса целей нет вовсе
|
|
162
|
+
const lines2 = [];
|
|
163
|
+
const goalCommand2 = new Function('ctx', 'log', 'goalStarts', 'goalOrigin', 'GOAL_MAX_PER_HOUR', 'GOAL_WAIT_TRIES', src4)(
|
|
164
|
+
{ get: () => undefined }, (m) => lines2.push(m), new Map(), new Map(), 3, 1);
|
|
165
|
+
r = await goalCommand2('/goal что-нибудь', 'tg', agent);
|
|
166
|
+
r.includes('не подключён') ? pass('без сервиса целей отказ внятный', r) : fail('без сервиса целей ответ невнятный', r);
|
|
167
|
+
lines2.some((l) => l.includes('невыполнима')) ? pass('и последствие названо в журнале', lines2[lines2.length - 1]) : fail('журнал молчит об отказе', lines2.join(' | '));
|
|
168
|
+
|
|
169
|
+
// маркера нет — цель ставится, но об этом сказано
|
|
170
|
+
const lines3 = [];
|
|
171
|
+
let created3 = null;
|
|
172
|
+
const goals3 = { get: () => created3,
|
|
173
|
+
create: (_a, { objective }) => (created3 = { id: 'g3', revision: 1, phase: 'active', roundsStarted: 0, maxGoalRounds: 12, objective }),
|
|
174
|
+
edit: () => { throw new Error('edit не должен зваться без маркера'); }, clear: () => {} };
|
|
175
|
+
const goalCommand3 = new Function('ctx', 'log', 'goalStarts', 'goalOrigin', 'GOAL_MAX_PER_HOUR', 'GOAL_WAIT_TRIES', src4)(
|
|
176
|
+
{ get: (n) => (n === 'goals' ? goals3 : undefined) }, (m) => lines3.push(m), new Map(), new Map(), 3, 1);
|
|
177
|
+
r = await goalCommand3('/goal без маркера', 'tg', agent);
|
|
178
|
+
r.includes('поставлена') ? pass('без маркера цель всё равно поставлена', r) : fail('без маркера постановка сорвалась', r);
|
|
179
|
+
lines3.some((l) => l.includes('goalMarker') && l.includes('предела раундов'))
|
|
180
|
+
? pass('последствие отсутствия маркера названо')
|
|
181
|
+
: fail('об отсутствии маркера промолчали', lines3.join(' | '));
|
|
182
|
+
} catch (e) { blin('goalCommand', e.message); }
|
|
183
|
+
|
|
184
|
+
// ── 5. Описание не расходится с кодом ────────────────────────────────────────
|
|
185
|
+
console.log('5. README, патч и код говорят одно');
|
|
186
|
+
try {
|
|
187
|
+
const readme = fs.readFileSync(path.join(PKG, 'README.md'), 'utf-8');
|
|
188
|
+
const patch = fs.readFileSync(path.join(PKG, 'cordis.patch.yml'), 'utf-8');
|
|
189
|
+
for (const field of ['goalUsers', 'goalA2ASenders']) {
|
|
190
|
+
const inCode = src.includes(`config.${field}`);
|
|
191
|
+
const inReadme = readme.includes(`\`${field}\``);
|
|
192
|
+
const inPatch = patch.includes(`${field}:`);
|
|
193
|
+
(inCode && inReadme && inPatch)
|
|
194
|
+
? pass(`${field}: код+README+патч`, `код=${inCode} readme=${inReadme} патч=${inPatch}`)
|
|
195
|
+
: fail(`${field} описан не везде`, `код=${inCode} readme=${inReadme} патч=${inPatch}`);
|
|
196
|
+
}
|
|
197
|
+
const maxInCode = extractConst(src, 'GOAL_MAX_PER_HOUR');
|
|
198
|
+
readme.includes('не более трёх за скользящий час') && maxInCode === '3'
|
|
199
|
+
? pass('предел в README и в коде сходится', `код=${maxInCode}`)
|
|
200
|
+
: fail('предел в README разошёлся с кодом', `код=${maxInCode}`);
|
|
201
|
+
} catch (e) { blin('README', e.message); }
|
|
202
|
+
|
|
203
|
+
// ── 6. Проверка «ничего нашего не уехало» ВЫНЕСЕНА ИЗ ПАКЕТА ───────────────
|
|
204
|
+
// Она лежит рядом со сборкой (proverka-sborki.mjs) и в тарбол не едет.
|
|
205
|
+
// Причина: её образцы — это и есть наши имена и пути, то есть проверка
|
|
206
|
+
// обезличенности сама несла их в опубликованный файл. Обнаружено на
|
|
207
|
+
// сборке 1.3.0: единственное вхождение имени агента в тарболе было
|
|
208
|
+
// внутри самой этой проверки. Чужому человеку она бесполезна — это
|
|
209
|
+
// проверка НАШЕГО процесса сборки, а не работы плагина.
|
|
210
|
+
|
|
211
|
+
console.log(`\nитог: ok=${ok} FAIL=${bad} слепота=${blind}`);
|
|
212
|
+
process.exit(bad > 0 ? 1 : (blind > 0 ? 2 : 0));
|