Files
brain/tools/secretary-span.mjs
T
Дмитрий ceda265a5d fix(secretary): границы спанов из сырья по ярлычку isMeta (корень бага со сдвигом)
Баг: границы спанов метились предсказанным номером хода (turnCount+1 в prompt-hook),
который уезжает под гейт-петлёй (coverage-хук вставляет служебные ходы, Claude Code
очередит промпт). Итог — служебный ход принимался за реальную просьбу (фантомный
«Ход 5» в тетради + ложные скрытые вопросы про coverage).

Корень: терялся структурный ярлычок isMeta (служебное vs владелец), который уже есть
в транскрипте. Теперь:
- parseLastExchange читает entry.isMeta -> userIsMeta;
- buildRawRecord пишет метку meta=1 в заголовок служебного хода;
- realBoundariesFromRaw определяет границы СТРУКТУРНО (meta=1; фолбэк по тексту) —
  это ОСНОВНОЙ источник; ненадёжный realPromptTurns/prompt-hook-механизм убран;
- разбор одного спана вынесен в общий distillSpan (stop-хук и пересборка из сырья).

Свод секретаря зелёный (143 теста). Живая пересборка дела на реальной модели дала
чистую тетрадь: Шаги по реальным промптам, гейт-шум не плодит скрытые вопросы.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 18:02:52 +03:00

57 lines
3.1 KiB
JavaScript

// Чистая спан-логика секретаря: границы спанов (реальные промпты владельца) → отрезки ходов.
// Без I/O. Нумерация — номера ходов сырья (raw/<session>.log).
import { splitRawIntoTurns } from './secretary-layer1.mjs';
/** Нормализованный, отсортированный список уникальных границ. */
function norm(realPromptTurns) {
return [...new Set((realPromptTurns || []).map(Number).filter((n) => Number.isFinite(n)))]
.sort((a, b) => a - b);
}
/** Отрезки спанов: [b_i .. b_{i+1}-1]; последний — до lastTurn, open:true. */
export function computeSpans(realPromptTurns, lastTurn) {
const b = norm(realPromptTurns);
const out = [];
for (let i = 0; i < b.length; i++) {
const start = b[i];
const isLast = i === b.length - 1;
const end = isLast ? lastTurn : b[i + 1] - 1;
out.push({ start, end, open: isLast });
}
return out;
}
/** Закрытые спаны с индексом строго больше курсора — их надо разобрать сейчас.
* Курсор «ничего не разобрано» = -1. */
export function spansToDistill(realPromptTurns, lastTurn, spanCursor) {
const cur = Number.isFinite(spanCursor) ? spanCursor : -1;
return computeSpans(realPromptTurns, lastTurn)
.map((s, index) => ({ ...s, index }))
.filter((s) => !s.open && s.index > cur)
.map(({ start, end, index }) => ({ start, end, index }));
}
/** Разбор одного блока хода сырья → {turn,user,assistant,actions}. Полное содержимое.
* Формат (buildRawRecord): [ЮЗЕР]\n…\n[АССИСТЕНТ]\n…\n([ДЕЙСТВИЕ] tool in=…\n[ВЫДАЧА] tool\n…)* */
export function parseTurnBlock(block) {
const s = String(block || '');
const turn = Number((s.match(/=== ХОД turn=(\d+)/) || [])[1]) || 0;
const um = s.match(/\[ЮЗЕР\]\n([\s\S]*?)\n\[АССИСТЕНТ\]\n/);
const am = s.match(/\[АССИСТЕНТ\]\n([\s\S]*?)(?:\n\[ДЕЙСТВИЕ\] |\n=== КОНЕЦ ХОДА ===)/);
const actions = [];
const re = /\[ДЕЙСТВИЕ\] (\S+) in=([\s\S]*?)\n\[ВЫДАЧА\] \S+\n([\s\S]*?)(?=\n\[ДЕЙСТВИЕ\] |\n=== КОНЕЦ ХОДА ===)/g;
let m;
while ((m = re.exec(s)) !== null) actions.push({ tool: m[1], input: m[2], result: m[3] });
return { turn, user: um ? um[1] : '', assistant: am ? am[1] : '', actions };
}
/** Склейка обмена спана из сырья: user из хода-начала; assistant и actions — со всех ходов [start..end]. */
export function assembleSpan(rawText, { start, end }) {
const blocks = splitRawIntoTurns(rawText).filter((p) => p.turn >= start && p.turn <= end);
const parsed = blocks.map((p) => parseTurnBlock(p.block));
const startTurn = parsed.find((p) => p.turn === start) || parsed[0] || {};
const assistant = parsed.map((p) => p.assistant).filter(Boolean).join('\n');
const actions = parsed.flatMap((p) => p.actions);
return { user: startTurn.user || '', assistant, actions };
}