dsh-telegram-multiagent 1.4.2 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,79 @@
1
+ ## 1.5.0
2
+
3
+ 🔴 **`/compact-status` больше не выдаёт устаревшую опору за свежую.** Строка «Опора замера:
4
+ usage (числа провайдера)» была правдива про ИСТОЧНИК и обманчива про АКТУАЛЬНОСТЬ: измеритель
5
+ копирует `baseline` из якоря вместе с его `kind`, а якорь ставится только на
6
+ `assistant/message`. После компакта поверхность пересчитывается, якорь остаётся прежним — и
7
+ команда сообщала «числа провайдера» рядом с числом, которого провайдер не называл. Флаг не
8
+ отсутствовал, он ПРИСУТСТВОВАЛ И УСПОКАИВАЛ, а это хуже отсутствия.
9
+
10
+ Три состояния вместо двух:
11
+
12
+ опора свежая «(числа провайдера)», расстояние до порога печатается
13
+ опора устарела «🔴 ЧИСЛА СНЯТЫ ДО КОМПАКТА», расстояние НЕ печатается вовсе
14
+ снять не удалось «⚠️ свежесть снять НЕ МОГУ (причина)», расстояние не печатается
15
+
16
+ Расстояние до порога от устаревшей опоры не «примерно», а НЕИЗВЕСТНО: вырезанное вычтено по
17
+ эвристической цене (4 знака = 1 токен), а она на кириллице занижает в разы.
18
+
19
+ Интерфейс сессии определяется ПО ФАКТУ, не по номеру версии: в одних редакциях платформы у
20
+ сессии массив `events`, в других `eventAt()` + `seq`. Не сумели снять — говорим «не знаю», а
21
+ НЕ «свежо»: молчаливое «свежо» при пропавшем интерфейсе было бы зелёным, которое не может
22
+ покраснеть.
23
+
24
+ Цена обхода замерена: **0,46 мс** на журнале в 61 866 событий.
25
+
26
+ 🔴 **Заведён `scripts.test` — его не было ВОВСЕ.** То есть `npm test` у получателя не гонял ни
27
+ одного из шести стендов, и снаружи пакет был неотличим от пакета без проверок. Прогонщик
28
+ снимает перечень обходом каталога, а не ведёт его рукой; `files` перечисляет каталоги, а не
29
+ файлы поимённо, поэтому новый стенд едет к получателю сам.
30
+ Прогон сразу показал законную слепоту `stend-imena` (имена проверяются только в двух строках
31
+ предмета, стенда по всему составу нет) — она была всегда, её просто некому было увидеть.
32
+
33
+ Новый стенд `stend-svezhesti-opory.mjs`: 12 проб, вырезает признак ИЗ ПРЕДМЕТА, а не хранит
34
+ копию. Две порчи дают РАЗНЫЕ ответы — удаление признака слепоту (2), излом логики при
35
+ сохранной форме расхождение (1).
36
+
37
+ ⚠️ Порог по-прежнему считается как `окно × 0.8` — умолчание платформы. Появится
38
+ `thresholdRatio` в составе — число станет ложью, а стенд не покраснеет: он читает наш файл,
39
+ а не чужой состав. Эта граница не тронута и не починена.
40
+
41
+ # Изменения
42
+
43
+ Указатель по версиям. Подробности каждой — отдельным разделом README: здесь
44
+ только строка о сути и ссылка, чтобы два описания одного и того же не разошлись.
45
+
46
+ ## 1.4.3 — команды платформы прямо из чата
47
+
48
+ Сообщение, начинающееся со слэша, отдаётся реестру команд платформы, а не
49
+ модели. Работают `/compact` (штатная команда сжатия) и `/compact-status`
50
+ (наша: сколько занято контекста и далеко ли до порога), плюс короткие `/ccc`
51
+ и `/cs`. Ответ команды называет сессию, над которой она выполнена.
52
+
53
+ Подробности и купленные боем правила — README, раздел «1.4.3».
54
+
55
+ ## 1.4.2 — обезличивание опубликованного
56
+
57
+ Из двух строк журнала убрана кличка чужого агента. Изменений поведения нет.
58
+
59
+ 🔴 Код 1.4.2 и код 1.4.1 у нас на узле были ТОЖДЕСТВЕННЫ ещё до выпуска 1.4.2:
60
+ правка обезличивания легла в рабочий файл раньше, чем была выпущена версия.
61
+ Живое отставало от реестра номером, но не содержанием. Записано здесь потому,
62
+ что через полгода такое расхождение читается как потерянная правка.
63
+
64
+ ## 1.4.1 — четыре исхода побудки вместо одного
65
+
66
+ Механизм побудки печатал беду при штатной работе: «сообщение не забрали» в 45
67
+ случаях из 48 за сутки при нуле побудок. Теперь исходы различаются — забрали,
68
+ агент был занят все проверки, побудка не помогла, очередь была пуста сразу, —
69
+ и 🔴 остаётся только там, где механизм действительно не сработал.
70
+ Добавлен стенд `test/stend-pobudki.mjs`.
71
+
72
+ ## 1.4.0 — причина сетевого отказа и чтение почтового ящика
73
+
74
+ См. README, раздел «1.4.0».
75
+
76
+ ## Более ранние
77
+
78
+ 1.3.0 — постановка цели из канала. 1.2.0 — отправка переживает сбой сети.
79
+ 1.1.0 — кто спросил, кому отвечено, и кто это видит. Разделы в README.
package/README.md CHANGED
@@ -103,6 +103,69 @@
103
103
 
104
104
  MIT.
105
105
 
106
+ ## 1.4.3 — команды платформы прямо из чата
107
+
108
+ **Ломающих изменений НЕТ.** Добавлены два стенда, из состава ничего не пропало.
109
+
110
+ Сообщение, начинающееся со слэша, теперь отдаётся **реестру команд платформы**,
111
+ а не модели. До этого владелец слал `/compact`, агент отвечал «сейчас сожму» —
112
+ и не сжимал ничего: у модели такого инструмента нет вовсе.
113
+
114
+ | Команда | Что делает |
115
+ |---|---|
116
+ | `/compact`, `/ccc` | Штатное сжатие истории (`dsh-command-compact`). |
117
+ | `/compact-status`, `/cs` | Сколько занято контекста и далеко ли до порога сжатия. Команда наша: у платформы своей нет. |
118
+
119
+ Всё остальное со слэшем модель получает как обычный текст: реестр отвечает
120
+ `undefined` на незнакомое имя, и проглотить такое сообщение было бы хуже, чем
121
+ не понять его.
122
+
123
+ **Ответ называет сессию, над которой выполнен.** Строчка `(сессия <id>)`
124
+ приписывается к любому ответу команды. Стоила она получаса пустого
125
+ расследования: команда была подана из личного чата, а сжалась другая сессия —
126
+ выглядело дефектом, а было настройкой `mergeChatIntoA2A`, сводящей два канала
127
+ в одну память. Числа настоящие, расхождение настоящее, объяснения нет.
128
+
129
+ ### Четыре правила, купленные боем
130
+
131
+ **Служба берётся через `ctx.get(имя)`, а не чтением `ctx.имя`.** Платформа
132
+ запрещает читать службу без объявления в `inject`, и проверяет это **в момент
133
+ обращения**, а не при монтаже. Обе команды владельца упали в бою на
134
+ `cannot get property "commands" without inject`.
135
+
136
+ **И не через `inject`.** Объявление встало бы в `pending` и обрубило связь с
137
+ владельцем целиком: канал важнее команды. Проверено канарейкой на другой службе.
138
+
139
+ **Получение службы — внутри `try`, а общий перехватчик тоже отвечает в чат.**
140
+ Первая правка бросок вылечила, но он улетал в общий перехватчик обновления и
141
+ оставался только в журнале: владелец не получал ни ответа, ни отказа. Условие
142
+ «отвечать при любом исходе» было выполнено внутри `try` и не выполнено снаружи —
143
+ у защиты было место, где она не применяется, и оно не было названо.
144
+
145
+ **Алиасы разворачиваются в имя штатной команды, а не в свою реализацию.**
146
+ `/ccc` → `/compact`. Прямой вызов движка сжатия сработал бы, но не записал бы
147
+ событие `command/run` — то самое, которым отличают «команда не дошла» от
148
+ «механизм молчит». Чинить доставку способом, ослепляющим проверку доставки,
149
+ нельзя. Концевой якорь в шаблоне обязателен: без него `/compactstatus`
150
+ сматчится как `/compact` и молча сожмёт историю.
151
+
152
+ ### Чего `/compact-status` не выдумывает
153
+
154
+ Занятость берётся у платформенного `tokenMeter.measure` — тем же числом
155
+ платформа решает, пора ли сжимать. Не из `usage` последнего ответа: там
156
+ `inputTokens` бывает равен двум при шестистах тысячах прочитанного из кэша.
157
+
158
+ Окно контекста спрашивается у службы `llm` тем же путём, каким его берёт
159
+ сама платформа: цель из заголовка запроса сессии, окно — у адаптера модели.
160
+ **Не нашлось — команда так и говорит, называя причину, и умолчание не
161
+ подставляет.** Выдуманное окно выглядит как замер и врёт ровно там, где на
162
+ него посмотрят.
163
+
164
+ Порог считается как «окно × 0.8». 0.8 — умолчание платформы, и оно верно
165
+ ровно потому, что в нашем составе сжатие смонтировано без своей настройки.
166
+ Переопределите `thresholdRatio` — это число станет неверным, и стенд не
167
+ покраснеет: он читает наш файл, а не ваш состав.
168
+
106
169
  ## 1.4.0 — причина сетевого отказа и чтение почтового ящика
107
170
 
108
171
  **Ломающих изменений НЕТ.** Проверено сравнением состава: из 1.3.0 не пропало ни одного
package/package.json CHANGED
@@ -1,19 +1,18 @@
1
1
  {
2
2
  "name": "dsh-telegram-multiagent",
3
- "version": "1.4.2",
3
+ "version": "1.5.0",
4
4
  "description": "Канал Telegram для DeepSeek Harness: один модуль на машину обслуживает несколько агентов, у каждого свой бот, свой файл токена и свой список разрешённых. Метка отправителя неподделываема, память владельца и координатора слита в одну, режим доставки переключается файлом вне кода, цели управляются из канала.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
+ "scripts": {
8
+ "test": "node test/vse-stendy.mjs"
9
+ },
7
10
  "files": [
8
- "src/index.js",
9
- "src/client.js",
10
- "test/extract.mjs",
11
- "test/stend-imena.mjs",
12
- "test/stend-yashchika-shiny.mjs",
13
- "test/test-goal-control.mjs",
14
- "test/stend-pobudki.mjs",
11
+ "src",
12
+ "test",
15
13
  "cordis.patch.yml",
16
- "README.md"
14
+ "README.md",
15
+ "CHANGELOG.md"
17
16
  ],
18
17
  "keywords": [
19
18
  "dsh",
@@ -36,5 +35,8 @@
36
35
  "bundle": {
37
36
  "patch": "./cordis.patch.yml"
38
37
  }
38
+ },
39
+ "devDependencies": {
40
+ "@deepseek-ai/cordis": "4.0.1"
39
41
  }
40
42
  }
package/src/index.js CHANGED
@@ -69,6 +69,14 @@ function platformOf(config, log) {
69
69
  const ACTIVE_POLLERS = new Map();
70
70
 
71
71
  export const name = 'dsh-telegram-multiagent';
72
+ // 🔴 compaction и tokenMeter НЕ в inject — намеренно, куплено канарейкой 02.09.2026.
73
+ // Первая редакция объявляла их здесь, и копия платформы НЕ ПОДНЯЛАСЬ ВОВСЕ:
74
+ // dsh-telegram-multiagent: pending (waiting for service: compaction)
75
+ // Сервис компакта живёт в пресете, а этот модуль монтируется слоем профиля, и
76
+ // inject превращает «нужно для одной команды» в «не монтируй меня, пока нет».
77
+ // Цена ошибки была бы полной: связь с владельцем не поднялась бы совсем — из-за
78
+ // удобной команды. Берём службы ЛЕНИВО, в момент вызова, и отказываем ГРОМКО,
79
+ // если их нет: канал связи важнее команды в нём.
72
80
  export const inject = ['agents'];
73
81
 
74
82
  /**
@@ -941,6 +949,92 @@ export function apply(ctx, config = {}) {
941
949
  return;
942
950
  }
943
951
 
952
+ // ── КОМАНДЫ ПЛАТФОРМЫ ИЗ ТЕЛЕГРАМА (02.09.2026, слово владельца) ────────
953
+ // Владелец работает с агентом ТОЛЬКО через телеграм, а slash-команды платформы
954
+ // приходят от клиента событием command/run — этот канал их не подавал: за всю
955
+ // жизнь сессии таких событий НОЛЬ. Его «/compact» доехал обычным текстом, агент
956
+ // прочёл его как сообщение, и сжатие не случилось.
957
+ //
958
+ // 🔴 ОТДАЁМ СТРОКУ В ШТАТНЫЙ РЕЕСТР, А НЕ ЗОВЁМ ДВИЖОК САМИ. Первая редакция
959
+ // звала ctx.compaction.compactNow напрямую — работало, но ослепляло прибор:
960
+ // command/run пишет РЕЕСТР, а не compactNow, и счётчик этих событий остался бы
961
+ // нулём ПО ПОСТРОЕНИЮ. А именно им мы 02.09 отличили «команда не дошла» от
962
+ // «слой восстановления молчит» — и сэкономили полчаса поиска следа, которого не
963
+ // могло быть. Чинить доставку способом, который ослепляет проверку доставки,
964
+ // нельзя. Плюс свой разбор и свои тексты ошибок разошлись бы со штатными молча.
965
+ //
966
+ // 🔴 АЛИАСЫ РАЗВОРАЧИВАЕМ В ИМЯ ШТАТНОЙ КОМАНДЫ, а не заводим вторую реализацию:
967
+ // /ccc -> /compact (штатная команда платформы, dsh-command-compact)
968
+ // /cs -> /compact-status (наша, регистрируется ниже: у DSH её нет вовсе)
969
+ // Концевой якорь обязателен: без него «/compactstatus» сматчится как /compact
970
+ // и МОЛЧА сожмёт историю (инцидент соседней фермы 07.06), а «/csv» — как /cs.
971
+ if (text.startsWith('/')) {
972
+ const stroka = text
973
+ .replace(/^\/ccc(\s|$)/, '/compact$1')
974
+ .replace(/^\/cs(\s|$)/, '/compact-status$1');
975
+ // 🔴 СЛУЖБА БЕРЁТСЯ ЧЕРЕЗ ctx.get, А НЕ ctx.commands — куплено боем 02.09.2026.
976
+ // Обе команды владельца упали:
977
+ // [<агент>] ошибка обработки обновления: cannot get property "commands" without inject
978
+ // Платформа запрещает ЧИТАТЬ ctx.<служба> без объявления в inject; «лениво» на
979
+ // чтении не работает — cordis проверяет доступ в момент обращения, а не только
980
+ // решает pending при монтаже. Законный путь один: ctx.get(имя).
981
+ //
982
+ // 🔴 И НЕ inject: он вернул бы pending и обрубил связь с владельцем целиком —
983
+ // это доказано канарейкой на службе compaction часом раньше. Канал важнее команды.
984
+ //
985
+ // 🔴 ПОЧЕМУ ЭТО НЕ ПОЙМАЛИ РАНЬШЕ. Проверяли ДОСТУПНОСТЬ службы: подъём показал
986
+ // «сервис commands появился через 1 с» и регистрация /compact-status удалась —
987
+ // но то был ДРУГОЙ путь, ctx.get внутри waitService. Доступна одним путём,
988
+ // запрещена другим. Доставку проверяет только доставленное.
989
+ // 🔴 ПОЛУЧЕНИЕ СЛУЖБЫ — ВНУТРИ try. Куплено боем 02.09.2026 дважды:
990
+ // первый раз строка бросала («without inject»), второй — оказалось, что бросок
991
+ // улетал в ОБЩИЙ catch обновления и оставался ТОЛЬКО В ЛОГЕ. Владелец не получил
992
+ // ничего: ни ответа, ни отказа. Наше условие «отвечать в чат при любом исходе»
993
+ // было выполнено ВНУТРИ try и не выполнено СНАРУЖИ — у защиты было место, где
994
+ // она не применяется, и оно не было названо. Теперь под try и получение службы,
995
+ // и создание агента: бросок любого из них отвечает в чат.
996
+ // 🔴 ОТВЕТ НАЗЫВАЕТ ПРЕДМЕТ СВОЕГО ДЕЙСТВИЯ (03.09.2026).
997
+ // Куплено получасом пустого расследования: команда подана из личного чата
998
+ // владельца, а сжалась сессия a2a — расхождение выглядело как дефект, а было
999
+ // НАШЕЙ ЖЕ настройкой mergeChatIntoA2A (строка 145 и key ниже). Числа
1000
+ // настоящие, объяснения нет — не хватало только строчки о том, над чем
1001
+ // работали. Сообщение, называющее предмет, снимает вопрос до его появления.
1002
+ // ГДЕ НЕ ПРИМЕНЯЕТСЯ: если agentFor бросил, сессии ещё нет — хвоста не будет,
1003
+ // и приписывать выдуманный id туда нельзя.
1004
+ let sid = null;
1005
+ try {
1006
+ const reestrKomand = ctx.get('commands');
1007
+ if (reestrKomand?.execute) {
1008
+ const key = (MERGE_CHAT && String(chatId) === MERGE_CHAT) ? A2A_CHAT : chatId;
1009
+ const { handle: ch, sessionId } = await agentFor(key);
1010
+ sid = sessionId;
1011
+ const vypolneno = await reestrKomand.execute(ch.agent, stroka, [], new AbortController().signal);
1012
+ // undefined = это НЕ команда. Тогда текст идёт в модель, как раньше:
1013
+ // проглотить его здесь значило бы съесть обычное сообщение со слэшем.
1014
+ if (vypolneno !== undefined) {
1015
+ const r = vypolneno.result;
1016
+ log(`[команда] ${stroka.split(/\s/)[0]} -> ${r?.kind ?? '?'} (id ${vypolneno.commandId})`);
1017
+ // Текст в чат ДОСЛОВНО на ОБЕИХ ветках. Молчание на ошибке — тот же класс,
1018
+ // что молчание успеха: снаружи неотличимо от «команда опять не дошла».
1019
+ const otvet = r?.text || (r?.kind === 'success'
1020
+ ? 'Команда выполнена.' : 'Команда завершилась ошибкой без объяснения.');
1021
+ await tg.send(chatId, `${otvet}\n\n(сессия ${sid})`);
1022
+ return;
1023
+ }
1024
+ } else if (/^\/(compact|ccc|cs|compact-status)(\s|$)/.test(text)) {
1025
+ // Отказ ГРОМКИЙ, а не тихий пропуск в модель: тихий и привёл к тому, что
1026
+ // владелец слал /compact, агент отвечал «не понимаю», и ничего не случалось.
1027
+ log('[команда] 🔴 реестр команд недоступен — команда НЕ выполнена');
1028
+ await tg.send(chatId, 'Реестр команд недоступен: выполнить не могу.');
1029
+ return;
1030
+ }
1031
+ } catch (e) {
1032
+ log(`[команда] 🔴 отказ на «${stroka.split(/\s/)[0]}»: ${e?.message ?? e}`);
1033
+ await tg.send(chatId, `Команда не выполнена${sid ? ` (сессия ${sid})` : ''}: ${String(e?.message ?? e).slice(0, 200)}`);
1034
+ return;
1035
+ }
1036
+ }
1037
+
944
1038
  // При включённом слиянии владелец попадает в ту же сессию, что и служебный
945
1039
  // канал: одна память на двоих. Ответ при этом обязан вернуться в мессенджер,
946
1040
  // поэтому запоминаем настоящий чат отдельно.
@@ -1004,8 +1098,10 @@ export function apply(ctx, config = {}) {
1004
1098
  const A2A_CHAT = config.a2aSession || 'a2a'; // отдельная сессия, не смешивается с Telegram
1005
1099
 
1006
1100
  // ── ВТОРОЙ ИСТОЧНИК ВХОДЯЩИХ: ЯЩИК ШИНЫ A2A (31.08.2026) ────────────────────
1007
- // Шина между агентами машины: отправитель кладёт письмо в щель
1008
- // /var/spool/a2a/<получатель>/ (каталог 1730: опустить можно, прочесть нельзя),
1101
+ // Шина между агентами машины: отправитель кладёт письмо в щель, а получатель
1102
+ // забирает. Каталог задаётся настройкой spoolDir; у НАС это /var/spool/a2a/<агент>
1103
+ // (режим 1730: опустить можно, прочесть нельзя) — путь приведён как образец
1104
+ // раскладки, кодом он не прибит и у поставившего пакет будет свой,
1009
1105
  // почтальон a2a-pochtalon раз в 5 с переносит его в собственность получателя,
1010
1106
  // а забрать из ящика и подать агенту должен ЭТОТ модуль.
1011
1107
  //
@@ -1282,7 +1378,22 @@ export function apply(ctx, config = {}) {
1282
1378
  const updates = await tg.poll(25);
1283
1379
  for (const u of updates) {
1284
1380
  try { await handleUpdate(u); }
1285
- catch (e) { log(`ошибка обработки обновления: ${e?.message ?? e}`); }
1381
+ catch (e) {
1382
+ // 🔴 ОТВЕЧАЕМ В ЧАТ, А НЕ ТОЛЬКО В ЛОГ. Куплено боем 02.09.2026: обе
1383
+ // команды владельца бросили ДО внутреннего try, исключение пришло сюда,
1384
+ // и он не получил НИЧЕГО — ни ответа, ни отказа. Молчание при ошибке
1385
+ // обработки снаружи неотличимо от «сообщение не дошло», и человек ищет
1386
+ // поломку в канале вместо кода. Журнал видим нам, чат — ему.
1387
+ log(`ошибка обработки обновления: ${e?.message ?? e}`);
1388
+ const komu = u?.message?.chat?.id ?? u?.edited_message?.chat?.id;
1389
+ if (komu !== undefined) {
1390
+ // Отправка сама может отказать (сеть, блокировка) — тогда молчим в чат,
1391
+ // но след в журнале уже есть. Второй отказ не должен рвать опрос.
1392
+ try {
1393
+ await tg.send(komu, `Не смогла обработать сообщение: ${String(e?.message ?? e).slice(0, 200)}`);
1394
+ } catch (e2) { log(`ответ об ошибке не ушёл: ${e2?.message ?? e2}`); }
1395
+ }
1396
+ }
1286
1397
  }
1287
1398
  } catch (e) {
1288
1399
  // Сеть моргнула или Telegram ответил ошибкой — ждём и продолжаем.
@@ -1327,6 +1438,169 @@ export function apply(ctx, config = {}) {
1327
1438
  .then((me) => log(`подключён как @${me.username} (${me.first_name})`))
1328
1439
  .catch((e) => log(`не удалось представиться Telegram: ${e?.message ?? e}`));
1329
1440
  log(`опрос запущен (монтажей: ${state.refs})`);
1441
+
1442
+ // 🔴 РЕГИСТРАЦИЯ /compact-status. Штатной команды статуса у платформы НЕТ
1443
+ // ВОВСЕ (в пресете только compact, feedback, goal — проверено), поэтому
1444
+ // заводим свою. Через реестр, а не обработкой мимо него: тогда она попадёт
1445
+ // и в список команд у любого клиента, и в журнал событием command/run —
1446
+ // тем самым, которым мы отличаем «команда не дошла» от «механизм молчит».
1447
+ // Ждём службу, а не объявляем в inject: inject встал бы в pending и обрубил
1448
+ // связь с владельцем целиком (поймано канарейкой 02.09 на compaction).
1449
+ void (async () => {
1450
+ const komandy = await waitService('commands', '[статус]',
1451
+ 'команда /compact-status и её алиас /cs работать НЕ БУДУТ');
1452
+ if (!komandy?.register) return;
1453
+ const schetchik = await waitService('tokenMeter', '[статус]',
1454
+ 'команда /compact-status ответит отказом');
1455
+ komandy.register({
1456
+ name: 'compact-status',
1457
+ description: 'Сводка о занятости контекста: сколько токенов и далеко ли до сжатия',
1458
+ handler: async (invocation) => {
1459
+ // 🔴 Числа берём У ПЛАТФОРМЫ. Соседняя ферма считала размер журнала
1460
+ // делением на четыре и завысила в 25 раз (16 млн против 676 тысяч).
1461
+ // И НЕ из usage последнего ответа: там inputTokens=2 при cacheRead
1462
+ // 632 тысячи — ошибка в триста тысяч раз. tokenMeter.measure — ровно
1463
+ // то число, которое платформа сравнивает со своим порогом
1464
+ // (dsh-compaction-basic:882: measurement.totalTokens < thresholdTokens).
1465
+ if (!schetchik?.measure) {
1466
+ return { kind: 'error', text: 'Измеритель контекста (tokenMeter) недоступен — сказать нечего.' };
1467
+ }
1468
+ const sess = invocation.agent?.session;
1469
+ if (!sess) return { kind: 'error', text: 'Сессия недоступна.' };
1470
+ let m;
1471
+ try { m = schetchik.measure(sess); }
1472
+ catch (e) { return { kind: 'error', text: `Измеритель отказал: ${String(e?.message ?? e).slice(0, 160)}` }; }
1473
+ // 🔴 СВЕЖЕСТЬ ОПОРЫ. baseline.kind различает ПРОИСХОЖДЕНИЕ опоры и НЕ различает
1474
+ // её СВЕЖЕСТЬ: при surface-replace (compaction/summary|prune) поверхность
1475
+ // пересчитывается, а якорь остаётся прежним и тащит свой kind='usage'
1476
+ // (token-meter :507 baseline = anchor.baseline; :588 якорь только на
1477
+ // assistant/message). Значит после компакта строка «числа провайдера» говорит
1478
+ // правду про источник и ложь про актуальность — ровно там, где на неё смотрят.
1479
+ // Флаг не отсутствует, он ПРИСУТСТВУЕТ И УСПОКАИВАЕТ; это хуже отсутствия.
1480
+ //
1481
+ // ⚠️ ИНТЕРФЕЙС ОПРЕДЕЛЯЕМ ПО ФАКТУ, НЕ ПО ВЕРСИИ: в 0.1.1-rc.2 у сессии массив
1482
+ // `events`, в 0.1.2-rc.1 и master — `eventAt()` + `seq`. Версия на диске может
1483
+ // отставать от кода; проверяем наличие, а не номер.
1484
+ //
1485
+ // 🔴 И ГЛАВНОЕ: не сумели снять — говорим «НЕ МОГУ», а НЕ «свежо». Молчаливое
1486
+ // «свежо» при пропавшем интерфейсе — зелёное, которое не может покраснеть.
1487
+ const svezhest = (() => {
1488
+ let poslednijKompakt = -1;
1489
+ let poslednijYakor = -1;
1490
+ const uchest = (ev, seq) => {
1491
+ const t = ev?.type;
1492
+ if (t === 'compaction/summary' || t === 'compaction/prune') {
1493
+ if (seq > poslednijKompakt) poslednijKompakt = seq;
1494
+ } else if (t === 'assistant/message' && ev?.data?.usage !== undefined) {
1495
+ if (seq > poslednijYakor) poslednijYakor = seq;
1496
+ }
1497
+ };
1498
+ try {
1499
+ if (Array.isArray(sess.events)) {
1500
+ for (const ev of sess.events) uchest(ev, Number(ev?.seq ?? -1));
1501
+ } else if (typeof sess.eventAt === 'function' && sess.seq !== undefined) {
1502
+ const granica = Number(sess.seq);
1503
+ if (!Number.isFinite(granica)) return { kind: 'ne-znayu', pochemu: 'session.seq не число' };
1504
+ for (let i = 0; i < granica; i++) {
1505
+ const ev = sess.eventAt(i);
1506
+ if (ev === undefined) return { kind: 'ne-znayu', pochemu: 'eventAt вернул undefined — иная сигнатура' };
1507
+ uchest(ev, i);
1508
+ }
1509
+ } else {
1510
+ return { kind: 'ne-znayu', pochemu: 'у сессии нет ни events, ни eventAt+seq' };
1511
+ }
1512
+ } catch (e) {
1513
+ return { kind: 'ne-znayu', pochemu: `обход событий отказал: ${String(e?.message ?? e).slice(0, 90)}` };
1514
+ }
1515
+ if (poslednijKompakt < 0) return { kind: 'svezhaya', kompaktov: 0 };
1516
+ if (poslednijYakor > poslednijKompakt) return { kind: 'svezhaya', kompaktov: 1 };
1517
+ return { kind: 'ustarela', kompakt: poslednijKompakt, yakor: poslednijYakor };
1518
+ })();
1519
+ const stroki = [
1520
+ `Контекст: занято ${m.totalTokens} токенов.`,
1521
+ `Источник — платформенный tokenMeter.measure: то же число, которым платформа решает, пора ли сжимать.`,
1522
+ `Опора замера: ${m.baseline?.kind ?? '?'}` +
1523
+ (m.baseline?.kind === 'usage'
1524
+ ? (svezhest.kind === 'ustarela'
1525
+ ? ' 🔴 ЧИСЛА ПРОВАЙДЕРА СНЯТЫ ДО КОМПАКТА И С ТЕХ ПОР НЕ ОБНОВЛЯЛИСЬ'
1526
+ : svezhest.kind === 'ne-znayu'
1527
+ ? ` ⚠️ свежесть опоры снять НЕ МОГУ (${svezhest.pochemu})`
1528
+ : ' (числа провайдера)')
1529
+ : ' (оценка по эвристике)') +
1530
+ `, прочитано событий журнала: ${m.logRevision}.`,
1531
+ ];
1532
+ // Порог платформы: contextWindow × thresholdRatio (умолчание 0.8).
1533
+ // 🔴 ОКНО БЕРЁМ ТЕМ ЖЕ ПУТЁМ, КАКИМ ЕГО БЕРЁТ САМА ПЛАТФОРМА, и не
1534
+ // константой: константа окна у нас однажды уже разошлась с настоящей
1535
+ // (долг 48). Путь списан с dsh-compaction-basic:856-881 —
1536
+ // цель = session.requestHeader().config {provider, model}
1537
+ // окно = llm.resolveModelInfo(цель).context.contextWindow
1538
+ // Первая редакция брала invocation.agent.model.contextWindow: такого
1539
+ // поля у Agent нет вовсе, и 03.09 владелец получил честное «сказать НЕ
1540
+ // МОГУ» там, где число было доступно. Проверять надо путь, а не
1541
+ // правдоподобность имени свойства.
1542
+ //
1543
+ // 🔴 СЛУЖБА ЧЕРЕЗ ctx.get, А НЕ ctx.llm — тот же запрет, что убил обе
1544
+ // команды владельца часом раньше: чтение ctx.<служба> без inject
1545
+ // бросает «cannot get property without inject».
1546
+ //
1547
+ // ГДЕ ЭТО ПЕРЕСТАНЕТ БЫТЬ ПРАВДОЙ: 0.8 — умолчание платформы
1548
+ // (DEFAULT_THRESHOLD_RATIO, dsh-compaction-basic:13), и оно верно
1549
+ // ровно потому, что запись compaction-basic в составе идёт БЕЗ config
1550
+ // (dsh-base/cordis.patch.yml:284-285, проверено 03.09). Появится там
1551
+ // thresholdRatio — это число станет ложью, а стенд не покраснеет:
1552
+ // он читает наш файл, а не чужой состав. Другого способа узнать порог
1553
+ // у платформы нет — служба compaction его не публикует.
1554
+ const cel = (() => {
1555
+ const c = sess.requestHeader?.()?.config;
1556
+ if (c?.provider && c?.model) return { provider: c.provider, model: c.model };
1557
+ const o = invocation.agent?.options;
1558
+ if (o?.provider && o?.model) return { provider: o.provider, model: o.model };
1559
+ return undefined;
1560
+ })();
1561
+ let okno;
1562
+ let pochemuNet = 'цель модели не определилась (ни в заголовке запроса, ни в настройках агента)';
1563
+ if (cel) {
1564
+ const llm = ctx.get('llm');
1565
+ if (!llm?.resolveModelInfo) pochemuNet = 'служба llm недоступна';
1566
+ else {
1567
+ try {
1568
+ okno = (await llm.resolveModelInfo(cel.provider, cel.model, invocation.signal)).context?.contextWindow;
1569
+ if (!(typeof okno === 'number' && okno > 0)) {
1570
+ pochemuNet = `адаптер ${cel.provider} не объявил ёмкость для ${cel.model}`;
1571
+ okno = undefined;
1572
+ }
1573
+ } catch (e) {
1574
+ pochemuNet = `llm.resolveModelInfo отказал: ${String(e?.message ?? e).slice(0, 120)}`;
1575
+ }
1576
+ }
1577
+ }
1578
+ if (okno !== undefined) {
1579
+ const porog = Math.floor(okno * 0.8);
1580
+ if (svezhest.kind === 'svezhaya') {
1581
+ stroki.push(`Порог сжатия ~${porog} (окно ${okno} × 0.8 — умолчание платформы), до него ${porog - m.totalTokens}.`);
1582
+ } else if (svezhest.kind === 'ustarela') {
1583
+ // Расстояние до порога от устаревшей опоры — не «примерно», а НЕИЗВЕСТНО:
1584
+ // вырезанное вычтено по эвристической цене (4 знака = 1 токен), а она на
1585
+ // не-латинице занижена в разы (замер 04.09: ×9 на живом компакте).
1586
+ stroki.push(`Порог сжатия ~${porog} (окно ${okno} × 0.8). 🔴 Расстояние до него сказать НЕ МОГУ:`);
1587
+ stroki.push(`опора снята ДО компакта, вырезанное вычтено по эвристике (4 знака = 1 токен), она занижает на кириллице.`);
1588
+ stroki.push(`Верное число появится после первого же ответа модели — тогда якорь переставится.`);
1589
+ } else {
1590
+ stroki.push(`Порог сжатия ~${porog} (окно ${okno} × 0.8). ⚠️ До него — не считаю: свежесть опоры не снята.`);
1591
+ }
1592
+ } else {
1593
+ // Умолчание сюда не подставляется намеренно: выдуманное окно выглядит
1594
+ // как замер и врёт ровно там, где на него посмотрят. Отказ называет
1595
+ // ПРИЧИНУ — иначе «не могу» неотличимо от «не пробовала».
1596
+ stroki.push(`🔴 Окно контекста недоступно (${pochemuNet}) — расстояние до порога сказать НЕ МОГУ.`);
1597
+ }
1598
+ log(`[статус] ${m.totalTokens} токенов, опора ${m.baseline?.kind ?? '?'}/${svezhest.kind}`);
1599
+ return { kind: 'success', text: stroki.join('\n') };
1600
+ },
1601
+ });
1602
+ log('[статус] команда /compact-status зарегистрирована (алиас /cs)');
1603
+ })();
1330
1604
  await loop(run);
1331
1605
  log('опрос завершён');
1332
1606
  })();
@@ -0,0 +1,85 @@
1
+ // Стенд ДОСТУПА К СЛУЖБАМ: проверяется НАСТОЯЩИМ Context из cordis, а не словарём.
2
+ //
3
+ // Коды: 0 сошлось | 1 расхождение | 2 слепота (проверить не удалось).
4
+ //
5
+ // 🔴 ЗАЧЕМ ОТДЕЛЬНЫЙ СТЕНД. 02.09.2026 обе команды владельца упали в бою на
6
+ // [<агент>] ошибка обработки обновления: cannot get property "commands" without inject
7
+ // при том, что стенд разбора команд показывал 16 из 16. Он не мог это поймать ПО
8
+ // ПОСТРОЕНИЮ: он подменяет ctx словарём, а словарь НЕ БРОСАЕТ. Шов проверяется тем
9
+ // же швом — иначе проверка говорит о фикстуре, а не о предмете.
10
+ //
11
+ // ГДЕ НЕ ПРИМЕНЯЕТСЯ: не проверяет ни одну команду и ни одну службу по существу —
12
+ // только ПРАВИЛО ДОСТУПА платформы и то, что предмет ему следует.
13
+
14
+ import { readFileSync } from 'node:fs'
15
+
16
+ const SRC = process.env.SVYAZ_SRC || new URL('../src/index.js', import.meta.url).pathname
17
+ // 🔴 УМОЛЧАНИЕ БЕЗ ЧАСТНОГО ПУТИ: прибитый /opt/<агент>/app работает только у нас,
18
+ // а у поставившего пакет стенд молча ослеп бы на чужой раскладке. Ищем cordis
19
+ // разрешением имени — так же, как его найдёт сам модуль. Не нашлось: KORDIS=<путь>.
20
+ const KORDIS = process.env.KORDIS || (() => {
21
+ try { return import.meta.resolve('@deepseek-ai/cordis') } catch { return '@deepseek-ai/cordis' }
22
+ })()
23
+
24
+ let text, Context
25
+ try { text = readFileSync(SRC, 'utf8') } catch (e) {
26
+ console.log(`СЛЕПОТА: предмет ${SRC} не читается: ${e?.message ?? e}`); process.exit(2)
27
+ }
28
+ try { ({ Context } = await import(KORDIS)) } catch (e) {
29
+ console.log(`СЛЕПОТА: cordis не загружается (${KORDIS}): ${e?.message ?? e}`)
30
+ console.log(' Пакет, лежащий ВНЕ дерева зависимостей платформы, разрешить имя не может.')
31
+ console.log(' Задайте путь явно:')
32
+ console.log(' KORDIS=<путь к платформе>/node_modules/@deepseek-ai/cordis/lib/index.js node test/stend-dostupa-sluzhb.mjs')
33
+ process.exit(2)
34
+ }
35
+ if (typeof Context !== 'function') { console.log('СЛЕПОТА: в cordis нет Context'); process.exit(2) }
36
+
37
+ let ok = 0, bad = 0
38
+ const t = (n, c, s) => { if (c) { ok++; console.log(` ok ${n}`) } else { bad++; console.log(` FAIL ${n}\n ${s ?? ''}`) } }
39
+
40
+ console.log('=== 1. Правило платформы, проверенное НАСТОЯЩИМ Context ===')
41
+ const root = new Context()
42
+ let brosok = null, cherezGet, brosokGet = null
43
+ await root.plugin({
44
+ name: 'proba-dostupa',
45
+ apply(ctx) {
46
+ try { void ctx.commands } catch (e) { brosok = e?.message ?? String(e) }
47
+ try { cherezGet = ctx.get('commands') } catch (e) { brosokGet = e?.message ?? String(e) }
48
+ },
49
+ })
50
+ t('чтение ctx.<служба> свойством БРОСАЕТ без inject', brosok !== null && /without inject/.test(brosok),
51
+ `бросок: ${brosok ?? 'не было'}`)
52
+
53
+ t('ctx.get не бросает', brosokGet === null, `бросок: ${brosokGet}`)
54
+ t('ctx.get отдаёт undefined на несмонтированной службе', cherezGet === undefined, String(cherezGet))
55
+
56
+ console.log('\n=== 2. Предмет следует правилу ===')
57
+ // Комментарии не в счёт: имя службы в пояснении никого не ломает.
58
+ const kod = text.split('\n').filter((l) => !/^\s*(\/\/|\*|\/\*)/.test(l)).join('\n')
59
+ const pryamye = [...kod.matchAll(/ctx\.(commands|compaction|tokenMeter|sessions|llm)\b/g)].map((m) => m[1])
60
+ t('в коде нет чтения служб свойством', pryamye.length === 0, `найдено: ${pryamye.join(', ') || '—'}`)
61
+ t('реестр команд берётся через ctx.get', /ctx\.get\('commands'\)/.test(kod), 'нет ctx.get(\'commands\')')
62
+
63
+ console.log('\n=== 3. Бросок НЕ уходит мимо ответа в чат ===')
64
+ // Дефект 02.09: получение службы стояло ДО try, исключение улетало в общий catch
65
+ // обновления и оставалось только в логе — владелец не получал НИЧЕГО.
66
+ const blok = kod.match(/if \(text\.startsWith\('\/'\)\) \{[\s\S]*?\n \}/)
67
+ t('блок команд найден в предмете', blok !== null, 'разбор не удался')
68
+ if (blok) {
69
+ const b = blok[0]
70
+ const iTry = b.indexOf('try {'), iGet = b.indexOf("ctx.get('commands')")
71
+ t('получение службы ВНУТРИ try', iTry !== -1 && iGet !== -1 && iTry < iGet,
72
+ `try на ${iTry}, ctx.get на ${iGet}`)
73
+ t('у блока есть catch с ответом в чат', /catch \(e\) \{[\s\S]*?tg\.send\(chatId/.test(b), 'catch не отвечает в чат')
74
+ }
75
+ t('общий catch обновления тоже отвечает в чат',
76
+ /ошибка обработки обновления[\s\S]{0,600}?tg\.send\(komu/.test(kod), 'общий catch молчит в чат')
77
+
78
+ console.log(`\nИТОГО: сошлось ${ok}, расхождений ${bad}`)
79
+ // 🔴 Канарейка точного числа: вырезанный раздел иначе проходит кодом 0.
80
+ const ZHDYOM = 9
81
+ if (ok + bad !== ZHDYOM) {
82
+ console.log(`\nСЛЕПОТА: проверок ${ok + bad}, а стенд состоит из ${ZHDYOM} — часть не состоялась.`)
83
+ process.exit(2)
84
+ }
85
+ process.exit(bad ? 1 : 0)
@@ -141,6 +141,15 @@ t('нет утверждения «модуль общий для машины»
141
141
  !/модуль общий для машины/.test(s),
142
142
  `вхождений: ${(s.match(/модуль общий для машины/g) ?? []).length}`);
143
143
 
144
+ // 🔴 ОБЛАСТЬ ПРОВЕРКИ — СТРОКОЙ В ВЫВОДЕ, А НЕ В ЧЬЕЙ-ТО ПАМЯТИ.
145
+ // Аудитор испортил комментарий в src/index.js — стенд промолчал, и это ГРАНИЦА,
146
+ // а не слепота: он никогда и не обходил весь предмет на имена. Но зелёный без
147
+ // названной области через полгода прочтётся как «пакет чист целиком».
148
+ console.log(`\nОБЛАСТЬ: имена как ДАННЫЕ ищутся только в самом стенде `
149
+ + `(${Array.isArray(SPISOK_IMEN) ? SPISOK_IMEN.length : 0} имён из списка). `
150
+ + `В предмете ${path.basename(SRC)} кличка проверяется ТОЛЬКО в двух строках — `
151
+ + `тревога о чужом и приветствие. Остальной файл и остальные файлы пакета `
152
+ + `на имена НЕ проверяются НИКЕМ — стенда по всему составу у этого пакета нет.`);
144
153
  console.log(`\nИТОГ: ok=${ok} fail=${fail} слепота=${slep}`);
145
154
  // 🔴 Код 0 ТОЛЬКО когда всё состоялось и сошлось. Слепота — код 2: «проверить
146
155
  // нечем» и «проверил, всё хорошо» это разные новости, и машина, читающая код,
@@ -0,0 +1,120 @@
1
+ // Стенд доставки команд платформы из телеграма: /compact, /ccc, /compact-status, /cs.
2
+ //
3
+ // Коды: 0 сошлось | 1 расхождение | 2 слепота (проверить не удалось).
4
+ //
5
+ // 🔴 ВСЁ ПРОВЕРЯЕМОЕ ВЫРЕЗАЕТСЯ ИЗ ПРЕДМЕТА, а не переписано здесь. Копия стареет
6
+ // МОЛЧА: перепишут предмет — стенд продолжит проверять прежнее и останется зелёным.
7
+ // Проверено на себе 02.09: при переводе с прямого вызова движка на реестр команд
8
+ // этот стенд ЧЕСТНО ОСЛЕП («вырезать нечего»), а копия сказала бы «сошлось».
9
+ //
10
+ // ГДЕ НЕ ПРИМЕНЯЕТСЯ: не проверяет ни сам реестр команд, ни сжатие, ни измеритель —
11
+ // только НАШУ часть: разворот алиасов, якоря и то, что обычный текст не съеден.
12
+ // Остальное — на живом, порчами.
13
+
14
+ import { readFileSync } from 'node:fs'
15
+
16
+ const SRC = process.env.SVYAZ_SRC || new URL('../src/index.js', import.meta.url).pathname
17
+ let text
18
+ try { text = readFileSync(SRC, 'utf8') } catch (e) {
19
+ console.log(`СЛЕПОТА: предмет ${SRC} не читается: ${e?.message ?? e}`); process.exit(2)
20
+ }
21
+
22
+ // Вырезаем ЦЕПОЧКУ разворота алиасов ровно так, как она стоит в предмете.
23
+ const blok = text.match(/const stroka = text\s*((?:\s*\.replace\([^\n]+\n)+)/)
24
+ if (!blok) { console.log('СЛЕПОТА: в предмете не найден разворот алиасов — вырезать нечего'); process.exit(2) }
25
+ const zameny = [...blok[1].matchAll(/\.replace\((\/.+?\/[a-z]*),\s*'([^']*)'\)/g)]
26
+ if (zameny.length === 0) { console.log('СЛЕПОТА: замены не разобрались'); process.exit(2) }
27
+
28
+ const razvernut = (t) => {
29
+ let s = t
30
+ for (const [, re, na] of zameny) {
31
+ const m = re.match(/^\/(.*)\/([a-z]*)$/)
32
+ s = s.replace(new RegExp(m[1], m[2]), na)
33
+ }
34
+ return s
35
+ }
36
+
37
+ let ok = 0, bad = 0
38
+ const t = (n, c, s) => { if (c) { ok++; console.log(` ok ${n}`) } else { bad++; console.log(` FAIL ${n}\n ${s ?? ''}`) } }
39
+
40
+ console.log(`=== 0. Разворот вырезан из предмета: замен ${zameny.length} ===`)
41
+ t('замен ровно две (ccc и cs)', zameny.length === 2, `нашлось ${zameny.length}`)
42
+
43
+ console.log('\n=== 1. Алиасы разворачиваются в ИМЯ ШТАТНОЙ команды ===')
44
+ t('/ccc -> /compact', razvernut('/ccc') === '/compact', razvernut('/ccc'))
45
+ t('/cs -> /compact-status', razvernut('/cs') === '/compact-status', razvernut('/cs'))
46
+ t('/ccc с доводом сохраняет пробел', razvernut('/ccc сейчас') === '/compact сейчас', razvernut('/ccc сейчас'))
47
+ t('/compact не трогается', razvernut('/compact') === '/compact', razvernut('/compact'))
48
+
49
+ console.log('\n=== 2. Концевой якорь: опечатка НЕ превращается в команду ===')
50
+ t('/cccc не разворачивается', razvernut('/cccc') === '/cccc', razvernut('/cccc'))
51
+ t('/csv не разворачивается', razvernut('/csv') === '/csv', razvernut('/csv'))
52
+ t('/cstatus не разворачивается', razvernut('/cstatus') === '/cstatus', razvernut('/cstatus'))
53
+
54
+ console.log('\n=== 3. Только начало строки ===')
55
+ t('«скажи /ccc потом» не трогается', razvernut('скажи /ccc потом') === 'скажи /ccc потом', razvernut('скажи /ccc потом'))
56
+
57
+ console.log('\n=== 4. Устройство: штатный путь, а не своя реализация ===')
58
+ t('строка отдаётся в ctx.commands.execute', /commands\.execute\(/.test(text) || /reestrKomand\.execute\(/.test(text), 'нет вызова execute')
59
+ t('compactNow сам НЕ зовётся (иначе command/run останется 0)', !/compaction\.compactNow\(/.test(text), 'найден прямой вызов движка')
60
+ t('undefined НЕ глотается: текст идёт в модель', /vypolneno !== undefined/.test(text), 'нет проверки на undefined')
61
+ t('ответ в чат на ОБЕИХ ветках result', /r\?\.text \|\|/.test(text), 'нет дословной подачи result.text')
62
+ t('commands НЕ в inject (иначе pending и связь не поднимется)', /export const inject = \['agents'\];/.test(text), 'commands объявлен в inject')
63
+ t('громкий отказ при недоступном реестре', /реестр команд недоступен/.test(text), 'нет громкого отказа')
64
+ t('/compact-status регистрируется в реестре', /name: 'compact-status'/.test(text), 'команда не регистрируется')
65
+
66
+ console.log('\n=== 4б. Ответ называет ПРЕДМЕТ своего действия ===')
67
+ // 🔴 03.09.2026: команда подана из личного чата, сжалась сессия a2a — и это
68
+ // была наша же настройка mergeChatIntoA2A, а выглядело как дефект. Полчаса
69
+ // расследования стоила ОДНА ненаписанная строчка.
70
+ t('в ответ команды приписан id сессии', text.includes('${otvet}\\n\\n(сессия ${sid})'),
71
+ 'ответ на УСПЕШНОЙ ветке не называет сессию (вхождение в catch не в счёт)')
72
+ t('id берётся из agentFor, а не собирается заново', /const \{ handle: ch, sessionId \} = await agentFor\(key\)/.test(text),
73
+ 'sessionId не взят из agentFor')
74
+ t('на ветке отказа хвост только при известной сессии', /sid \? ` \(сессия \$\{sid\}\)`/.test(text),
75
+ 'в catch id подставляется безусловно — соврёт, если agentFor бросил')
76
+
77
+ console.log('\n=== 4в. Окно контекста берётся у платформы, а не выдумывается ===')
78
+ t('окно спрашивается у службы llm', /resolveModelInfo\(cel\.provider, cel\.model/.test(text), 'нет вызова resolveModelInfo')
79
+ t('цель модели — из заголовка запроса, как у платформы', /sess\.requestHeader\?\.\(\)\?\.config/.test(text),
80
+ 'цель не берётся из requestHeader')
81
+ t('есть запасной путь через agent.options', /invocation\.agent\?\.options/.test(text), 'нет запасного пути')
82
+ {
83
+ // Блок вырезается из предмета: искать по всему файлу нельзя — там есть и slice(0, 120),
84
+ // и законные числа. Порча `okno = okno ?? 1000000` прошла мимо прежнего шаблона.
85
+ const blokOkna = text.match(/let okno;[\s\S]*?Окно контекста недоступно/)
86
+ if (!blokOkna) { console.log(' СЛЕПОТА: блок окна не вырезался'); process.exit(2) }
87
+ const chisla = [...blokOkna[0].matchAll(/\b\d[\d_]{3,}\b/g)].map((m) => m[0])
88
+ t('умолчание окна НЕ подставляется', chisla.length === 0,
89
+ `в блоке окна найдены числовые литералы: ${chisla.join(', ')}`)
90
+ }
91
+ t('при недоступном окне остаётся честное НЕ МОГУ', /расстояние до порога сказать НЕ МОГУ/.test(text),
92
+ 'честный отказ потерян')
93
+ t('отказ называет ПРИЧИНУ', /\$\{pochemuNet\}[^\n]*НЕ МОГУ/.test(text),
94
+ 'в строке отказа нет причины — «не могу» неотличимо от «не пробовала»')
95
+
96
+ console.log('\n=== 5. Доступ к службам ТОЛЬКО законным путём ===')
97
+ // 🔴 Куплено боем 02.09.2026: обе команды владельца упали с
98
+ // «cannot get property "commands" without inject». Платформа запрещает ЧИТАТЬ
99
+ // ctx.<служба> без inject — проверка идёт в момент обращения, а не только при
100
+ // монтаже. Все 16 прежних проверок были зелёными на коде, который падал на
101
+ // первой же команде: они проверяли РАЗБОР, а не ДОСТУП.
102
+ // Строки-комментарии не в счёт: имя службы в пояснении никого не ломает.
103
+ {
104
+ const kod = text.split('\n').filter((l) => !/^\s*(\/\/|\*|\/\*)/.test(l)).join('\n')
105
+ const pryamye = [...kod.matchAll(/ctx\.(commands|compaction|tokenMeter|llm)\b/g)].map((m) => m[1])
106
+ t('в КОДЕ нет прямого ctx.<служба> (только ctx.get)', pryamye.length === 0,
107
+ `найдено: ${pryamye.join(', ') || '—'}`)
108
+ t('реестр команд берётся через ctx.get', /ctx\.get\('commands'\)/.test(kod), 'нет ctx.get(\'commands\')')
109
+ t('служба для регистрации берётся ожиданием', /waitService\('commands'/.test(kod), 'нет waitService для регистрации')
110
+ t('служба llm берётся через ctx.get', /ctx\.get\('llm'\)/.test(kod), 'нет ctx.get(\'llm\')')
111
+ }
112
+
113
+ console.log(`\nИТОГО: сошлось ${ok}, расхождений ${bad}`)
114
+ // 🔴 Канарейка точного числа: вырезанный раздел иначе проходит кодом 0.
115
+ const ZHDYOM = 29
116
+ if (ok + bad !== ZHDYOM) {
117
+ console.log(`\nСЛЕПОТА: проверок ${ok + bad}, а стенд состоит из ${ZHDYOM} — часть не состоялась.`)
118
+ process.exit(2)
119
+ }
120
+ process.exit(bad ? 1 : 0)
@@ -0,0 +1,135 @@
1
+ // Стенд признака СВЕЖЕСТИ опоры замера в /compact-status.
2
+ //
3
+ // Коды: 0 сошлось | 1 расхождение | 2 слепота (проверить не удалось).
4
+ //
5
+ // ЧТО ЛЕЧИТ ПРЕДМЕТ. baseline.kind различает ПРОИСХОЖДЕНИЕ опоры («числа провайдера»
6
+ // против «оценка по эвристике») и НЕ различает её СВЕЖЕСТЬ. При surface-replace
7
+ // (compaction/summary|prune) поверхность пересчитывается, а якорь остаётся прежним и
8
+ // тащит свой kind='usage'. То есть после компакта строка правдива про источник и
9
+ // обманчива про актуальность — ровно там, где на неё смотрят. Флаг не отсутствует,
10
+ // он ПРИСУТСТВУЕТ И УСПОКАИВАЕТ, и это хуже отсутствия.
11
+ //
12
+ // 🔴 ВЫРЕЗАЕМ ИЗ ПРЕДМЕТА, А НЕ ПЕРЕПИСЫВАЕМ. Копия стареет молча: перепишут предмет —
13
+ // стенд продолжит проверять прежнее и останется зелёным. То же правило, что в
14
+ // stend-komand-konteksta.mjs, и оно там уже окупилось.
15
+ //
16
+ // ГДЕ НЕ ПРИМЕНЯЕТСЯ. Стенд проверяет ТОЛЬКО вычисление свежести на подставных
17
+ // сессиях. Он НЕ проверяет:
18
+ // — что признак верен на ЖИВОМ компакте (это П1/П2 ворот, только в бою);
19
+ // — что расстояние до порога печатается/не печатается (это текст ответа, не эта функция);
20
+ // — цену обхода на большом журнале.
21
+ // «Стенд зелёный» здесь означает «логика различает три состояния», а не «команда не врёт».
22
+
23
+ import { readFileSync } from 'node:fs'
24
+
25
+ const SRC = process.env.SVYAZ_SRC || new URL('../src/index.js', import.meta.url).pathname
26
+ let text
27
+ try { text = readFileSync(SRC, 'utf8') } catch (e) {
28
+ console.log(`СЛЕПОТА: предмет ${SRC} не читается: ${e?.message ?? e}`); process.exit(2)
29
+ }
30
+
31
+ // Вырезаем тело признака ровно так, как оно стоит в предмете.
32
+ const m = text.match(/const svezhest = \(\(\) => \{[\s\S]*?\n {14}\}\)\(\);/)
33
+ if (!m) {
34
+ console.log('СЛЕПОТА: в предмете не найден блок `const svezhest = (() => {...})();` — вырезать нечего.')
35
+ console.log(' Это НЕ «признака нет»: он мог быть переписан в другой форме. Проверить руками.')
36
+ process.exit(2)
37
+ }
38
+ // Оборачиваем вырезанное в функцию от sess: внутри блок обращается только к `sess`.
39
+ let svezhestOt
40
+ try {
41
+ svezhestOt = new Function('sess', m[0].replace('const svezhest = ', 'return ') .replace(/;$/, '') + ';')
42
+ } catch (e) {
43
+ console.log(`СЛЕПОТА: вырезанный блок не собирается: ${e?.message ?? e}`); process.exit(2)
44
+ }
45
+
46
+ let ok = 0, bed = 0
47
+ const t = (imya, delo) => {
48
+ try { delo(); ok++; console.log(` ok ${imya}`) }
49
+ catch (e) { bed++; console.log(` FAIL ${imya} — ${e?.message ?? e}`) }
50
+ }
51
+ const ravno = (bylo, zhdali, chto) => {
52
+ if (bylo !== zhdali) throw new Error(`${chto}: «${bylo}», а ждали «${zhdali}»`)
53
+ }
54
+
55
+ // ── подставные сессии ────────────────────────────────────────────────────────
56
+ const soobshchenie = (seq) => ({ seq, type: 'assistant/message', data: { usage: { inputTokens: 1 } } })
57
+ const kompakt = (seq, tip = 'compaction/summary') => ({ seq, type: tip })
58
+ const massiv = (evs) => ({ events: evs })
59
+ const cherezEventAt = (evs) => ({
60
+ seq: evs.length,
61
+ eventAt: (i) => evs[i],
62
+ })
63
+
64
+ // ── СТАРЫЙ ИНТЕРФЕЙС: массив events ─────────────────────────────────────────
65
+ t('компакта не было → свежая', () => {
66
+ ravno(svezhestOt(massiv([soobshchenie(0), soobshchenie(1)])).kind, 'svezhaya', 'исход')
67
+ })
68
+
69
+ t('компакт был, ответ ПОСЛЕ него → свежая (якорь переставился)', () => {
70
+ ravno(svezhestOt(massiv([soobshchenie(0), kompakt(1), soobshchenie(2)])).kind, 'svezhaya', 'исход')
71
+ })
72
+
73
+ // 🔴 ГЛАВНАЯ. Ради неё вся заплатка: это состояние сегодня врало «числа провайдера».
74
+ t('компакт был, ответа ПОСЛЕ нет → устарела', () => {
75
+ const r = svezhestOt(massiv([soobshchenie(0), soobshchenie(1), kompakt(2)]))
76
+ ravno(r.kind, 'ustarela', 'исход')
77
+ if (r.kompakt !== 2 || r.yakor !== 1) throw new Error(`числа: компакт ${r.kompakt}, якорь ${r.yakor}, ждали 2 и 1`)
78
+ })
79
+
80
+ t('prune двигает поверхность так же, как summary', () => {
81
+ ravno(svezhestOt(massiv([soobshchenie(0), kompakt(1, 'compaction/prune')])).kind, 'ustarela', 'исход')
82
+ })
83
+
84
+ // Ответ БЕЗ usage якорем не является: измеритель ставит якорь по числам провайдера.
85
+ t('ответ без usage после компакта якорем НЕ считается', () => {
86
+ const evs = [soobshchenie(0), kompakt(1), { seq: 2, type: 'assistant/message', data: {} }]
87
+ ravno(svezhestOt(massiv(evs)).kind, 'ustarela', 'исход')
88
+ })
89
+
90
+ // ── НОВЫЙ ИНТЕРФЕЙС: eventAt + seq ──────────────────────────────────────────
91
+ t('новый интерфейс: компакт последним → устарела', () => {
92
+ ravno(svezhestOt(cherezEventAt([soobshchenie(0), soobshchenie(1), kompakt(2)])).kind, 'ustarela', 'исход')
93
+ })
94
+
95
+ t('новый интерфейс: ответ после компакта → свежая', () => {
96
+ ravno(svezhestOt(cherezEventAt([soobshchenie(0), kompakt(1), soobshchenie(2)])).kind, 'svezhaya', 'исход')
97
+ })
98
+
99
+ // ── ОТКАЗЫ. Каждый обязан дать «не знаю», а НЕ «свежо» ──────────────────────
100
+ // 🔴 Это и есть ветка, которая сработает при обновлении платформы. Без этих проб
101
+ // она не проверена ничем, а молчаливое «свежо» при пропавшем интерфейсе — зелёное,
102
+ // которое не может покраснеть.
103
+ t('нет ни events, ни eventAt → не знаю, а не «свежо»', () => {
104
+ const r = svezhestOt({})
105
+ ravno(r.kind, 'ne-znayu', 'исход')
106
+ if (!/events/.test(r.pochemu)) throw new Error(`причина не названа: «${r.pochemu}»`)
107
+ })
108
+
109
+ t('eventAt есть, seq не число → не знаю', () => {
110
+ ravno(svezhestOt({ eventAt: () => undefined, seq: 'много' }).kind, 'ne-znayu', 'исход')
111
+ })
112
+
113
+ t('eventAt вернул undefined (иная сигнатура) → не знаю', () => {
114
+ const r = svezhestOt({ eventAt: () => undefined, seq: 3 })
115
+ ravno(r.kind, 'ne-znayu', 'исход')
116
+ if (!/сигнатура/.test(r.pochemu)) throw new Error(`причина не про сигнатуру: «${r.pochemu}»`)
117
+ })
118
+
119
+ t('eventAt бросил → не знаю, причина названа', () => {
120
+ const r = svezhestOt({ eventAt: () => { throw new Error('SessionSeq требуется') }, seq: 2 })
121
+ ravno(r.kind, 'ne-znayu', 'исход')
122
+ if (!/отказал/.test(r.pochemu)) throw new Error(`причина не про отказ: «${r.pochemu}»`)
123
+ })
124
+
125
+ // ── ПАРА К ГЛАВНОЙ: три исхода обязаны РАЗЛИЧАТЬСЯ ──────────────────────────
126
+ // Иначе признак есть, а различать нечем — та же вывеска, только внутри механизма.
127
+ t('три исхода дают три РАЗНЫХ ответа', () => {
128
+ const a = svezhestOt(massiv([soobshchenie(0)])).kind
129
+ const b = svezhestOt(massiv([soobshchenie(0), kompakt(1)])).kind
130
+ const c = svezhestOt({}).kind
131
+ if (new Set([a, b, c]).size !== 3) throw new Error(`исходы слиплись: ${a} / ${b} / ${c}`)
132
+ })
133
+
134
+ console.log(`итог: ${ok} из ${ok + bed}`)
135
+ process.exit(bed === 0 ? 0 : 1)
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Прогон ВСЕХ стендов пакета одной командой.
3
+ *
4
+ * 🔴 ЗАВЕДЁН 04.09.2026 ПО ЗАМЕРУ ЭТОГО ПАКЕТА: `scripts` в манифесте НЕ БЫЛО ВОВСЕ.
5
+ * То есть `npm test` у получателя не гонял НИ ОДНОГО из шести стендов — не «часть»,
6
+ * а ноль. Снаружи это неотличимо от пакета без проверок.
7
+ *
8
+ * Устройство перенесено из dsh-pamyat-core, где оно родилось из своего замера: там
9
+ * перечень вёлся рукой и называл шесть стендов из девяти, а `&&` обрывал цепь на
10
+ * первой законной слепоте. Здесь перечень СНИМАЕТСЯ С ДИСКА, поэтому «забыли вписать
11
+ * новый стенд» невозможно по устройству, а не по памяти.
12
+ *
13
+ * ⚠️ ГДЕ НЕ ПРИМЕНЯЕТСЯ: обход берёт только `stend-*.mjs`. Файл `test-goal-control.mjs`
14
+ * назван иначе и НЕ ЗОВЁТСЯ — это не недосмотр обхода, а несовпадение имени с шаблоном.
15
+ * Переименовать его — отдельная правка с отдельной приёмкой, здесь не делается.
16
+ *
17
+ * ИСХОД: расхождение (1) важнее слепоты (2) — так же, как во всех наших стендах.
18
+ * Слепота отдельного стенда НЕ гасится: она названа в сводке и уходит в код возврата,
19
+ * иначе «проверить нечем» стало бы неотличимо от «проверено».
20
+ */
21
+ import { readdirSync } from 'node:fs';
22
+ import { spawnSync } from 'node:child_process';
23
+ import { fileURLToPath } from 'node:url';
24
+ import { join, dirname } from 'node:path';
25
+
26
+ const katalog = dirname(fileURLToPath(import.meta.url));
27
+ const svoyo = 'vse-stendy.mjs';
28
+ const stendy = readdirSync(katalog)
29
+ .filter((f) => f.startsWith('stend-') && f.endsWith('.mjs') && f !== svoyo)
30
+ .sort();
31
+
32
+ if (!stendy.length) {
33
+ console.log('СЛЕПОТА: стендов рядом не найдено вовсе — прогонять нечего');
34
+ console.log(` область обхода: ${katalog}, шаблон stend-*.mjs`);
35
+ process.exit(2);
36
+ }
37
+
38
+ const ishody = [];
39
+ for (const s of stendy) {
40
+ const r = spawnSync(process.execPath, [join(katalog, s)], { stdio: 'inherit' });
41
+ ishody.push({ stend: s, kod: r.status ?? 2 });
42
+ }
43
+
44
+ const razoshlis = ishody.filter((i) => i.kod === 1);
45
+ const slepye = ishody.filter((i) => i.kod !== 0 && i.kod !== 1);
46
+ console.log(`\n═══ стендов ${stendy.length} · зелёных ${ishody.length - razoshlis.length - slepye.length}`
47
+ + ` · расхождений ${razoshlis.length} · слепота ${slepye.length}`);
48
+ console.log(` область обхода: ${katalog}, шаблон stend-*.mjs — перечень СНЯТ С ДИСКА, не записан`);
49
+ for (const i of [...razoshlis, ...slepye]) console.log(` ${i.kod === 1 ? '🔴 разошёлся' : '⚠️ слепота'}: ${i.stend}`);
50
+
51
+ // Расхождение важнее слепоты: «нашли беду» сильнее, чем «проверить нечем».
52
+ process.exit(razoshlis.length ? 1 : (slepye.length ? 2 : 0));