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); }