Files
brain/tools/secretary-transcript.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

147 lines
7.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Чистый разбор хвоста стенограммы: последний обмен (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 = [];
for (const line of String(text || '').split(/\r?\n/)) {
const t = line.trim();
if (!t) continue;
try { entries.push(JSON.parse(t)); } catch { /* битую строку пропускаем */ }
}
return entries;
}
// Настоящий промпт пользователя (НЕ tool_result): content — строка или массив с text-блоком.
// В формате Anthropic tool_result — это сообщения role:user, их пропускаем, иначе теряются
// и настоящий промпт, и все действия ассистента до него.
function isRealUserPrompt(msg) {
if (!msg || msg.role !== 'user') return false;
const c = msg.content;
if (typeof c === 'string') return true;
if (Array.isArray(c)) return c.some((b) => b && b.type === 'text');
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) {
if (typeof content === 'string') return content;
if (Array.isArray(content)) {
return content.filter((b) => b && b.type === 'text' && typeof b.text === 'string')
.map((b) => b.text).join('\n');
}
return '';
}
/** Последний обмен из стенограммы: { user, assistant, actions:[{tool,input,result?}], userIsMeta }.
* Тонкая обёртка над assembleExchanges (источник правды о видах записи и метках) — без дубля логики. */
export function parseLastExchange(transcriptText) {
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 });
}