// Обезвреживание маркеров внутри полезного текста: если в реплике/действии встретились те же // строки-разделители (цитата хода, тест-фикстура), ломаем их, чтобы счётчик ходов и нарезка // не считали их за настоящие границы (самозагрязнение лога при чтении/цитировании самого лога). function neutralizeMarkers(s) { return String(s ?? '') .replace(/=== ХОД turn=/g, '=≡ ХОД turn=') .replace(/=== КОНЕЦ ХОДА ===/g, '=≡ КОНЕЦ ХОДА ≡='); } // Чистый билдер сырой записи Слоя 1 (§L1). PII вырезается вызывающим хуком до записи; // чтение источника (transcript_path) — в хук-обёртке. Здесь — только формат. export function buildRawRecord({ turn, time, session, user, assistant, actions = [] } = {}) { const acts = Array.isArray(actions) ? actions : []; const lines = [`=== ХОД turn=${turn} · ${time} · session=${session} ===`, '[ЮЗЕР]', 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 }; } // Пересборка Шагов из общего сырья: по строке на КАЖДЫЙ ход (хук пишет Шаг только во вкл-ходы, // поэтому на остановке собираем все ходы из Слоя 1 — чтобы в Шагах не было пропусков). export function buildStepsFromRaw(rawText, session) { return splitRawIntoTurns(rawText).map(({ turn, block }) => { const um = block.match(/\[ЮЗЕР\]\n([\s\S]*?)\n\[АССИСТЕНТ\]/); const am = block.match(/\[АССИСТЕНТ\]\n([\s\S]*?)(?:\n\[ДЕЙСТВИЕ\]|\n=== КОНЕЦ ХОДА ===|$)/); const actions = [...block.matchAll(/\[ДЕЙСТВИЕ\]\s+(\S+)/g)].map((x) => x[1]); return { turn, session, text: buildStepLine({ turn, user: um ? um[1] : '', assistant: am ? am[1] : '', actions }) }; }); } // Человекочитаемая строка шага для раздела «Шаги (Слой 1)»: «Ход N — я: … · ты: … · делал: …». // Суть — первая фраза реплики; служебные строки (экономия/coverage/вердикт) отброшены; // «делал» — имена инструментов из действий хода. Название файла полного хода добавляет рендер. export function buildStepLine({ turn, user, assistant, actions = [] } = {}) { // Содержательная фраза: убираем ведущую нумерацию списка («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 u = sysLabel(user) || firstSentence(user) || '(без вопроса)'; const a = firstSentence(cleanA) || '(без ответа)'; const did = [...new Set((actions || []).map((t) => String(t).trim()).filter(Boolean))].join(', ') || '—'; return `Ход ${turn} — я: ${u} · ты: ${a} · делал: ${did}`; }