c778d10d10
Task 5/5 плана. Ветка off prompt-hook звала buildStepsFromRaw, перезатирая модельные формулировки шагов детерминированными. Новая mergeStepsPreservingText: существующий шаг сохраняется, из сырья достраиваются только пропущенные ходы. Свод секретаря 112/112. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
112 lines
7.7 KiB
JavaScript
112 lines
7.7 KiB
JavaScript
// Обезвреживание маркеров внутри полезного текста: если в реплике/действии встретились те же
|
|
// строки-разделители (цитата хода, тест-фикстура), ломаем их, чтобы счётчик ходов и нарезка
|
|
// не считали их за настоящие границы (самозагрязнение лога при чтении/цитировании самого лога).
|
|
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 }) };
|
|
});
|
|
}
|
|
|
|
// Слияние «Шагов» при выключении: на КАЖДЫЙ ход из сырья берём существующий шаг (модельная
|
|
// формулировка) если он есть, иначе достраиваем детерминированно из сырья. Порядок — по сырью
|
|
// (хронология); модельный текст переживает выключение/нарезку.
|
|
export function mergeStepsPreservingText(existingSteps, rawText, session) {
|
|
const have = new Map((Array.isArray(existingSteps) ? existingSteps : []).map((s) => [s.turn, s]));
|
|
return buildStepsFromRaw(rawText, session).map((r) => (have.has(r.turn) ? have.get(r.turn) : r));
|
|
}
|
|
|
|
// Человекочитаемая строка шага для раздела «Шаги (Слой 1)»: «Ход N — я: … · ты: … · делал: …».
|
|
// Суть — первая фраза реплики; служебные строки (экономия/coverage/вердикт) отброшены;
|
|
// «делал» — имена инструментов из действий хода. Название файла полного хода добавляет рендер.
|
|
export function buildStepLine({ turn, user, assistant, actions = [], essence = null } = {}) {
|
|
// Содержательная фраза: убираем ведущую нумерацию списка («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(', ') || '—';
|
|
return `Ход ${turn} — я: ${u} · ты: ${a} · делал: ${did}`;
|
|
}
|
|
|
|
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);
|
|
}
|