90f1360065
Секретарь перестал терять промпт владельца при обрыве (сбой 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>
147 lines
7.9 KiB
JavaScript
147 lines
7.9 KiB
JavaScript
// Чистый разбор хвоста стенограммы: последний обмен (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 });
|
||
}
|