// Чистый разбор хвоста стенограммы: последний обмен (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 }); }