dsh-telegram-multiagent 1.4.3 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,65 @@
1
+ ## 1.5.1
2
+
3
+ 🔴 **`errDetail` печатал пустоту после слова «причина».** Собирал причину через `??`, а нулевое
4
+ слияние реагирует только на `null` и `undefined` — пустая строка для него «найденное значение»,
5
+ и до `code` дело не доходило. У `AggregateError`, которую кладёт `fetch`, перебрав адреса,
6
+ собственный `message` как раз пустой.
7
+
8
+ В живом журнале это дало **125 строк за три часа** вида `причина: ` с пустотой после двоеточия.
9
+ Хуже отсутствия поля: «причина:» с пустотой читается как «причину узнали, она пустая», а её
10
+ просто не достали из объекта.
11
+
12
+ Теперь причина собирается перечнем: класс ошибки (AggregateError и Error лечатся по-разному, а
13
+ по тексту неотличимы), `code`, `message`, и внутренние ошибки `AggregateError.errors`, где у неё
14
+ и лежат настоящие причины. Пустой перечень даёт «объект есть, полей с описанием нет» — это НЕ
15
+ пустая причина, а другое состояние, и слова для него другие.
16
+
17
+ Строка отказа теперь называет, ЧТО опрашивалось: в цикле три опроса подряд (a2a, ящик, telegram),
18
+ и без имени отказ читается как «всё сломалось», хотя стоять может одно из трёх.
19
+
20
+ Стенд `stend-prichiny-otkaza.mjs`, 5 проб, вырезает функцию ИЗ ПРЕДМЕТА. Порча возвратом `??`
21
+ роняет три пробы из пяти.
22
+
23
+ ## 1.5.0
24
+
25
+ 🔴 **`/compact-status` больше не выдаёт устаревшую опору за свежую.** Строка «Опора замера:
26
+ usage (числа провайдера)» была правдива про ИСТОЧНИК и обманчива про АКТУАЛЬНОСТЬ: измеритель
27
+ копирует `baseline` из якоря вместе с его `kind`, а якорь ставится только на
28
+ `assistant/message`. После компакта поверхность пересчитывается, якорь остаётся прежним — и
29
+ команда сообщала «числа провайдера» рядом с числом, которого провайдер не называл. Флаг не
30
+ отсутствовал, он ПРИСУТСТВОВАЛ И УСПОКАИВАЛ, а это хуже отсутствия.
31
+
32
+ Три состояния вместо двух:
33
+
34
+ опора свежая «(числа провайдера)», расстояние до порога печатается
35
+ опора устарела «🔴 ЧИСЛА СНЯТЫ ДО КОМПАКТА», расстояние НЕ печатается вовсе
36
+ снять не удалось «⚠️ свежесть снять НЕ МОГУ (причина)», расстояние не печатается
37
+
38
+ Расстояние до порога от устаревшей опоры не «примерно», а НЕИЗВЕСТНО: вырезанное вычтено по
39
+ эвристической цене (4 знака = 1 токен), а она на кириллице занижает в разы.
40
+
41
+ Интерфейс сессии определяется ПО ФАКТУ, не по номеру версии: в одних редакциях платформы у
42
+ сессии массив `events`, в других `eventAt()` + `seq`. Не сумели снять — говорим «не знаю», а
43
+ НЕ «свежо»: молчаливое «свежо» при пропавшем интерфейсе было бы зелёным, которое не может
44
+ покраснеть.
45
+
46
+ Цена обхода замерена: **0,46 мс** на журнале в 61 866 событий.
47
+
48
+ 🔴 **Заведён `scripts.test` — его не было ВОВСЕ.** То есть `npm test` у получателя не гонял ни
49
+ одного из шести стендов, и снаружи пакет был неотличим от пакета без проверок. Прогонщик
50
+ снимает перечень обходом каталога, а не ведёт его рукой; `files` перечисляет каталоги, а не
51
+ файлы поимённо, поэтому новый стенд едет к получателю сам.
52
+ Прогон сразу показал законную слепоту `stend-imena` (имена проверяются только в двух строках
53
+ предмета, стенда по всему составу нет) — она была всегда, её просто некому было увидеть.
54
+
55
+ Новый стенд `stend-svezhesti-opory.mjs`: 12 проб, вырезает признак ИЗ ПРЕДМЕТА, а не хранит
56
+ копию. Две порчи дают РАЗНЫЕ ответы — удаление признака слепоту (2), излом логики при
57
+ сохранной форме расхождение (1).
58
+
59
+ ⚠️ Порог по-прежнему считается как `окно × 0.8` — умолчание платформы. Появится
60
+ `thresholdRatio` в составе — число станет ложью, а стенд не покраснеет: он читает наш файл,
61
+ а не чужой состав. Эта граница не тронута и не починена.
62
+
1
63
  # Изменения
2
64
 
3
65
  Указатель по версиям. Подробности каждой — отдельным разделом README: здесь
package/package.json CHANGED
@@ -1,19 +1,15 @@
1
1
  {
2
2
  "name": "dsh-telegram-multiagent",
3
- "version": "1.4.3",
3
+ "version": "1.5.1",
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-komand-konteksta.mjs",
15
- "test/stend-dostupa-sluzhb.mjs",
16
- "test/stend-pobudki.mjs",
11
+ "src",
12
+ "test",
17
13
  "cordis.patch.yml",
18
14
  "README.md",
19
15
  "CHANGELOG.md"
package/src/index.js CHANGED
@@ -1364,10 +1364,33 @@ export function apply(ctx, config = {}) {
1364
1364
  // «причины нет», и это та же слепота, что и раньше).
1365
1365
  // Перенесено из второй живой копии модуля 01.09.2026 ДОСЛОВНО: своя редакция
1366
1366
  // того же лечения развела бы копии по логике при сверке.
1367
+ // 🔴 `??` ПРОПУСКАЕТ ПУСТУЮ СТРОКУ КАК ЗАДАННОЕ ЗНАЧЕНИЕ (замер 04.09.2026).
1368
+ // Было `c?.message ?? c?.code ?? String(c)`. Нулевое слияние реагирует только на
1369
+ // null и undefined, а `message` у AggregateError (её кладёт fetch, перебрав адреса)
1370
+ // бывает ПУСТОЙ СТРОКОЙ — и она «найдена», до `code` дело не доходит.
1371
+ // В журнале это дало 125 строк за три часа вида «причина: » с пустотой после
1372
+ // двоеточия. Хуже отсутствия поля: «причина:» с пустотой читается как «причину
1373
+ // узнали, она пустая», а её просто не достали.
1374
+ // Воспроизведено: errDetail(cause с message='') -> '', errDetail(AggregateError) -> ''.
1367
1375
  function errDetail(e) {
1368
1376
  const c = e?.cause;
1369
1377
  if (!c) return 'не указана';
1370
- return c?.message ?? c?.code ?? String(c);
1378
+ const chasti = [];
1379
+ // Класс ошибки называем всегда: AggregateError и Error лечатся по-разному, а по
1380
+ // тексту они неотличимы.
1381
+ if (c?.constructor?.name) chasti.push(c.constructor.name);
1382
+ if (c?.code) chasti.push(String(c.code));
1383
+ if (c?.message) chasti.push(String(c.message));
1384
+ // У AggregateError настоящие причины лежат в errors, а собственный message пуст.
1385
+ if (Array.isArray(c?.errors) && c.errors.length) {
1386
+ const vnutri = c.errors.slice(0, 3)
1387
+ .map((x) => [x?.code, x?.message].filter(Boolean).join(' ') || String(x))
1388
+ .filter(Boolean);
1389
+ if (vnutri.length) chasti.push('внутри: ' + vnutri.join('; '));
1390
+ }
1391
+ // Пустой перечень — это НЕ пустая причина, а «объект есть, а сказать про него
1392
+ // нечего». Разные состояния, разные слова.
1393
+ return chasti.length ? chasti.join(' ') : `объект ${typeof c}, полей с описанием нет`;
1371
1394
  }
1372
1395
 
1373
1396
  async function loop(run) {
@@ -1397,7 +1420,10 @@ export function apply(ctx, config = {}) {
1397
1420
  }
1398
1421
  } catch (e) {
1399
1422
  // Сеть моргнула или Telegram ответил ошибкой — ждём и продолжаем.
1400
- log(`опрос не удался: ${e?.message ?? e} | причина: ${errDetail(e)}`);
1423
+ // ЧТО именно опрашивалось называем: в цикле три опроса подряд (a2a, ящик,
1424
+ // телеграм), и без имени отказ читается как «всё сломалось», хотя стоять может
1425
+ // одно из трёх.
1426
+ log(`опрос не удался (a2a+ящик+telegram): ${e?.message ?? e} | причина: ${errDetail(e)}`);
1401
1427
  await new Promise((r) => setTimeout(r, 3000));
1402
1428
  }
1403
1429
  }
@@ -1470,11 +1496,63 @@ export function apply(ctx, config = {}) {
1470
1496
  let m;
1471
1497
  try { m = schetchik.measure(sess); }
1472
1498
  catch (e) { return { kind: 'error', text: `Измеритель отказал: ${String(e?.message ?? e).slice(0, 160)}` }; }
1499
+ // 🔴 СВЕЖЕСТЬ ОПОРЫ. baseline.kind различает ПРОИСХОЖДЕНИЕ опоры и НЕ различает
1500
+ // её СВЕЖЕСТЬ: при surface-replace (compaction/summary|prune) поверхность
1501
+ // пересчитывается, а якорь остаётся прежним и тащит свой kind='usage'
1502
+ // (token-meter :507 baseline = anchor.baseline; :588 якорь только на
1503
+ // assistant/message). Значит после компакта строка «числа провайдера» говорит
1504
+ // правду про источник и ложь про актуальность — ровно там, где на неё смотрят.
1505
+ // Флаг не отсутствует, он ПРИСУТСТВУЕТ И УСПОКАИВАЕТ; это хуже отсутствия.
1506
+ //
1507
+ // ⚠️ ИНТЕРФЕЙС ОПРЕДЕЛЯЕМ ПО ФАКТУ, НЕ ПО ВЕРСИИ: в 0.1.1-rc.2 у сессии массив
1508
+ // `events`, в 0.1.2-rc.1 и master — `eventAt()` + `seq`. Версия на диске может
1509
+ // отставать от кода; проверяем наличие, а не номер.
1510
+ //
1511
+ // 🔴 И ГЛАВНОЕ: не сумели снять — говорим «НЕ МОГУ», а НЕ «свежо». Молчаливое
1512
+ // «свежо» при пропавшем интерфейсе — зелёное, которое не может покраснеть.
1513
+ const svezhest = (() => {
1514
+ let poslednijKompakt = -1;
1515
+ let poslednijYakor = -1;
1516
+ const uchest = (ev, seq) => {
1517
+ const t = ev?.type;
1518
+ if (t === 'compaction/summary' || t === 'compaction/prune') {
1519
+ if (seq > poslednijKompakt) poslednijKompakt = seq;
1520
+ } else if (t === 'assistant/message' && ev?.data?.usage !== undefined) {
1521
+ if (seq > poslednijYakor) poslednijYakor = seq;
1522
+ }
1523
+ };
1524
+ try {
1525
+ if (Array.isArray(sess.events)) {
1526
+ for (const ev of sess.events) uchest(ev, Number(ev?.seq ?? -1));
1527
+ } else if (typeof sess.eventAt === 'function' && sess.seq !== undefined) {
1528
+ const granica = Number(sess.seq);
1529
+ if (!Number.isFinite(granica)) return { kind: 'ne-znayu', pochemu: 'session.seq не число' };
1530
+ for (let i = 0; i < granica; i++) {
1531
+ const ev = sess.eventAt(i);
1532
+ if (ev === undefined) return { kind: 'ne-znayu', pochemu: 'eventAt вернул undefined — иная сигнатура' };
1533
+ uchest(ev, i);
1534
+ }
1535
+ } else {
1536
+ return { kind: 'ne-znayu', pochemu: 'у сессии нет ни events, ни eventAt+seq' };
1537
+ }
1538
+ } catch (e) {
1539
+ return { kind: 'ne-znayu', pochemu: `обход событий отказал: ${String(e?.message ?? e).slice(0, 90)}` };
1540
+ }
1541
+ if (poslednijKompakt < 0) return { kind: 'svezhaya', kompaktov: 0 };
1542
+ if (poslednijYakor > poslednijKompakt) return { kind: 'svezhaya', kompaktov: 1 };
1543
+ return { kind: 'ustarela', kompakt: poslednijKompakt, yakor: poslednijYakor };
1544
+ })();
1473
1545
  const stroki = [
1474
1546
  `Контекст: занято ${m.totalTokens} токенов.`,
1475
1547
  `Источник — платформенный tokenMeter.measure: то же число, которым платформа решает, пора ли сжимать.`,
1476
1548
  `Опора замера: ${m.baseline?.kind ?? '?'}` +
1477
- (m.baseline?.kind === 'usage' ? ' (числа провайдера)' : ' (оценка по эвристике)') +
1549
+ (m.baseline?.kind === 'usage'
1550
+ ? (svezhest.kind === 'ustarela'
1551
+ ? ' 🔴 ЧИСЛА ПРОВАЙДЕРА СНЯТЫ ДО КОМПАКТА И С ТЕХ ПОР НЕ ОБНОВЛЯЛИСЬ'
1552
+ : svezhest.kind === 'ne-znayu'
1553
+ ? ` ⚠️ свежесть опоры снять НЕ МОГУ (${svezhest.pochemu})`
1554
+ : ' (числа провайдера)')
1555
+ : ' (оценка по эвристике)') +
1478
1556
  `, прочитано событий журнала: ${m.logRevision}.`,
1479
1557
  ];
1480
1558
  // Порог платформы: contextWindow × thresholdRatio (умолчание 0.8).
@@ -1525,14 +1603,25 @@ export function apply(ctx, config = {}) {
1525
1603
  }
1526
1604
  if (okno !== undefined) {
1527
1605
  const porog = Math.floor(okno * 0.8);
1528
- stroki.push(`Порог сжатия ~${porog} (окно ${okno} × 0.8 умолчание платформы), до него ${porog - m.totalTokens}.`);
1606
+ if (svezhest.kind === 'svezhaya') {
1607
+ stroki.push(`Порог сжатия ~${porog} (окно ${okno} × 0.8 — умолчание платформы), до него ${porog - m.totalTokens}.`);
1608
+ } else if (svezhest.kind === 'ustarela') {
1609
+ // Расстояние до порога от устаревшей опоры — не «примерно», а НЕИЗВЕСТНО:
1610
+ // вырезанное вычтено по эвристической цене (4 знака = 1 токен), а она на
1611
+ // не-латинице занижена в разы (замер 04.09: ×9 на живом компакте).
1612
+ stroki.push(`Порог сжатия ~${porog} (окно ${okno} × 0.8). 🔴 Расстояние до него сказать НЕ МОГУ:`);
1613
+ stroki.push(`опора снята ДО компакта, вырезанное вычтено по эвристике (4 знака = 1 токен), она занижает на кириллице.`);
1614
+ stroki.push(`Верное число появится после первого же ответа модели — тогда якорь переставится.`);
1615
+ } else {
1616
+ stroki.push(`Порог сжатия ~${porog} (окно ${okno} × 0.8). ⚠️ До него — не считаю: свежесть опоры не снята.`);
1617
+ }
1529
1618
  } else {
1530
1619
  // Умолчание сюда не подставляется намеренно: выдуманное окно выглядит
1531
1620
  // как замер и врёт ровно там, где на него посмотрят. Отказ называет
1532
1621
  // ПРИЧИНУ — иначе «не могу» неотличимо от «не пробовала».
1533
1622
  stroki.push(`🔴 Окно контекста недоступно (${pochemuNet}) — расстояние до порога сказать НЕ МОГУ.`);
1534
1623
  }
1535
- log(`[статус] ${m.totalTokens} токенов, опора ${m.baseline?.kind ?? '?'}`);
1624
+ log(`[статус] ${m.totalTokens} токенов, опора ${m.baseline?.kind ?? '?'}/${svezhest.kind}`);
1536
1625
  return { kind: 'success', text: stroki.join('\n') };
1537
1626
  },
1538
1627
  });
@@ -0,0 +1,73 @@
1
+ // Стенд разбора причины отказа опроса.
2
+ //
3
+ // Коды: 0 сошлось | 1 расхождение | 2 слепота.
4
+ //
5
+ // ЧТО ЛЕЧИТ ПРЕДМЕТ. `errDetail` собирал причину через `??`. Нулевое слияние реагирует
6
+ // только на null и undefined, а `message` у AggregateError (её кладёт fetch, перебрав
7
+ // адреса) бывает ПУСТОЙ СТРОКОЙ — она «найдена», и до `code` дело не доходит.
8
+ // В живом журнале это дало 125 строк за три часа вида «причина: » с пустотой.
9
+ // 🔴 Хуже отсутствия поля: «причина:» с пустотой читается как «причину узнали, она
10
+ // пустая», а её просто не достали из объекта.
11
+ //
12
+ // 🔴 ВЫРЕЗАЕМ ИЗ ПРЕДМЕТА, а не переписываем: копия стареет молча.
13
+ //
14
+ // ГДЕ НЕ ПРИМЕНЯЕТСЯ: стенд проверяет только РАЗБОР объекта ошибки. Он не проверяет,
15
+ // что отказ вообще случается, и не проверяет доставку строки в журнал.
16
+
17
+ import { readFileSync } from 'node:fs'
18
+
19
+ const SRC = process.env.SVYAZ_SRC || new URL('../src/index.js', import.meta.url).pathname
20
+ let text
21
+ try { text = readFileSync(SRC, 'utf8') } catch (e) {
22
+ console.log(`СЛЕПОТА: предмет ${SRC} не читается: ${e?.message ?? e}`); process.exit(2)
23
+ }
24
+ const m = text.match(/function errDetail\(e\) \{[\s\S]*?\n \}/)
25
+ if (!m) { console.log('СЛЕПОТА: функция errDetail в предмете не найдена — вырезать нечего'); process.exit(2) }
26
+ let errDetail
27
+ try { errDetail = new Function(m[0] + '; return errDetail;')() }
28
+ catch (e) { console.log(`СЛЕПОТА: вырезанное не собирается: ${e?.message ?? e}`); process.exit(2) }
29
+
30
+ let ok = 0, bed = 0
31
+ const t = (imya, delo) => { try { delo(); ok++; console.log(` ok ${imya}`) }
32
+ catch (e) { bed++; console.log(` FAIL ${imya} — ${e?.message ?? e}`) } }
33
+ const nePusto = (v, chto) => {
34
+ if (typeof v !== 'string' || v.trim() === '')
35
+ throw new Error(`${chto}: причина пуста — «${v}»`)
36
+ }
37
+
38
+ t('cause отсутствует → «не указана», а не пустота', () => {
39
+ const r = errDetail(new Error('fetch failed'))
40
+ nePusto(r, 'нет cause'); if (!/не указана/.test(r)) throw new Error(`ждали «не указана», получили «${r}»`)
41
+ })
42
+
43
+ // 🔴 ГЛАВНАЯ: ровно тот случай, что дал 125 пустых строк в бою.
44
+ t('cause.message ПУСТАЯ СТРОКА → причина всё равно названа', () => {
45
+ const c = Object.assign(new Error(''), { code: 'ECONNRESET' })
46
+ const r = errDetail(Object.assign(new Error('fetch failed'), { cause: c }))
47
+ nePusto(r, 'пустой message')
48
+ if (!/ECONNRESET/.test(r)) throw new Error(`код не попал в причину: «${r}»`)
49
+ })
50
+
51
+ t('AggregateError с пустым message → названа и она сама, и её внутренние', () => {
52
+ const vnutri = [Object.assign(new Error('connect ECONNREFUSED'), { code: 'ECONNREFUSED' })]
53
+ const c = new AggregateError(vnutri, '')
54
+ const r = errDetail(Object.assign(new Error('fetch failed'), { cause: c }))
55
+ nePusto(r, 'AggregateError')
56
+ if (!/AggregateError/.test(r)) throw new Error(`класс ошибки не назван: «${r}»`)
57
+ if (!/ECONNREFUSED/.test(r)) throw new Error(`внутренние причины потеряны: «${r}»`)
58
+ })
59
+
60
+ t('обычная причина с message → она и печатается', () => {
61
+ const c = Object.assign(new Error('getaddrinfo ENOTFOUND api.telegram.org'), { code: 'ENOTFOUND' })
62
+ const r = errDetail(Object.assign(new Error('fetch failed'), { cause: c }))
63
+ if (!/ENOTFOUND/.test(r)) throw new Error(`потеряно: «${r}»`)
64
+ })
65
+
66
+ // Отличать «объект есть, описания нет» от «cause отсутствует» — разные состояния.
67
+ t('cause без единого поля → сказано, что описания нет, а не пустота', () => {
68
+ const r = errDetail(Object.assign(new Error('fetch failed'), { cause: Object.create(null) }))
69
+ nePusto(r, 'пустой объект')
70
+ })
71
+
72
+ console.log(`итог: ${ok} из ${ok + bed}`)
73
+ process.exit(bed === 0 ? 0 : 1)
@@ -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));