feat(secretary): устойчивость к обрывам — источник=транскрипт, склейка продолжений, догон сессий

Секретарь перестал терять промпт владельца при обрыве (сбой API / ручной стоп /
жёсткий крах). Источник правды — транскрипт на диске: сырьё (Слой 1) пересобирается
из всего транскрипта на каждом завершении, а не дописывается по последнему обмену.

- classifyEntry/assembleExchanges: распознавание машинных меток (isApiErrorMessage,
  [Request interrupted by user] обе формы, isCompactSummary, isMeta) — метка не считается
  настоящим промптом; промпт после обрыва помечается продолжением (cont=1), хвост — tail=1.
- realBoundariesFromRaw: продолжение не открывает новый спан (одна работа не дробится).
- честные пометки спана: «(связь прерывалась — продолжено)» / «(прервана, не завершена)».
- stop-хук: пересборка сырья из транскрипта + догон недоразобранного хвоста прошлых
  (умерших) сессий дела при «включи секретаря <дело>» (_sessions.json, secretary-sessions).
- parseLastExchange → тонкая обёртка над assembleExchanges (без дубля логики).

Свод секретаря зелёный: 172 теста / 12 файлов.
Спека: docs/superpowers/specs/2026-06-23-secretary-interruption-resilience-spec.md
План: docs/superpowers/plans/2026-06-23-secretary-interruption-resilience.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-24 04:19:16 +03:00
parent f1a2134d03
commit 90f1360065
13 changed files with 1686 additions and 82 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,173 @@
# Финальная спека: секретарь — устойчивость к обрывам (источник = транскрипт)
> **Финал брейншторма** поверх хендофф-направления `2026-06-23-secretary-interruption-resilience-design.md`.
> Все открытые вопросы §6 хендоффа закрыты с владельцем (см. §A). Все машинные метки проверены Grep'ом
> по реальным транскриптам — строки указаны. Дисциплина дальше: `writing-plans` → TDD.
> Режим: штатный (стен нет; пол + проверка-перед-пушем). Ветка `main`, удалёнка `gitea`. Тетради дел НЕ коммитить.
---
## A. Закрытые решения владельца (источник правды по спорным точкам)
| Вопрос (§6 хендоффа) | Решение владельца |
|---|---|
| 6.1 источник правды | **Транскрипт — главный.** На каждом завершении ответа сырьё (Слой 1) пересобирается ИЗ ВСЕГО транскрипта, а не дописывается по последнему обмену. |
| 6.2 продолжение/отмена/уточнение | **Один склеенный кусок + честная пометка.** Модель смысл обрыва НЕ решает — всё механически, по меткам. |
| 6.3 крах между сессиями | **Догон при «включи секретаря `<дело>`».** Не на каждом старте; запуск — действием владельца, привязка по имени дела. |
| 6.4 связь со сделанным | Нарезка по спанам, `distillSpan`, `realBoundariesFromRaw` (meta=1) **остаются**; над ними встаёт транскрипт-сборщик. |
| 6.5 перестройка сырья | **Да** — сырьё всегда полно перестраивается из транскрипта, прерванные ходы не теряются. |
| 6.6 объём меток | Список ниже (§C), **проверен на реальных транскриптах**. |
| Правило после РУЧНОГО стопа | **Считать продолжением (склеивать).** Следующая реплика после любого обрыва (сбой API ИЛИ ручной стоп) по умолчанию = продолжение прежней работы. |
| §8 связанные дела | **Вне этой задачи** (контекст другой сессии). Записаны в §H, не реализуются. |
---
## B. Проблема и корень (доказано)
Секретарь захватывает работу на **Stop** (конец ответа) через `parseLastExchange`
([secretary-transcript.mjs:40](../../../tools/secretary-transcript.mjs)). При обрыве Stop хрупок:
- **Корень потери промпта.** Ручной стоп лежит в транскрипте как сообщение с ролью `user` и текстовым блоком:
```json
{"type":"user","message":{"role":"user","content":[{"type":"text","text":"[Request interrupted by user]"}]}}
```
(`6cead3ab` строки 334, 468, 1465). Текущий `isRealUserPrompt` ([secretary-transcript.mjs:18](../../../tools/secretary-transcript.mjs#L18))
принимает **любой** user-текст за настоящий промпт → метка-обрыв (и «продолжи» после сбоя) крадёт границу
настоящей просьбы владельца. Одна работа дробится на «я: продолжи».
- **Единственное, что переживает все обрывы, — файл транскрипта на диске** (`~/.claude/projects/<proj>/<session>.jsonl`),
Claude Code пишет его по ходу. Значит источник правды — транскрипт, а не Stop/сырьё.
---
## C. Машинные метки (проверено Grep'ом — НЕ по памяти)
Сборщик обязан распознавать и НЕ принимать за настоящий промпт владельца:
| Метка | Где на записи | Смысл | Доказательство (файл:строка) |
|---|---|---|---|
| `entry.isMeta === true` | верхний уровень записи | служебный ход (гейт/навык/контекст) | `ff1d4618` — 5 шт.; уже используется (`userIsMeta`) |
| `entry.isApiErrorMessage === true` | запись с `role:"assistant"`, текст `"API Error: …"` | сбой API, ответ лопнул | `ff1d4618:534` (без статуса), `ff1d4618:577` (`apiErrorStatus:529`) |
| текст user-блока `[Request interrupted by user]` **или** `[Request interrupted by user for tool use]` | `role:"user"`, `content[].type:"text"` | ручной стоп (ДВЕ формы!) | `6cead3ab:334`; `240e3972:530` и `42e79641:225` (форма «for tool use») |
| `entry.isCompactSummary === true` (+ `isVisibleInTranscriptOnly:true`) | служебная запись | выжимка при сжатии контекста | `69992620:4929`, `aca79163` |
**Важно:** `apiErrorStatus` присутствует НЕ всегда (на `ff1d4618:534` его нет) → опираться на `isApiErrorMessage`,
статус — только доп-инфо. Детект ручного стопа матчить по **префиксу** `[Request interrupted by user`
(чтобы покрыть обе формы и будущие вариации хвоста).
---
## D. Решение
### D1. Источник = транскрипт; сырьё пересобирается целиком
На каждом завершении ответа (Stop) секретарь:
1. читает **весь** транскрипт сессии (через `node:fs` в хуке — Read-инструмент закрыт, fs работает);
2. собирает из него последовательность **обменов** (ход = настоящий промпт владельца → ответ → действия),
распознавая метки §C: служебные/метки-обрывы/выжимки **не** считаются настоящим промптом;
3. перезаписывает сырьё (`docs/secretary/raw/<session>.log`) этой пересборкой (PII вырезается перед записью,
как сейчас — `sanitize`).
Сырьё всегда полное → ни один ход (включая прерванный) не пропадает. Нумерация ходов стабильна
(транскрипт append-only). Нарезка по спанам (`computeSpans`/`realBoundariesFromRaw`) и разбор (`distillSpan`)
работают поверх честного сырья **без изменения логики**.
### D2. Сегментация обменов и метки-обрывы
При сборке обменов:
- **Границей нового обмена** служит только настоящий промпт владельца (не meta, не метка-обрыв, не выжимка).
- Запись `isApiErrorMessage` (assistant) и `[Request interrupted by user…]` (user) — **не границы**; они
поглощаются текущим обменом как факт обрыва (помечают, что работа прерывалась).
- `isMeta` и `isCompactSummary` — пропускаются как служебные (не границы, не работа владельца).
### D3. Правило продолжения (структурно, БЕЗ слов)
**Настоящий промпт владельца, идущий сразу после метки-обрыва** (сбой API ИЛИ ручной стоп), **без
завершённого ответа между ними**, — это **продолжение**: он НЕ открывает новый спан, его работа склеивается
со спаном предыдущей настоящей просьбы. Распознаётся по позиции относительно метки, **не по тексту реплики**
(словарь «продолжалок» отвергнут владельцем).
- Реализация: ход-продолжение метится в сырье отдельным ярлычком (по образцу `meta=1`, напр. `cont=1`),
`realBoundariesFromRaw` исключает его из границ — как и meta. Спан настоящей просьбы поглощает ходы-продолжения.
- В протоколе спан получает честную пометку: **«(связь прерывалась — продолжено)»**.
- Хвост, прерванный и **не** продолженный (на момент краха), помечается **«(прервана, не завершена)»** —
обрывок не выдаётся за готовую работу.
### D4. Догон после жёсткого краха (между сессиями)
Жёсткий крах (свет/закрыл VS Code) машинной метки не оставляет; старая сессия умирает, новая получает другой
транскрипт. Догон — при **«включи секретаря `<дело>`»**:
1. секретарь по имени дела находит **прошлую сессию(и)** этого дела (указатель сессий хранится в папке дела —
см. D5);
2. находит её транскрипт `dirname(currentTranscriptPath)/<prevSession>.jsonl`;
3. пересобирает её сырьё из транскрипта (теперь уже с хвостом — D1) и доразбирает недоразобранные спаны
(по курсору) в тетрадь дела через тот же `distillSpan`.
Граница честности (§2.5 хендоффа): хвост появляется не мгновенно, а этим догоном на следующем включении дела.
### D5. Привязка сессии к делу
Чтобы догон знал, чей транскрипт перечитывать, в **папке дела** ведётся указатель сессий
(напр. `docs/secretary/<work>/_sessions.json`: список `{session, lastSpanCursor}`), обновляемый stop-хуком
на каждом ходе, пока дело активно. На re-активации этот указатель → список прошлых сессий для догона.
Сам прогон догона (вызовы модели) делает **stop-хук** на первом Stop после активации (prompt-хук лишь ставит
пометку «догнать сессии X»), чтобы вся LLM-машинерия осталась в одном месте.
---
## E. Контракт (что должно стать правдой)
- `parseLastExchange`/новый ассемблер распознаёт `isApiErrorMessage`, `isCompactSummary` и текст
`[Request interrupted by user…]`; НЕ берёт их за настоящий промпт; находит настоящие промпты как границы обменов.
- Сырьё пересобирается из всего транскрипта → прерванные ходы не теряются; настоящий промпт владельца сохранён
(он финализируется в момент отправки, до ответа).
- Ход-продолжение (после метки-обрыва) не создаёт новый спан; склеивается с предыдущей настоящей просьбой;
спан помечен «(связь прерывалась — продолжено)».
- Незавершённый прерванный хвост помечен «(прервана, не завершена)».
- Догон при re-активации дела дописывает недоразобранный хвост прошлой (умершей) сессии.
- Модель не решает смысл обрыва (детерминированно по меткам).
---
## F. Карта файлов (что трогать)
| Файл | Правка |
|---|---|
| `tools/secretary-transcript.mjs` | новый/расширенный ассемблер ВСЕГО транскрипта по обменам; классификация user-записи (настоящий промпт / meta / метка-обрыв / выжимка); флаг продолжения по позиции относительно метки. |
| `tools/secretary-stop-hook.mjs` | «пересобрать сырьё из всего транскрипта» вместо «дописать последний обмен»; обновлять указатель сессий дела (D5); выполнять догон (D4) при пометке от prompt-хука. |
| `tools/secretary-layer1.mjs` | `buildRawRecord` — ярлычок `cont=1` для хода-продолжения; `realBoundariesFromRaw` — исключать `cont=1` из границ (как `meta=1`). |
| `tools/secretary-prompt-hook.mjs` | на «включи секретаря `<дело>`» — поставить пометку «догнать прошлые сессии дела» по указателю (D5). |
| `tools/secretary-span.mjs` | без изменений логики; кормится честным сырьём. Рендер пометок «прерывалось/продолжено» и «не завершена». |
| `tools/secretary-distill.mjs` | без изменений логики (общий разбор спана). |
| `tools/secretary-protocol.mjs` (рендер) | показ честных пометок в спане. |
| `tools/secretary-*.test.mjs` | TDD-фикстуры на каждый вид обрыва (см. §G). |
---
## G. Тесты (TDD — фикстуры под каждый вид обрыва)
1. **Сбой API + продолжение:** транскрипт с `isApiErrorMessage` посередине работы и настоящим промптом-продолжением
после → один спан под настоящей первой просьбой, пометка «продолжено»; «продолжи»/реплика-продолжение НЕ
стала отдельным спаном.
2. **Ручной стоп (обе формы) + продолжение:** `[Request interrupted by user]` и `…for tool use` → то же склеивание.
3. **Прерван и не продолжен:** обрыв в конце транскрипта без продолжения → пометка «(прервана, не завершена)».
4. **Выжимка контекста:** `isCompactSummary` в транскрипте не становится ни промптом, ни работой.
5. **Служебный ход:** `isMeta` (регресс — не сломать существующее) не граница.
6. **Догон между сессиями:** сырьё прошлой сессии перестроено из её транскрипта; недоразобранный хвост дописан
в тетрадь дела на re-активации.
7. **Полнота:** пересборка сырья из транскрипта не теряет ни один ход (сверка число обменов ↔ настоящие промпты).
---
## H. Приёмка
- **Полный свод секретаря зелёный** (11 тест-файлов, команда из хендоффа §1, плюс новые тесты §G):
```
npx vitest run tools/secretary-reconcile.test.mjs tools/secretary-layer1.test.mjs tools/secretary-protocol.test.mjs tools/secretary-index.test.mjs tools/secretary-audit.test.mjs tools/secretary-hookutil.test.mjs tools/secretary-transcript.test.mjs tools/secretary-flag.test.mjs tools/secretary-prompt-hook.test.mjs tools/secretary-span.test.mjs tools/secretary-distill.test.mjs
```
- **ЖИВОЙ прогон** с реальным `SECRETARY_LLM_KEY` (aitunnel, `SECRETARY_LLM_MODEL`), где есть обрыв:
(а) сбой API + «продолжи», (б) ручной стоп. Убедиться: настоящий промпт владельца **в тетради** (а не «продолжи»);
прерванная работа помечена честно; одна работа не раздроблена.
- Сверка: ничего из работы/диалога не потеряно (транскрипт ↔ тетрадь).
---
## I. Вне области (контекст §8 хендоффа — НЕ реализуется здесь)
Поднято в другой сессии, отдельные дела со своим брейнштормом/спекой — **не трогаем**:
- (A) дословное логирование промптов/ответов наставника/судьи/роутера (подсистема стены, не секретарь);
- (Б) качество модели секретаря (deepseek-v4-flash слабо поднимает «Решения/волю» — вопрос смены `SECRETARY_LLM_MODEL`).
+2 -2
View File
@@ -9,7 +9,7 @@ import { buildStepLine } from './secretary-layer1.mjs';
* proto — текущий протокол; spanEx — склеенный обмен спана {user,assistant,actions};
* {start,end} — границы спана (в ходах сырья); opts: { callModel|null, session, diag }.
* Без callModel (нет ключа) — пишется только детерминированный шаг, категории/СВ не трогаются. */
export async function distillSpan(proto, spanEx, { start, end }, { callModel, session, diag } = {}) {
export async function distillSpan(proto, spanEx, { start, end, note = '' }, { callModel, session, diag } = {}) {
// Снимок реестра СВ ДО reconcile (reconcile перенумеровывает hidden) — вернём после merge.
const svSnapshot = JSON.parse(JSON.stringify({
hidden: proto.hidden || [], acceptance: proto.acceptance || [],
@@ -25,7 +25,7 @@ export async function distillSpan(proto, spanEx, { start, end }, { callModel, se
if (updated && 'step' in updated) delete updated.step;
const step = { turn: start, session,
text: buildStepLine({ turn: start, endTurn: end, user: spanEx.user, assistant: spanEx.assistant,
actions: (spanEx.actions || []).map((a) => a.tool), essence: modelStep }) };
actions: (spanEx.actions || []).map((a) => a.tool), essence: modelStep, note }) };
const toWrite = mergeTurnIntoProtocol({ proto, updated, step });
// Реестр СВ — вотчина аудитора: вернуть из снимка ДО reconcile.
+10
View File
@@ -2,6 +2,16 @@ import { describe, it, expect } from 'vitest';
import { distillSpan } from './secretary-distill.mjs';
import { EMPTY_PROTOCOL } from './secretary-protocol.mjs';
describe('distillSpan — честная пометка спана в шаге (без модели)', () => {
it('передаёт note в шаг, когда спан помечен продолжением', async () => {
const proto = EMPTY_PROTOCOL();
const spanEx = { user: 'настоящая просьба длинная', assistant: 'докончил', actions: [] };
const out = await distillSpan(proto, spanEx, { start: 3, end: 4, note: '(связь прерывалась — продолжено)' }, { callModel: null });
const step = out.steps.find((s) => s.turn === 3);
expect(step.text.endsWith('(связь прерывалась — продолжено)')).toBe(true);
});
});
describe('distillSpan — разбор одного завершённого спана (reconcile + аудит)', () => {
it('добавляет шаг спана, применяет reconcile и аудит', async () => {
const proto = EMPTY_PROTOCOL();
+22 -8
View File
@@ -15,12 +15,13 @@ function neutralizeMarkers(s) {
// Чистый билдер сырой записи Слоя 1 (§L1). PII вырезается вызывающим хуком до записи;
// чтение источника (transcript_path) — в хук-обёртке. Здесь — только формат.
export function buildRawRecord({ turn, time, session, user, assistant, actions = [], userIsMeta = false } = {}) {
export function buildRawRecord({ turn, time, session, user, assistant, actions = [], userIsMeta = false, isContinuation = false, interruptedTail = false } = {}) {
const acts = Array.isArray(actions) ? actions : [];
// Структурная метка служебного хода (гейт-фидбек/навык/контекст) прямо в заголовке — чтобы
// границы спанов определялись честно по ярлычку isMeta, а не угадывались по тексту/номеру.
const meta = userIsMeta ? ' · meta=1' : '';
const lines = [`=== ХОД turn=${turn} · ${time} · session=${session}${meta} ===`,
// Структурные ярлычки хода в заголовке: meta=1 служебный, cont=1 продолжение после обрыва,
// tail=1 прерван-и-не-завершён. Границы спанов читают их структурно (не по тексту реплики).
const marks = [userIsMeta ? 'meta=1' : '', isContinuation ? 'cont=1' : '', interruptedTail ? 'tail=1' : '']
.filter(Boolean).map((m) => ` · ${m}`).join('');
const lines = [`=== ХОД turn=${turn} · ${time} · session=${session}${marks} ===`,
'[ЮЗЕР]', neutralizeMarkers(user), '[АССИСТЕНТ]', neutralizeMarkers(assistant)];
for (const a of acts) {
lines.push(`[ДЕЙСТВИЕ] ${a.tool} in=${neutralizeMarkers(a.input ?? '')}`);
@@ -62,12 +63,23 @@ export function realBoundariesFromRaw(rawText) {
return splitRawIntoTurns(rawText).filter(({ block }) => {
const header = (block.match(/=== ХОД turn=\d+[^\n]*===/) || [''])[0];
if (/·\s*meta=1/.test(header)) return false; // структурно служебный
if (/·\s*cont=1/.test(header)) return false; // продолжение после обрыва — не граница
const um = block.match(/\[ЮЗЕР\]\n([\s\S]*?)\n\[АССИСТЕНТ\]/);
const u = (um ? um[1] : '').trim();
return !/^Stop hook feedback/i.test(u) && !/^Base directory for this skill/i.test(u); // фолбэк по тексту
}).map((p) => p.turn);
}
// Честная пометка спана по структурным ярлычкам ходов в нём: tail (прервана-не-завершена)
// приоритетнее cont (продолжено). Без ярлычков — пусто.
export function spanInterruptNote(rawText, { start, end }) {
const blocks = splitRawIntoTurns(rawText).filter((p) => p.turn >= start && p.turn <= end);
const headers = blocks.map((b) => (b.block.match(/=== ХОД turn=\d+[^\n]*===/) || [''])[0]);
if (headers.some((h) => /·\s*tail=1/.test(h))) return '(прервана, не завершена)';
if (headers.some((h) => /·\s*cont=1/.test(h))) return '(связь прерывалась — продолжено)';
return '';
}
// Пересборка Шагов из сырья ПО СПАНАМ: одна строка на реальный промпт (склейка ходов спана).
// realPromptTurns — авторитетные границы; null/пусто → фолбэк по sysLabel.
export function buildStepsFromRaw(rawText, session, realPromptTurns = null) {
@@ -87,7 +99,8 @@ export function buildStepsFromRaw(rawText, session, realPromptTurns = null) {
.filter(Boolean).join(' ');
const actions = blocks.flatMap((b) => [...b.matchAll(/\[ДЕЙСТВИЕ\]\s+(\S+)/g)].map((x) => x[1]));
return { turn: start, session,
text: buildStepLine({ turn: start, endTurn: end, user: um ? um[1] : '', assistant: aAll, actions }) };
text: buildStepLine({ turn: start, endTurn: end, user: um ? um[1] : '', assistant: aAll, actions,
note: spanInterruptNote(rawText, { start, end }) }) };
});
}
@@ -101,7 +114,7 @@ export function mergeStepsPreservingText(existingSteps, rawText, session, realPr
// Человекочитаемая строка шага для раздела «Шаги (Слой 1)»: «Ход N — я: … · ты: … · делал: …».
// Суть — первая фраза реплики; служебные строки (экономия/coverage/вердикт) отброшены;
// «делал» — имена инструментов из действий хода. Название файла полного хода добавляет рендер.
export function buildStepLine({ turn, endTurn = null, user, assistant, actions = [], essence = null } = {}) {
export function buildStepLine({ turn, endTurn = null, user, assistant, actions = [], essence = null, note = '' } = {}) {
// Содержательная фраза: убираем ведущую нумерацию списка («1.»/«2)»), копим до ≥25 симв.,
// чтобы не выдать обрывок «Стоп.»; длинное усекаем.
const firstSentence = (s) => {
@@ -129,7 +142,8 @@ export function buildStepLine({ turn, endTurn = null, user, assistant, actions =
const a = eA || firstSentence(cleanA) || '(без ответа)';
const did = [...new Set((actions || []).map((t) => String(t).trim()).filter(Boolean))].join(', ') || '—';
const span = (endTurn != null && endTurn > turn) ? ` [вобрал ходы ${turn}-${endTurn}]` : '';
return `Ход (промпт) ${turn}${span} — я: ${u} · ты: ${a} · делал: ${did}`;
const tail = note ? ` · ${note}` : '';
return `Ход (промпт) ${turn}${span} — я: ${u} · ты: ${a} · делал: ${did}${tail}`;
}
import { writeFileSync as _writeFileSync, renameSync as _renameSync } from 'node:fs';
+45 -1
View File
@@ -1,5 +1,30 @@
import { describe, it, expect } from 'vitest';
import { buildRawRecord, buildStepLine, splitRawIntoTurns, turnFileName, prepareTurnFiles, buildStepsFromRaw, writeFileAtomic, mergeStepsPreservingText, realBoundariesFromRaw } from './secretary-layer1.mjs';
import { buildRawRecord, buildStepLine, splitRawIntoTurns, turnFileName, prepareTurnFiles, buildStepsFromRaw, writeFileAtomic, mergeStepsPreservingText, realBoundariesFromRaw, spanInterruptNote } from './secretary-layer1.mjs';
describe('честные пометки прерванного спана', () => {
const rawCont = [
buildRawRecord({ turn: 3, time: 't', session: 's', user: 'настоящая просьба длинная', assistant: 'начал' }),
buildRawRecord({ turn: 4, time: 't', session: 's', user: 'продолжи', assistant: 'докончил', isContinuation: true }),
].join('');
const rawTail = [
buildRawRecord({ turn: 7, time: 't', session: 's', user: 'большая задача длинная', assistant: 'часть', interruptedTail: true }),
].join('');
it('spanInterruptNote: спан с cont → «продолжено»', () => {
expect(spanInterruptNote(rawCont, { start: 3, end: 4 })).toBe('(связь прерывалась — продолжено)');
});
it('spanInterruptNote: спан с tail → «прервана, не завершена»', () => {
expect(spanInterruptNote(rawTail, { start: 7, end: 7 })).toBe('(прервана, не завершена)');
});
it('spanInterruptNote: обычный спан → пусто', () => {
const raw = buildRawRecord({ turn: 1, time: 't', session: 's', user: 'обычный длинный вопрос', assistant: 'ок' });
expect(spanInterruptNote(raw, { start: 1, end: 1 })).toBe('');
});
it('buildStepLine с note приклеивает пометку в конец', () => {
const s = buildStepLine({ turn: 3, endTurn: 4, user: 'просьба длинная достаточно', assistant: 'ок', note: '(связь прерывалась — продолжено)' });
expect(s.endsWith('(связь прерывалась — продолжено)')).toBe(true);
});
});
describe('обезвреживание маркеров на записи (от самозагрязнения лога)', () => {
it('маркеры внутри текста реплик/действий не дают лишних структурных совпадений', () => {
@@ -35,6 +60,17 @@ describe('метка служебного хода (meta=1) + структурн
const rec = buildRawRecord({ turn: 6, time: 't', session: 's', user: 'привет', assistant: 'a' });
expect(rec).not.toContain('meta=1');
});
it('buildRawRecord: продолжение помечается cont=1, незавершённый хвост — tail=1', () => {
const cont = buildRawRecord({ turn: 5, time: 't', session: 's', user: 'продолжи', assistant: 'a', isContinuation: true });
expect(cont).toMatch(/=== ХОД turn=5[^\n]*cont=1[^\n]*===/);
const tail = buildRawRecord({ turn: 6, time: 't', session: 's', user: 'задача', assistant: 'a', interruptedTail: true });
expect(tail).toMatch(/=== ХОД turn=6[^\n]*tail=1[^\n]*===/);
});
it('buildRawRecord: meta+cont вместе — оба ярлычка в заголовке', () => {
const rec = buildRawRecord({ turn: 7, time: 't', session: 's', user: 'u', assistant: 'a', userIsMeta: true, isContinuation: true });
expect(rec).toMatch(/meta=1/);
expect(rec).toMatch(/cont=1/);
});
it('realBoundariesFromRaw: служебные по meta=1 исключены (структурно, не по тексту)', () => {
const raw = [
buildRawRecord({ turn: 7, time: 't', session: 's', user: 'настоящий 1', assistant: 'a' }),
@@ -43,6 +79,14 @@ describe('метка служебного хода (meta=1) + структурн
].join('');
expect(realBoundariesFromRaw(raw)).toEqual([7, 9]);
});
it('realBoundariesFromRaw: ход-продолжение (cont=1) НЕ граница (склеивается к прошлой просьбе)', () => {
const raw = [
buildRawRecord({ turn: 3, time: 't', session: 's', user: 'настоящая просьба', assistant: 'a' }),
buildRawRecord({ turn: 4, time: 't', session: 's', user: 'продолжи', assistant: 'b', isContinuation: true }),
buildRawRecord({ turn: 5, time: 't', session: 's', user: 'новая просьба', assistant: 'c' }),
].join('');
expect(realBoundariesFromRaw(raw)).toEqual([3, 5]); // ход 4 (cont) приклеен к спану 3
});
it('realBoundariesFromRaw: фолбэк по тексту для старого сырья без меток', () => {
const raw = [
'=== ХОД turn=7 · t · session=s ===', '[ЮЗЕР]', 'настоящий', '[АССИСТЕНТ]', 'a', '=== КОНЕЦ ХОДА ===', '',
+13 -3
View File
@@ -7,6 +7,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from
import { join, dirname } from 'node:path';
import { homedir } from 'node:os';
import { detectSecretaryCommand, secretaryModeFileName, resolveCaseActivation } from './secretary-flag.mjs';
import { prevSessionsForCatchUp } from './secretary-sessions.mjs';
function readStdin() { try { return readFileSync(0, 'utf-8'); } catch { return ''; } }
function turnCount(rawFile) {
@@ -25,7 +26,7 @@ function listCases(secdir) {
// Решение хука на «включи»: активировать (флажок on) либо переспросить (имя похоже на
// существующее дело). Чистая функция — вынесена ради теста; main() её исполняет с реальными fs.
export function planActivation({ requested, existing = [], startedAtTurn = 0, session } = {}) {
export function planActivation({ requested, existing = [], startedAtTurn = 0, session, sessionsOfCase = [] } = {}) {
const res = resolveCaseActivation(requested, existing);
if (res.action === 'confirm') {
const context = `📒 Секретарь: имя дела «${requested}» похоже на существующее: ${res.candidates.join(', ')}.\n`
@@ -33,7 +34,9 @@ export function planActivation({ requested, existing = [], startedAtTurn = 0, se
+ 'Если новое дело — повтори с именем, не совпадающим с этими.';
return { confirm: true, candidates: res.candidates, context };
}
return { confirm: false, flag: { mode: 'on', startedAtTurn, work: res.work, session } };
// catchUp — прошлые сессии этого дела (кроме текущей): stop-хук догонит их недоразобранный хвост.
return { confirm: false, flag: { mode: 'on', startedAtTurn, work: res.work, session,
catchUp: prevSessionsForCatchUp(sessionsOfCase, session) } };
}
// Решение хука по команде секретаря. cmd: 'off' → перевести флажок в closing (с сохранением полей);
@@ -60,9 +63,16 @@ function main() {
if (cmd === 'on') {
const m = prompt.match(/секретар[а-я]*\s+(?:для\s+|по\s+)?([a-zA-Zа-яёА-ЯЁ0-9-]{2,})/);
const requested = (m && m[1]) || 'general';
// Прошлые сессии дела — из _sessions.json в папке дела (для догона после краха).
const res0 = resolveCaseActivation(requested, listCases(secdir));
let sessionsOfCase = [];
try {
const sf = join(secdir, res0.work || requested, '_sessions.json');
if (existsSync(sf)) sessionsOfCase = JSON.parse(readFileSync(sf, 'utf-8'));
} catch { sessionsOfCase = []; }
const plan = planActivation({
requested, existing: listCases(secdir),
startedAtTurn: turnCount(rawFile), session,
startedAtTurn: turnCount(rawFile), session, sessionsOfCase,
});
if (plan.confirm) {
// Похоже на существующее дело — НЕ включаем, переспрашиваем (защита от дела-двойника).
+13 -1
View File
@@ -17,7 +17,7 @@ describe('planActivation — решение хука: активировать
it('новое имя (нет похожих) — флажок on с work', () => {
const r = planActivation({ requested: 'биллинг', existing: ['general'], startedAtTurn: 3, session: 's1' });
expect(r.confirm).toBe(false);
expect(r.flag).toEqual({ mode: 'on', startedAtTurn: 3, work: 'биллинг', session: 's1' });
expect(r.flag).toEqual({ mode: 'on', startedAtTurn: 3, work: 'биллинг', session: 's1', catchUp: [] });
});
it('точное совпадение — флажок on с существующим именем', () => {
const r = planActivation({ requested: 'general', existing: ['general'], startedAtTurn: 0, session: 's2' });
@@ -31,4 +31,16 @@ describe('planActivation — решение хука: активировать
expect(r.candidates).toContain('создание-секретаря');
expect(r.context).toContain('создание-секретаря');
});
it('проставляет catchUp из прошлых сессий дела', () => {
const plan = planActivation({
requested: 'наставник', existing: ['наставник'], startedAtTurn: 0, session: 's2',
sessionsOfCase: [{ session: 's1', cursor: 2 }, { session: 's2', cursor: 0 }],
});
expect(plan.confirm).toBe(false);
expect(plan.flag.catchUp).toEqual([{ session: 's1', cursor: 2 }]);
});
it('без прошлых сессий — catchUp пустой', () => {
const plan = planActivation({ requested: 'новое', existing: [], session: 's1', sessionsOfCase: [] });
expect(plan.flag.catchUp).toEqual([]);
});
});
+16
View File
@@ -0,0 +1,16 @@
// Учёт «какие сессии вели дело» — для догона недоразобранного хвоста умершей сессии после краха.
// Хранится в папке дела (docs/secretary/<work>/_sessions.json), НЕ коммитится.
/** Добавить/обновить указатель сессии {session, cursor}. cursor — последний разобранный spanCursor. */
export function upsertSessionPointer(list, { session, cursor }) {
const out = (Array.isArray(list) ? list : []).map((e) => ({ ...e }));
const i = out.findIndex((e) => e.session === session);
if (i >= 0) out[i].cursor = cursor;
else out.push({ session, cursor });
return out;
}
/** Прошлые сессии дела (кроме текущей) — кандидаты на догон хвоста. */
export function prevSessionsForCatchUp(list, currentSession) {
return (Array.isArray(list) ? list : []).filter((e) => e && e.session && e.session !== currentSession);
}
+20
View File
@@ -0,0 +1,20 @@
import { describe, it, expect } from 'vitest';
import { upsertSessionPointer, prevSessionsForCatchUp } from './secretary-sessions.mjs';
describe('secretary-sessions — учёт сессий дела', () => {
it('upsertSessionPointer добавляет новую сессию с курсором', () => {
const out = upsertSessionPointer([], { session: 's1', cursor: 2 });
expect(out).toEqual([{ session: 's1', cursor: 2 }]);
});
it('upsertSessionPointer обновляет курсор существующей', () => {
const out = upsertSessionPointer([{ session: 's1', cursor: 1 }], { session: 's1', cursor: 5 });
expect(out).toEqual([{ session: 's1', cursor: 5 }]);
});
it('prevSessionsForCatchUp — все сессии дела кроме текущей', () => {
const list = [{ session: 's1', cursor: 3 }, { session: 's2', cursor: 0 }];
expect(prevSessionsForCatchUp(list, 's2')).toEqual([{ session: 's1', cursor: 3 }]);
});
it('prevSessionsForCatchUp — пустой список → пусто', () => {
expect(prevSessionsForCatchUp([], 's2')).toEqual([]);
});
});
+51 -19
View File
@@ -5,11 +5,12 @@
// Закрытые спаны разбираются один раз (курсор в флажке); при mode:'closing' добивается последний
// открытый спан + нарезка сырья + гашение флажка. Разбор одного спана — общий distillSpan.
import { existsSync, readFileSync, appendFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { join, dirname } from 'node:path';
import { homedir } from 'node:os';
import { parseLastExchange } from './secretary-transcript.mjs';
import { assembleExchanges, buildRawFromExchanges } from './secretary-transcript.mjs';
import { secretaryModeFileName } from './secretary-flag.mjs';
import { buildRawRecord, writeFileAtomic, realBoundariesFromRaw, mergeStepsPreservingText, prepareTurnFiles } from './secretary-layer1.mjs';
import { upsertSessionPointer } from './secretary-sessions.mjs';
import { writeFileAtomic, realBoundariesFromRaw, mergeStepsPreservingText, prepareTurnFiles, spanInterruptNote } from './secretary-layer1.mjs';
import { formatReconcileLogLine } from './secretary-reconcile.mjs';
import { renderProtocol, EMPTY_PROTOCOL } from './secretary-protocol.mjs';
import { upsertIndexEntry } from './secretary-index.mjs';
@@ -26,10 +27,6 @@ function readFlag(session) {
function writeFlag(session, flag) {
try { writeFileSync(flagPath(session), JSON.stringify(flag)); } catch { /* ignore */ }
}
function turnCount(rawFile) {
if (!existsSync(rawFile)) return 0;
try { return (readFileSync(rawFile, 'utf-8').match(/=== ХОД turn=/g) || []).length; } catch { return 0; }
}
async function main() {
let ev = {};
@@ -41,17 +38,15 @@ async function main() {
const secdir = join(process.cwd(), 'docs', 'secretary');
const rawFile = join(secdir, 'raw', `${session}.log`);
const ex = parseLastExchange(transcript);
const turn = turnCount(rawFile) + 1;
const ex = assembleExchanges(transcript);
const turn = ex.length;
// Слой 1: всегда пишем сырьё (PII вырезается перед записью); служебный ход помечаем meta=1.
// Слой 1: ВСЕГДА пересобираем сырьё из всего транскрипта (переживает обрывы; PII вырезается).
// Метки-обрывы не считаются настоящим промптом; продолжение помечается cont=1, хвост — tail=1.
try {
const rec = sanitize(buildRawRecord({
turn, time: new Date().toISOString(), session,
user: ex.user, assistant: ex.assistant, actions: ex.actions, userIsMeta: ex.userIsMeta,
}));
const rawContent = buildRawFromExchanges(ex, { session, sanitize });
mkdirSync(join(secdir, 'raw'), { recursive: true });
appendFileSync(rawFile, rec + '\n', 'utf-8');
writeFileAtomic(rawFile, rawContent);
} catch { /* fail-quiet */ }
// Тетрадь (Слой 2) — только если секретарь включён или закрывается.
@@ -82,9 +77,6 @@ async function main() {
list.push({ start: lastOpen.start, end: lastOpen.end, index: lastOpen.index });
}
// Обычный ход без новых закрытых спанов — тетрадь не трогаем (отставание на один промпт).
if (!list.length && !closing) { process.exit(0); }
const reLog = join(workDir, '_reconcile.log');
const logReason = (info) => {
try {
@@ -103,11 +95,44 @@ async function main() {
})
: null;
// Догон после жёсткого краха: разобрать недоразобранный хвост прошлых (умерших) сессий этого дела.
// Сессия мертва → её последний (открытый) спан тоже финальный, поэтому берём ВСЕ спаны за курсором.
let didCatchUp = false;
if (Array.isArray(flag.catchUp) && flag.catchUp.length && callModel) {
const projDir = tp ? dirname(tp) : null;
for (const prev of flag.catchUp) {
if (!projDir || !prev || !prev.session) continue;
let prevTranscript = '';
try {
const prevTp = join(projDir, `${prev.session}.jsonl`);
if (existsSync(prevTp)) prevTranscript = readFileSync(prevTp, 'utf-8');
} catch { prevTranscript = ''; }
if (!prevTranscript) continue;
const prevRaw = buildRawFromExchanges(assembleExchanges(prevTranscript), { session: prev.session, sanitize });
try { mkdirSync(join(secdir, 'raw'), { recursive: true }); writeFileAtomic(join(secdir, 'raw', `${prev.session}.log`), prevRaw); } catch { /* ignore */ }
const prevBounds = realBoundariesFromRaw(prevRaw);
const prevLast = (prevRaw.match(/=== ХОД turn=/g) || []).length;
const prevCursor = Number.isFinite(prev.cursor) ? prev.cursor : -1;
for (const span of computeSpans(prevBounds, prevLast).map((s, index) => ({ ...s, index }))) {
if (span.index <= prevCursor) continue;
const spanEx = assembleSpan(prevRaw, span);
const note = spanInterruptNote(prevRaw, span);
proto = await distillSpan(proto, spanEx, { ...span, note }, { callModel, session: prev.session, diag: logReason });
}
}
writeFlag(session, { ...readFlag(session), catchUp: [] });
didCatchUp = true;
}
// Обычный ход без новых закрытых спанов и без догона — тетрадь не трогаем (отставание на промпт).
if (!list.length && !closing && !didCatchUp) { process.exit(0); }
// Разбор каждого завершённого спана по порядку (общий distillSpan: reconcile + аудит на ПОЛНОМ спане).
let lastIndex = cursor;
for (const span of list) {
const spanEx = assembleSpan(rawText, span);
proto = await distillSpan(proto, spanEx, span, { callModel, session, diag: logReason });
const note = spanInterruptNote(rawText, span);
proto = await distillSpan(proto, spanEx, { ...span, note }, { callModel, session, diag: logReason });
lastIndex = span.index;
}
@@ -142,6 +167,13 @@ async function main() {
} else {
// Обычный ход: сохранить продвинутый курсор (прочие поля флажка целы).
writeFlag(session, { ...readFlag(session), spanCursor: lastIndex });
// Указатель сессии дела (для догона будущих сессий после краха).
try {
const sf = join(workDir, '_sessions.json');
let arr = [];
try { if (existsSync(sf)) arr = JSON.parse(readFileSync(sf, 'utf-8')); } catch { arr = []; }
writeFileAtomic(sf, JSON.stringify(upsertSessionPointer(arr, { session, cursor: lastIndex })));
} catch { /* указатель вторичен */ }
}
} catch { /* fail-quiet: сырьё уже записано */ }
process.exit(0);
+108 -47
View File
@@ -1,6 +1,7 @@
// Чистый разбор хвоста стенограммы: последний обмен (user + assistant + действия).
// Схема сверена с observer-transcript-parser: entry.message.role / entry.message.content
// (строка или массив блоков text/tool_use{name,input}).
import { buildRawRecord } from './secretary-layer1.mjs';
function parseLines(text) {
const entries = [];
@@ -23,6 +24,92 @@ function isRealUserPrompt(msg) {
return false;
}
// Текст user-контента (строка или массив text-блоков) — для классификации меток.
function userText(content) {
if (typeof content === 'string') return content;
if (Array.isArray(content)) return content.filter((b) => b && b.type === 'text').map((b) => b.text).join('\n');
return '';
}
// Вид записи транскрипта для сборки обменов. Метки печатает Claude Code (не владелец) —
// распознаём структурно, опечатки в тексте владельца ни на что не влияют.
// Порядок проверок важен: метка-обрыв проверяется ДО real (она тоже role:user с text-блоком).
export function classifyEntry(entry) {
if (!entry) return 'skip';
if (entry.isCompactSummary === true) return 'summary';
if (entry.isApiErrorMessage === true) return 'interrupt-api';
const m = entry.message;
if (!m) return 'skip';
if (m.role === 'user') {
if (/^\s*\[Request interrupted by user/.test(userText(m.content))) return 'interrupt-stop';
if (Array.isArray(m.content) && m.content.some((b) => b && b.type === 'tool_result')) return 'tool_result';
if (isRealUserPrompt(m)) return entry.isMeta === true ? 'meta' : 'real';
return 'skip';
}
if (m.role === 'assistant') return 'assistant';
return 'skip';
}
// Сборка ВСЕХ обменов из транскрипта. Обмен = настоящий промпт владельца (или служебный ход)
// → ответ ассистента + действия, до следующего настоящего промпта/служебного хода.
// Метки-обрывы (сбой API / ручной стоп) НЕ начинают обмен: промпт сразу после метки помечается
// продолжением (isContinuation), не открывает новый спан. Незавершённый прерванный хвост в конце —
// interruptedTail. Выжимки сжатия и прочее служебное — пропускаются.
export function assembleExchanges(transcriptText) {
const entries = parseLines(transcriptText);
const exchanges = [];
let cur = null;
let pendingInterrupt = false; // метка-обрыв видна, ждём следующий настоящий промпт
const push = () => { if (cur) exchanges.push(cur); };
for (const e of entries) {
const kind = classifyEntry(e);
if (kind === 'real' || kind === 'meta') {
push();
cur = {
user: userText(e.message.content), assistant: '', actions: [], results: {},
userIsMeta: kind === 'meta',
isContinuation: kind === 'real' && pendingInterrupt,
interruptedTail: false,
time: e.timestamp || '',
};
pendingInterrupt = false;
} else if (kind === 'assistant') {
if (!cur) continue;
const c = e.message.content;
if (Array.isArray(c)) {
for (const b of c) {
if (b && b.type === 'text' && b.text) cur.assistant += (cur.assistant ? '\n' : '') + b.text;
if (b && b.type === 'tool_use') cur.actions.push({ id: b.id, tool: b.name, input: JSON.stringify(b.input ?? {}) });
}
} else if (typeof c === 'string') {
cur.assistant += (cur.assistant ? '\n' : '') + c;
}
} else if (kind === 'tool_result') {
if (!cur) continue;
for (const b of e.message.content) {
if (b && b.type === 'tool_result' && b.tool_use_id != null) cur.results[b.tool_use_id] = resultText(b.content);
}
} else if (kind === 'interrupt-api' || kind === 'interrupt-stop') {
pendingInterrupt = true;
if (cur) cur.interruptedTail = true; // предварительно; снимется, если ниже есть продолжение
}
// 'summary' / 'skip' — игнор
}
push();
// Хвостом остаётся только ПОСЛЕДНИЙ обмен: если ниже есть ещё обмен — работа так или иначе продолжилась.
for (let i = 0; i < exchanges.length - 1; i++) exchanges[i].interruptedTail = false;
// Привязка выдачи к действию по id; снять служебное поле results.
for (const ex of exchanges) {
ex.actions = ex.actions.map((a) => {
const out = { tool: a.tool, input: a.input };
if (a.id != null && ex.results[a.id] != null) out.result = String(ex.results[a.id] ?? '');
return out;
});
delete ex.results;
}
return exchanges;
}
// Текст результата инструмента: строка как есть; массив блоков → склейка text-блоков.
// Без обрезки: секретарь должен видеть ПОЛНОЕ содержимое (линзы ловят ошибки/пропуски).
function resultText(content) {
@@ -34,52 +121,26 @@ function resultText(content) {
return '';
}
/** Последний обмен из стенограммы: { user, assistant, actions:[{tool,input,result?}] }.
* result привязывается к действию по tool_use.id === tool_result.tool_use_id (усечён до предела);
* без совпадения действие остаётся прежней формы {tool,input} — без ключа result. */
/** Последний обмен из стенограммы: { user, assistant, actions:[{tool,input,result?}], userIsMeta }.
* Тонкая обёртка над assembleExchanges (источник правды о видах записи и метках) — без дубля логики. */
export function parseLastExchange(transcriptText) {
const entries = parseLines(transcriptText);
let u = -1;
for (let i = entries.length - 1; i >= 0; i--) {
if (entries[i] && isRealUserPrompt(entries[i].message)) { u = i; break; }
}
const userContent = u >= 0 ? entries[u].message.content : '';
const user = typeof userContent === 'string'
? userContent
: (Array.isArray(userContent)
? userContent.filter((b) => b && b.type === 'text').map((b) => b.text).join('\n')
: '');
// Структурный ярлычок: служебное сообщение (гейт-фидбек / загрузка навыка / контекст) помечено
// isMeta:true на самой записи транскрипта. Реальная просьба владельца — без него. Это честный
// разделитель «хозяин vs служебное» (не угадывание по тексту/номеру хода).
const userIsMeta = u >= 0 && entries[u].isMeta === true;
let assistant = '';
const raw = []; // {id, tool, input} — вызовы инструментов
const results = {}; // tool_use_id -> текст результата (из tool_result в сообщениях role:user)
for (let i = u + 1; i < entries.length; i++) {
const m = entries[i] && entries[i].message;
if (!m) continue;
const c = m.content;
if (m.role === 'assistant') {
if (Array.isArray(c)) {
for (const b of c) {
if (b && b.type === 'text' && b.text) assistant += (assistant ? '\n' : '') + b.text;
if (b && b.type === 'tool_use') raw.push({ id: b.id, tool: b.name, input: JSON.stringify(b.input ?? {}) });
}
} else if (typeof c === 'string') {
assistant += (assistant ? '\n' : '') + c;
}
} else if (m.role === 'user' && Array.isArray(c)) {
for (const b of c) {
if (b && b.type === 'tool_result' && b.tool_use_id != null) results[b.tool_use_id] = resultText(b.content);
}
}
}
const actions = raw.map((a) => {
const out = { tool: a.tool, input: a.input };
if (a.id != null && results[a.id] != null) out.result = String(results[a.id] ?? '');
return out;
});
return { user, assistant, actions, userIsMeta };
const all = assembleExchanges(transcriptText);
const last = all[all.length - 1];
if (!last) return { user: '', assistant: '', actions: [], userIsMeta: false };
return { user: last.user, assistant: last.assistant, actions: last.actions, userIsMeta: last.userIsMeta };
}
// Сырьё (Слой 1) из готовых обменов: ход = индекс обмена. Каждая запись санируется (PII) перед склейкой.
export function buildRawFromExchanges(exchanges, { session, sanitize = (x) => x } = {}) {
const recs = exchanges.map((ex, i) => sanitize(buildRawRecord({
turn: i + 1, time: ex.time || '', session,
user: ex.user, assistant: ex.assistant, actions: ex.actions,
userIsMeta: ex.userIsMeta, isContinuation: ex.isContinuation, interruptedTail: ex.interruptedTail,
})));
return recs.map((r) => r + '\n').join('');
}
// Полная пересборка сырья из текста транскрипта (источник правды переживает обрывы).
export function rebuildRawFromTranscript(transcriptText, { session, sanitize } = {}) {
return buildRawFromExchanges(assembleExchanges(transcriptText), { session, sanitize });
}
+133 -1
View File
@@ -1,5 +1,137 @@
import { describe, it, expect } from 'vitest';
import { parseLastExchange } from './secretary-transcript.mjs';
import { parseLastExchange, classifyEntry, assembleExchanges, rebuildRawFromTranscript } from './secretary-transcript.mjs';
import { realBoundariesFromRaw } from './secretary-layer1.mjs';
describe('rebuildRawFromTranscript — пересборка сырья (источник = транскрипт)', () => {
it('сбой API + продолжи → 2 хода, ход-продолжение помечен cont=1, граница одна', () => {
const t = [
{ message: { role: 'user', content: 'настоящая просьба' } },
{ message: { role: 'assistant', content: [{ type: 'text', text: 'начал' }] } },
{ isApiErrorMessage: true, type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'API Error: Overloaded' }] } },
{ message: { role: 'user', content: 'продолжи' } },
{ message: { role: 'assistant', content: [{ type: 'text', text: 'докончил' }] } },
].map((e) => JSON.stringify(e)).join('\n');
const raw = rebuildRawFromTranscript(t, { session: 's' });
expect((raw.match(/=== ХОД turn=/g) || []).length).toBe(2);
expect(raw).toMatch(/=== ХОД turn=2[^\n]*cont=1/);
expect(realBoundariesFromRaw(raw)).toEqual([1]); // одна логическая работа, не «продолжи»
});
it('пустой транскрипт → пустое сырьё', () => {
expect(rebuildRawFromTranscript('', { session: 's' })).toBe('');
});
it('sanitize применяется к каждой записи', () => {
const t = JSON.stringify({ message: { role: 'user', content: 'СЕКРЕТ' } });
const raw = rebuildRawFromTranscript(t, { session: 's', sanitize: (x) => x.replace('СЕКРЕТ', '[вырезано]') });
expect(raw).toContain('[вырезано]');
expect(raw).not.toContain('СЕКРЕТ');
});
});
describe('assembleExchanges — обмены из всего транскрипта', () => {
it('два настоящих промпта → два обмена с накопленным ответом и действиями', () => {
const t = [
{ message: { role: 'user', content: 'первый' } },
{ message: { role: 'assistant', content: [{ type: 'text', text: 'ответ1' }, { type: 'tool_use', id: 'a', name: 'Read', input: { f: 'x' } }] } },
{ message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'a', content: 'r' }] } },
{ message: { role: 'user', content: 'второй' } },
{ message: { role: 'assistant', content: [{ type: 'text', text: 'ответ2' }] } },
].map((e) => JSON.stringify(e)).join('\n');
const ex = assembleExchanges(t);
expect(ex.map((x) => x.user)).toEqual(['первый', 'второй']);
expect(ex[0].assistant).toBe('ответ1');
expect(ex[0].actions).toEqual([{ tool: 'Read', input: '{"f":"x"}', result: 'r' }]);
expect(ex[0].isContinuation).toBe(false);
});
it('сбой API + следующий промпт → продолжение (isContinuation), не новый настоящий промпт', () => {
const t = [
{ message: { role: 'user', content: 'настоящая просьба' } },
{ message: { role: 'assistant', content: [{ type: 'text', text: 'начал работу' }] } },
{ isApiErrorMessage: true, type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'API Error: Overloaded' }] } },
{ message: { role: 'user', content: 'продолжи' } },
{ message: { role: 'assistant', content: [{ type: 'text', text: 'докончил' }] } },
].map((e) => JSON.stringify(e)).join('\n');
const ex = assembleExchanges(t);
expect(ex.map((x) => x.user)).toEqual(['настоящая просьба', 'продолжи']);
expect(ex[1].isContinuation).toBe(true); // «продолжи» — продолжение, не новая просьба
expect(ex[ex.length - 1].interruptedTail).toBe(false); // работа доведена → не хвост
});
it('ручной стоп + следующий промпт → продолжение (склеиваем по умолчанию)', () => {
const t = [
{ message: { role: 'user', content: 'просьба' } },
{ message: { role: 'assistant', content: [{ type: 'text', text: 'работаю' }] } },
{ message: { role: 'user', content: [{ type: 'text', text: '[Request interrupted by user]' }] } },
{ message: { role: 'user', content: 'дальше давай' } },
{ message: { role: 'assistant', content: [{ type: 'text', text: 'готово' }] } },
].map((e) => JSON.stringify(e)).join('\n');
const ex = assembleExchanges(t);
expect(ex.map((x) => x.user)).toEqual(['просьба', 'дальше давай']);
expect(ex[1].isContinuation).toBe(true);
});
it('прерван и НЕ продолжен (хвост в конце) → interruptedTail на последнем обмене', () => {
const t = [
{ message: { role: 'user', content: 'большая задача' } },
{ message: { role: 'assistant', content: [{ type: 'text', text: 'делаю часть' }] } },
{ message: { role: 'user', content: [{ type: 'text', text: '[Request interrupted by user]' }] } },
].map((e) => JSON.stringify(e)).join('\n');
const ex = assembleExchanges(t);
expect(ex).toHaveLength(1);
expect(ex[0].user).toBe('большая задача');
expect(ex[0].interruptedTail).toBe(true);
});
it('выжимка сжатия не становится обменом', () => {
const t = [
{ message: { role: 'user', content: 'реальный' } },
{ message: { role: 'assistant', content: [{ type: 'text', text: 'ок' }] } },
{ isCompactSummary: true, isVisibleInTranscriptOnly: true, message: { role: 'user', content: 'СЖАТАЯ ВЫЖИМКА' } },
{ message: { role: 'user', content: 'после сжатия' } },
{ message: { role: 'assistant', content: [{ type: 'text', text: 'дальше' }] } },
].map((e) => JSON.stringify(e)).join('\n');
const ex = assembleExchanges(t);
expect(ex.map((x) => x.user)).toEqual(['реальный', 'после сжатия']);
expect(ex.some((x) => x.user.includes('ВЫЖИМКА'))).toBe(false);
});
it('служебный ход (meta) — отдельный обмен с userIsMeta', () => {
const t = [
{ message: { role: 'user', content: 'настоящий' } },
{ message: { role: 'assistant', content: [{ type: 'text', text: 'a' }] } },
{ isMeta: true, message: { role: 'user', content: 'Stop hook feedback: x' } },
{ message: { role: 'assistant', content: [{ type: 'text', text: 'b' }] } },
].map((e) => JSON.stringify(e)).join('\n');
const ex = assembleExchanges(t);
expect(ex).toHaveLength(2);
expect(ex[1].userIsMeta).toBe(true);
expect(ex[1].isContinuation).toBe(false);
});
});
describe('classifyEntry — вид записи транскрипта', () => {
it('настоящий промпт владельца → real', () => {
expect(classifyEntry({ message: { role: 'user', content: 'сделай X' } })).toBe('real');
});
it('служебный ход (isMeta) → meta', () => {
expect(classifyEntry({ isMeta: true, message: { role: 'user', content: 'Stop hook feedback' } })).toBe('meta');
});
it('сбой API (isApiErrorMessage) → interrupt-api', () => {
expect(classifyEntry({ isApiErrorMessage: true, type: 'assistant',
message: { role: 'assistant', content: [{ type: 'text', text: 'API Error: Overloaded' }] } })).toBe('interrupt-api');
});
it('ручной стоп (обе формы) → interrupt-stop', () => {
expect(classifyEntry({ message: { role: 'user', content: [{ type: 'text', text: '[Request interrupted by user]' }] } })).toBe('interrupt-stop');
expect(classifyEntry({ message: { role: 'user', content: [{ type: 'text', text: '[Request interrupted by user for tool use]' }] } })).toBe('interrupt-stop');
});
it('выжимка сжатия (isCompactSummary) → summary', () => {
expect(classifyEntry({ isCompactSummary: true, message: { role: 'user', content: 'итог...' } })).toBe('summary');
});
it('ответ ассистента → assistant; tool_result → tool_result', () => {
expect(classifyEntry({ message: { role: 'assistant', content: [{ type: 'text', text: 'ок' }] } })).toBe('assistant');
expect(classifyEntry({ message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'x', content: 'r' }] } })).toBe('tool_result');
});
});
describe('parseLastExchange', () => {
it('тащит последний user + assistant + действия', () => {