Files
brain/tools/secretary-layer1.mjs
T
Дмитрий 90f1360065 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>
2026-06-24 04:19:16 +03:00

159 lines
11 KiB
JavaScript

import { computeSpans } from './secretary-span.mjs';
// Обезвреживание маркеров внутри полезного текста: если в реплике/действии встретились те же
// строки-разделители (цитата хода, тест-фикстура), ломаем их, чтобы счётчик ходов и нарезка
// не считали их за настоящие границы (самозагрязнение лога при чтении/цитировании самого лога).
function neutralizeMarkers(s) {
return String(s ?? '')
.replace(/=== ХОД turn=/g, '=≡ ХОД turn=')
.replace(/=== КОНЕЦ ХОДА ===/g, '=≡ КОНЕЦ ХОДА ≡=')
// структурные метки блока: ломаем только в начале строки (реальные ставит buildRawRecord),
// чтобы полный вывод действия не подделал границы при обратном разборе сырья.
// Пробел перед «]» → «[ЮЗЕР ]» уже не совпадёт с разбором «^[ЮЗЕР]».
.replace(/^\[(ЮЗЕР|АССИСТЕНТ|ДЕЙСТВИЕ|ВЫДАЧА)\]/gm, '[$1 ]');
}
// Чистый билдер сырой записи Слоя 1 (§L1). PII вырезается вызывающим хуком до записи;
// чтение источника (transcript_path) — в хук-обёртке. Здесь — только формат.
export function buildRawRecord({ turn, time, session, user, assistant, actions = [], userIsMeta = false, isContinuation = false, interruptedTail = false } = {}) {
const acts = Array.isArray(actions) ? actions : [];
// Структурные ярлычки хода в заголовке: 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 ?? '')}`);
lines.push(`[ВЫДАЧА] ${a.tool}`, neutralizeMarkers(a.result ?? ''));
}
lines.push('=== КОНЕЦ ХОДА ===', '');
return lines.join('\n');
}
// Разрезка общего сырого лога на блоки по ходам — для нарезки в отдельные файлы при остановке
// секретаря (поднять один ход = открыть один маленький файл, а не парсить весь лог).
export function splitRawIntoTurns(rawText) {
const re = /=== ХОД turn=(\d+)[^\n]*===[\s\S]*?=== КОНЕЦ ХОДА ===/g;
const out = [];
let m;
while ((m = re.exec(String(rawText || ''))) !== null) out.push({ turn: Number(m[1]), block: m[0] });
return out;
}
// Имя файла отдельного хода и ссылка для раздела «Шаги» (папка «ходы» рядом с протоколом дела).
export function turnFileName(turn) { return `turn-${turn}.log`; }
export function turnFileRef(turn) { return `ходы/${turnFileName(turn)}`; }
// Из общего сырья + протокола: список файлов-ходов {name, content} и шаги с проставленной
// ссылкой file=«ходы/turn-N.log». Шаг без блока в сырье остаётся без ссылки (не выдумываем).
export function prepareTurnFiles(rawText, protocol = {}) {
const parts = splitRawIntoTurns(rawText);
const files = parts.map((p) => ({ name: turnFileName(p.turn), content: p.block }));
const known = new Set(parts.map((p) => p.turn));
const steps = (protocol.steps || []).map((s) =>
known.has(s.turn) ? { ...s, file: turnFileRef(s.turn) } : s);
return { files, steps };
}
// Реальные границы спанов из сырья — ОСНОВНОЙ источник правды о «где настоящая просьба владельца».
// Структурно: ход служебный, если в заголовке метка meta=1 (ярлычок isMeta, пишет buildRawRecord).
// Фолбэк для СТАРОГО сырья без метки — по тексту [ЮЗЕР] (sysLabel-шаблоны).
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) {
const parts = splitRawIntoTurns(rawText);
if (!parts.length) return [];
const lastTurn = parts[parts.length - 1].turn;
const bounds = (Array.isArray(realPromptTurns) && realPromptTurns.length)
? realPromptTurns : realBoundariesFromRaw(rawText);
const spans = computeSpans(bounds, lastTurn);
const byTurn = new Map(parts.map((p) => [p.turn, p.block]));
return spans.map(({ start, end }) => {
const blocks = [];
for (let t = start; t <= end; t++) if (byTurn.has(t)) blocks.push(byTurn.get(t));
const startBlock = byTurn.get(start) || blocks[0] || '';
const um = startBlock.match(/\[ЮЗЕР\]\n([\s\S]*?)\n\[АССИСТЕНТ\]/);
const aAll = blocks.map((b) => (b.match(/\[АССИСТЕНТ\]\n([\s\S]*?)(?:\n\[ДЕЙСТВИЕ\]|\n=== КОНЕЦ ХОДА ===|$)/) || [, ''])[1])
.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,
note: spanInterruptNote(rawText, { start, end }) }) };
});
}
// Слияние «Шагов» при выключении: на КАЖДЫЙ спан берём существующий (модельный) шаг по turn-начала,
// иначе достраиваем детерминированно. Порядок — по сырью.
export function mergeStepsPreservingText(existingSteps, rawText, session, realPromptTurns = null) {
const have = new Map((Array.isArray(existingSteps) ? existingSteps : []).map((s) => [s.turn, s]));
return buildStepsFromRaw(rawText, session, realPromptTurns).map((r) => (have.has(r.turn) ? have.get(r.turn) : r));
}
// Человекочитаемая строка шага для раздела «Шаги (Слой 1)»: «Ход N — я: … · ты: … · делал: …».
// Суть — первая фраза реплики; служебные строки (экономия/coverage/вердикт) отброшены;
// «делал» — имена инструментов из действий хода. Название файла полного хода добавляет рендер.
export function buildStepLine({ turn, endTurn = null, user, assistant, actions = [], essence = null, note = '' } = {}) {
// Содержательная фраза: убираем ведущую нумерацию списка («1.»/«2)»), копим до ≥25 симв.,
// чтобы не выдать обрывок «Стоп.»; длинное усекаем.
const firstSentence = (s) => {
const t = String(s ?? '').replace(/\s+/g, ' ').trim().replace(/^\d+[.)]\s*/, '');
let out = '';
for (const p of t.split(/(?<=[.!?…])\s+/)) { out = out ? `${out} ${p}` : p; if (out.length >= 25) break; }
return out.length > 130 ? `${out.slice(0, 130)}` : out;
};
// Служебный ход (фидбек гейта / загрузка навыка) — короткая метка вместо шума.
const sysLabel = (s) => {
const t = String(s ?? '').trim();
if (/^Stop hook feedback/i.test(t)) return '(гейт проверки)';
if (/^Base directory for this skill/i.test(t)) {
const sm = t.match(/skills[\\/]([a-zA-Z0-9-]+)/);
return `(навык: ${sm ? sm[1] : '—'})`;
}
return null;
};
const cleanA = String(assistant ?? '').split('\n')
.filter((l) => !/^\s*(экономия:|coverage:|вердикт:)/i.test(l)).join(' ');
const clean1 = (s) => String(s ?? '').replace(/\s+/g, ' ').trim();
const eU = essence && clean1(essence.user);
const eA = essence && clean1(essence.assistant);
const u = eU || sysLabel(user) || firstSentence(user) || '(без вопроса)';
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}]` : '';
const tail = note ? ` · ${note}` : '';
return `Ход (промпт) ${turn}${span} — я: ${u} · ты: ${a} · делал: ${did}${tail}`;
}
import { writeFileSync as _writeFileSync, renameSync as _renameSync } from 'node:fs';
// Атомарная запись: пишем во временный файл рядом с целью, затем переименовываем (rename атомарен
// в пределах ФС). Параллельная сессия не увидит полузаписанный protocol.json/содержание.md.
// fs инъектируется ради теста; по умолчанию — реальные writeFileSync/renameSync.
export function writeFileAtomic(path, content, fs = { writeFileSync: _writeFileSync, renameSync: _renameSync }) {
const tmp = `${path}.tmp-${process.pid}`;
fs.writeFileSync(tmp, content, 'utf-8');
fs.renameSync(tmp, path);
}