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>
This commit is contained in:
Дмитрий
2026-06-23 18:02:52 +03:00
parent 2b6170313b
commit ceda265a5d
11 changed files with 159 additions and 120 deletions
+43
View File
@@ -0,0 +1,43 @@
// Разбор ОДНОГО завершённого спана: reconcile (категории + суть шага) → шаг → аудит (скрытые
// вопросы). Общий код для stop-хука (живой разбор) и пересборки дела из сырья (одинаковое качество).
// Чистая оркестрация над reconcile/audit/layer1; единственный побочный эффект — вызовы callModel.
import { reconcileTurn, mergeTurnIntoProtocol, collapseProtocol } from './secretary-reconcile.mjs';
import { buildAuditPrompt, parseAuditResponse, applyAudit, preserveRegistry } from './secretary-audit.mjs';
import { buildStepLine } from './secretary-layer1.mjs';
/** Разобрать спан и вернуть НОВЫЙ протокол (вход не мутируется reconcile-веткой; collapse — копия).
* proto — текущий протокол; spanEx — склеенный обмен спана {user,assistant,actions};
* {start,end} — границы спана (в ходах сырья); opts: { callModel|null, session, diag }.
* Без callModel (нет ключа) — пишется только детерминированный шаг, категории/СВ не трогаются. */
export async function distillSpan(proto, spanEx, { start, end }, { callModel, session, diag } = {}) {
// Снимок реестра СВ ДО reconcile (reconcile перенумеровывает hidden) — вернём после merge.
const svSnapshot = JSON.parse(JSON.stringify({
hidden: proto.hidden || [], acceptance: proto.acceptance || [],
tails: proto.tails || [], nextSvId: proto.nextSvId || 1,
}));
let updated = null;
if (callModel) {
updated = await reconcileTurn({ proto, ex: spanEx, turn: start, session, callModel, diag });
}
const modelStep = (updated && updated.step) || null;
if (updated && 'step' in updated) delete updated.step;
const step = { turn: start, session,
text: buildStepLine({ turn: start, endTurn: end, user: spanEx.user, assistant: spanEx.assistant,
actions: (spanEx.actions || []).map((a) => a.tool), essence: modelStep }) };
const toWrite = mergeTurnIntoProtocol({ proto, updated, step });
// Реестр СВ — вотчина аудитора: вернуть из снимка ДО reconcile.
preserveRegistry(toWrite, svSnapshot);
if (callModel) {
try {
const auditMsgs = buildAuditPrompt(toWrite, spanEx);
const raw = await callModel(auditMsgs);
applyAudit(toWrite, parseAuditResponse(typeof raw === 'string' ? raw : (raw?.text || '')), start);
} catch (e) { if (typeof diag === 'function') diag({ turn: start, reason: 'audit-fail', error: e && e.message }); }
}
return collapseProtocol(toWrite);
}
+26
View File
@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest';
import { distillSpan } from './secretary-distill.mjs';
import { EMPTY_PROTOCOL } from './secretary-protocol.mjs';
describe('distillSpan — разбор одного завершённого спана (reconcile + аудит)', () => {
it('добавляет шаг спана, применяет reconcile и аудит', async () => {
const proto = EMPTY_PROTOCOL();
const spanEx = { user: 'реши А или Б', assistant: 'беру А', actions: [{ tool: 'Read', input: 'x', result: 'y' }] };
let call = 0;
const callModel = async () => {
call++;
if (call === 1) return JSON.stringify({ subject: 'дело', decisions: [{ text: 'A', struck: false }], alternatives: [], consequences: [], will: [], open: [], doneNext: [], step: { user: 'реши', assistant: 'взял A' } });
return JSON.stringify({ new: [{ text: 'почему A?', lens: 'Л2' }], ops: [], resolved: [] });
};
const out = await distillSpan(proto, spanEx, { start: 3, end: 4 }, { callModel, session: 's' });
expect(out.steps[0].text).toContain('Ход (промпт) 3 [вобрал ходы 3-4]');
expect(out.decisions.map((d) => d.text)).toContain('A');
expect(out.hidden.map((h) => h.text)).toContain('почему A?');
});
it('без модели — только детерминированный шаг, категории пусты', async () => {
const out = await distillSpan(EMPTY_PROTOCOL(), { user: 'вопрос достаточно длинный', assistant: 'ответ', actions: [] },
{ start: 5, end: 5 }, { callModel: null, session: 's' });
expect(out.steps[0].text).toContain('Ход (промпт) 5');
expect(out.decisions).toEqual([]);
});
});
+11 -5
View File
@@ -15,9 +15,12 @@ function neutralizeMarkers(s) {
// Чистый билдер сырой записи Слоя 1 (§L1). PII вырезается вызывающим хуком до записи;
// чтение источника (transcript_path) — в хук-обёртке. Здесь — только формат.
export function buildRawRecord({ turn, time, session, user, assistant, actions = [] } = {}) {
export function buildRawRecord({ turn, time, session, user, assistant, actions = [], userIsMeta = false } = {}) {
const acts = Array.isArray(actions) ? actions : [];
const lines = [`=== ХОД turn=${turn} · ${time} · session=${session} ===`,
// Структурная метка служебного хода (гейт-фидбек/навык/контекст) прямо в заголовке — чтобы
// границы спанов определялись честно по ярлычку isMeta, а не угадывались по тексту/номеру.
const meta = userIsMeta ? ' · meta=1' : '';
const lines = [`=== ХОД turn=${turn} · ${time} · session=${session}${meta} ===`,
'[ЮЗЕР]', neutralizeMarkers(user), '[АССИСТЕНТ]', neutralizeMarkers(assistant)];
for (const a of acts) {
lines.push(`[ДЕЙСТВИЕ] ${a.tool} in=${neutralizeMarkers(a.input ?? '')}`);
@@ -52,13 +55,16 @@ export function prepareTurnFiles(rawText, protocol = {}) {
return { files, steps };
}
// Реальные границы по фолбэку: ход реальный, если его [ЮЗЕР] не совпал с sysLabel-шаблонами.
// Экспортируется: stop-хук берёт её как запасной детект, если flag.realPromptTurns пуст.
// Реальные границы спанов из сырья — ОСНОВНОЙ источник правды о «где настоящая просьба владельца».
// Структурно: ход служебный, если в заголовке метка 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; // структурно служебный
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);
return !/^Stop hook feedback/i.test(u) && !/^Base directory for this skill/i.test(u); // фолбэк по тексту
}).map((p) => p.turn);
}
+27 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { buildRawRecord, buildStepLine, splitRawIntoTurns, turnFileName, prepareTurnFiles, buildStepsFromRaw, writeFileAtomic, mergeStepsPreservingText } from './secretary-layer1.mjs';
import { buildRawRecord, buildStepLine, splitRawIntoTurns, turnFileName, prepareTurnFiles, buildStepsFromRaw, writeFileAtomic, mergeStepsPreservingText, realBoundariesFromRaw } from './secretary-layer1.mjs';
describe('обезвреживание маркеров на записи (от самозагрязнения лога)', () => {
it('маркеры внутри текста реплик/действий не дают лишних структурных совпадений', () => {
@@ -26,6 +26,32 @@ describe('обезвреживание маркеров на записи (от
});
});
describe('метка служебного хода (meta=1) + структурные границы', () => {
it('buildRawRecord помечает служебный ход meta=1 в заголовке', () => {
const rec = buildRawRecord({ turn: 5, time: 't', session: 's', user: 'Stop hook feedback', assistant: 'a', userIsMeta: true });
expect(rec).toMatch(/=== ХОД turn=5[^\n]*meta=1[^\n]*===/);
});
it('обычный ход — без meta=1', () => {
const rec = buildRawRecord({ turn: 6, time: 't', session: 's', user: 'привет', assistant: 'a' });
expect(rec).not.toContain('meta=1');
});
it('realBoundariesFromRaw: служебные по meta=1 исключены (структурно, не по тексту)', () => {
const raw = [
buildRawRecord({ turn: 7, time: 't', session: 's', user: 'настоящий 1', assistant: 'a' }),
buildRawRecord({ turn: 8, time: 't', session: 's', user: 'любой текст', assistant: 'a', userIsMeta: true }),
buildRawRecord({ turn: 9, time: 't', session: 's', user: 'настоящий 2', assistant: 'a' }),
].join('');
expect(realBoundariesFromRaw(raw)).toEqual([7, 9]);
});
it('realBoundariesFromRaw: фолбэк по тексту для старого сырья без меток', () => {
const raw = [
'=== ХОД turn=7 · t · session=s ===', '[ЮЗЕР]', 'настоящий', '[АССИСТЕНТ]', 'a', '=== КОНЕЦ ХОДА ===', '',
'=== ХОД turn=8 · t · session=s ===', '[ЮЗЕР]', 'Stop hook feedback: x', '[АССИСТЕНТ]', 'a', '=== КОНЕЦ ХОДА ===', '',
].join('\n');
expect(realBoundariesFromRaw(raw)).toEqual([7]);
});
});
describe('buildStepsFromRaw — Шаг на КАЖДЫЙ спан (пересборка на остановке)', () => {
const raw = [
'=== ХОД turn=3 · t · session=s ===', '[ЮЗЕР]', 'настоящий вопрос достаточно длинный', '[АССИСТЕНТ]', 'ответ раз', '[ДЕЙСТВИЕ] Read in=x', '[ВЫДАЧА] Read', 'r', '=== КОНЕЦ ХОДА ===', '',
+10 -24
View File
@@ -1,12 +1,12 @@
#!/usr/bin/env node
// UserPromptSubmit-переходник секретаря: ловит «включи/выключи секретаря» И метит границы спанов.
// Реальный промпт владельца = срабатывание этого хука (служебные впрыски его не вызывают), поэтому
// здесь — авторитетный детект границы спана. Тяжёлый разбор/нарезка — в Stop-хуке (таймаут 15 мин).
// UserPromptSubmit-переходник секретаря: ловит «включи/выключи секретаря».
// Границы спанов больше НЕ метятся здесь (предсказание номера ненадёжно под гейт-петлёй) — их
// определяет stop-хук из сырья структурно (ярлычок meta=1). Здесь только вкл/выкл: «выключи»
// переводит в mode:'closing' (финальный спан добивает ближайший Stop, у него таймаут 15 мин).
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { homedir } from 'node:os';
import { detectSecretaryCommand, secretaryModeFileName, resolveCaseActivation } from './secretary-flag.mjs';
import { recordRealPrompt } from './secretary-span.mjs';
function readStdin() { try { return readFileSync(0, 'utf-8'); } catch { return ''; } }
function turnCount(rawFile) {
@@ -36,17 +36,10 @@ export function planActivation({ requested, existing = [], startedAtTurn = 0, se
return { confirm: false, flag: { mode: 'on', startedAtTurn, work: res.work, session } };
}
// Решение хука на обычный промпт / выключение по отношению к границам спанов.
// cmd: 'on'|'off'|null; flag — текущий флажок; turnCount — число ходов в сырье.
// Возврат { flag: <новый флажок для записи> | null }.
export function planPromptTurn({ cmd, flag, turnCount: tc }) {
if (cmd === 'off') {
// НЕ гасим сразу: финальный открытый спан разберёт ближайший Stop (у него таймаут 15 мин).
return { flag: { ...(flag || {}), mode: 'closing' } };
}
if (cmd == null && flag && flag.mode === 'on') {
return { flag: recordRealPrompt(flag, tc + 1) };
}
// Решение хука по команде секретаря. cmd: 'off' → перевести флажок в closing (с сохранением полей);
// иначе ничего (границы спанов определяет stop-хук из сырья). Возврат { flag | null }.
export function planPromptTurn({ cmd, flag } = {}) {
if (cmd === 'off') return { flag: { ...(flag || {}), mode: 'closing' } };
return { flag: null };
}
@@ -57,20 +50,13 @@ function main() {
const session = ev.session_id || ev.sessionId || 'unknown';
const FLAG = join(homedir(), '.claude', 'runtime', secretaryModeFileName(session));
const cmd = detectSecretaryCommand(prompt);
if (!cmd) { process.exit(0); }
const secdir = join(process.cwd(), 'docs', 'secretary');
const rawFile = join(secdir, 'raw', `${session}.log`);
try { mkdirSync(dirname(FLAG), { recursive: true }); } catch { /* ignore */ }
const readFlag = () => { try { return JSON.parse(readFileSync(FLAG, 'utf-8')); } catch { return {}; } };
if (!cmd) {
// Обычный промпт: при включённом секретаре метим границу спана (реальный промпт владельца).
const r = planPromptTurn({ cmd: null, flag: readFlag(), turnCount: turnCount(rawFile) });
if (r.flag) { try { writeFileSync(FLAG, JSON.stringify(r.flag)); } catch { /* ignore */ } }
process.exit(0);
}
if (cmd === 'on') {
const m = prompt.match(/секретар[а-я]*\s+(?:для\s+|по\s+)?([a-zA-Zа-яёА-ЯЁ0-9-]{2,})/);
const requested = (m && m[1]) || 'general';
@@ -86,7 +72,7 @@ function main() {
try { writeFileSync(FLAG, JSON.stringify(plan.flag)); } catch { /* ignore */ }
} else if (cmd === 'off') {
// Только метим mode:closing. Финальный спан разберёт + нарежет сырьё + погасит флажок Stop-хук.
const r = planPromptTurn({ cmd: 'off', flag: readFlag(), turnCount: turnCount(rawFile) });
const r = planPromptTurn({ cmd: 'off', flag: readFlag() });
try { writeFileSync(FLAG, JSON.stringify(r.flag)); } catch { /* ignore */ }
}
process.exit(0);
+6 -11
View File
@@ -1,20 +1,15 @@
import { describe, it, expect } from 'vitest';
import { planActivation, planPromptTurn } from './secretary-prompt-hook.mjs';
describe('planPromptTurn — обычный промпт при включённом секретаре метит границу спана', () => {
it('cmd=null, секретарь on → дописать границу (turnCount+1)', () => {
const r = planPromptTurn({ cmd: null, flag: { mode: 'on', work: 'x', realPromptTurns: [3] }, turnCount: 11 });
expect(r.flag.realPromptTurns).toEqual([3, 12]);
});
it('cmd=null, секретарь off → ничего', () => {
const r = planPromptTurn({ cmd: null, flag: { mode: 'off' }, turnCount: 5 });
expect(r.flag).toBeNull();
});
describe('planPromptTurn — вкл/выкл (границы спанов определяет stop-хук из сырья)', () => {
it('cmd=off → флажок mode:closing с сохранением полей', () => {
const r = planPromptTurn({ cmd: 'off', flag: { mode: 'on', work: 'дело', realPromptTurns: [3, 12], spanCursor: 0, session: 's' }, turnCount: 20 });
const r = planPromptTurn({ cmd: 'off', flag: { mode: 'on', work: 'дело', spanCursor: 0, session: 's' } });
expect(r.flag.mode).toBe('closing');
expect(r.flag.work).toBe('дело');
expect(r.flag.realPromptTurns).toEqual([3, 12]);
expect(r.flag.spanCursor).toBe(0);
});
it('обычный промпт (cmd=null) → ничего не пишем', () => {
expect(planPromptTurn({ cmd: null, flag: { mode: 'on' } }).flag).toBeNull();
});
});
-8
View File
@@ -31,14 +31,6 @@ export function spansToDistill(realPromptTurns, lastTurn, spanCursor) {
.map(({ start, end, index }) => ({ start, end, index }));
}
/** Добавить границу спана в флажок (идемпотентно, сортировка). Вход не мутируется. */
export function recordRealPrompt(flag, turn) {
const prev = Array.isArray(flag && flag.realPromptTurns) ? flag.realPromptTurns : [];
const set = new Set(prev);
set.add(Number(turn));
return { ...flag, realPromptTurns: [...set].sort((a, b) => a - b) };
}
/** Разбор одного блока хода сырья → {turn,user,assistant,actions}. Полное содержимое.
* Формат (buildRawRecord): [ЮЗЕР]\n…\n[АССИСТЕНТ]\n…\n([ДЕЙСТВИЕ] tool in=…\n[ВЫДАЧА] tool\n…)* */
export function parseTurnBlock(block) {
+1 -20
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { computeSpans, spansToDistill, recordRealPrompt, parseTurnBlock, assembleSpan } from './secretary-span.mjs';
import { computeSpans, spansToDistill, parseTurnBlock, assembleSpan } from './secretary-span.mjs';
import { buildRawRecord } from './secretary-layer1.mjs';
describe('computeSpans', () => {
@@ -40,25 +40,6 @@ describe('spansToDistill', () => {
});
});
describe('recordRealPrompt', () => {
it('добавляет границу, не дублирует, держит сортировку', () => {
let f = { mode: 'on', work: 'x' };
f = recordRealPrompt(f, 3);
expect(f.realPromptTurns).toEqual([3]);
f = recordRealPrompt(f, 12);
expect(f.realPromptTurns).toEqual([3, 12]);
f = recordRealPrompt(f, 12); // дубль игнор
expect(f.realPromptTurns).toEqual([3, 12]);
expect(f.mode).toBe('on'); // прочие поля целы
});
it('не мутирует вход', () => {
const f = { mode: 'on' };
const out = recordRealPrompt(f, 1);
expect(f.realPromptTurns).toBeUndefined();
expect(out.realPromptTurns).toEqual([1]);
});
});
describe('parseTurnBlock', () => {
it('тащит turn, user, assistant, действия с input/result', () => {
const block = buildRawRecord({
+14 -50
View File
@@ -1,21 +1,22 @@
#!/usr/bin/env node
// Stop-переходник секретаря: ВСЕГДА пишет сырьё (Слой 1); если секретарь включён — отложенный
// разбор ПО СПАНАМ (реальный промпт + вся активность до следующего реального промпта).
// Закрытые спаны (не последний) разбираются один раз; курсор в флажке сессии. При mode:'closing'
// (после «выключи секретаря») добивается последний открытый спан + нарезка сырья + гашение флажка.
// Stop-переходник секретаря: ВСЕГДА пишет сырьё (Слой 1) с ярлычком служебного хода (meta=1);
// если секретарь включён — отложенный разбор ПО СПАНАМ. Границы спанов берутся из СЫРЬЯ структурно
// (realBoundariesFromRaw: служебный = meta=1, фолбэк по тексту) — без угадывания по номерам.
// Закрытые спаны разбираются один раз (курсор в флажке); при mode:'closing' добивается последний
// открытый спан + нарезка сырья + гашение флажка. Разбор одного спана — общий distillSpan.
import { existsSync, readFileSync, appendFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { parseLastExchange } from './secretary-transcript.mjs';
import { secretaryModeFileName } from './secretary-flag.mjs';
import { buildRawRecord, buildStepLine, writeFileAtomic, realBoundariesFromRaw, mergeStepsPreservingText, prepareTurnFiles } from './secretary-layer1.mjs';
import { reconcileTurn, mergeTurnIntoProtocol, formatReconcileLogLine, collapseProtocol } from './secretary-reconcile.mjs';
import { buildRawRecord, writeFileAtomic, realBoundariesFromRaw, mergeStepsPreservingText, prepareTurnFiles } from './secretary-layer1.mjs';
import { formatReconcileLogLine } from './secretary-reconcile.mjs';
import { renderProtocol, EMPTY_PROTOCOL } from './secretary-protocol.mjs';
import { upsertIndexEntry } from './secretary-index.mjs';
import { sanitize } from './observer-pii-filter.mjs';
import { callAnthropicAPI } from './router-classifier.mjs';
import { buildAuditPrompt, parseAuditResponse, applyAudit, preserveRegistry } from './secretary-audit.mjs';
import { computeSpans, spansToDistill, assembleSpan } from './secretary-span.mjs';
import { distillSpan } from './secretary-distill.mjs';
function readStdin() { try { return readFileSync(0, 'utf-8'); } catch { return ''; } }
function flagPath(session) { return join(homedir(), '.claude', 'runtime', secretaryModeFileName(session)); }
@@ -43,11 +44,11 @@ async function main() {
const ex = parseLastExchange(transcript);
const turn = turnCount(rawFile) + 1;
// Слой 1: всегда пишем сырьё (PII вырезается перед записью). Append — атомарность не нужна.
// Слой 1: всегда пишем сырьё (PII вырезается перед записью); служебный ход помечаем meta=1.
try {
const rec = sanitize(buildRawRecord({
turn, time: new Date().toISOString(), session,
user: ex.user, assistant: ex.assistant, actions: ex.actions,
user: ex.user, assistant: ex.assistant, actions: ex.actions, userIsMeta: ex.userIsMeta,
}));
mkdirSync(join(secdir, 'raw'), { recursive: true });
appendFileSync(rawFile, rec + '\n', 'utf-8');
@@ -66,16 +67,13 @@ async function main() {
let proto = EMPTY_PROTOCOL();
try { if (existsSync(protoJson)) proto = JSON.parse(readFileSync(protoJson, 'utf-8')); } catch { proto = EMPTY_PROTOCOL(); }
// Сырьё целиком (только что дописали текущий ход) — источник для сборки спанов и фолбэк-границ.
let rawText = '';
try { rawText = readFileSync(rawFile, 'utf-8'); } catch { rawText = ''; }
// Границы спанов: авторитетные из флажка (пишет prompt-hook), иначе фолбэк по sysLabel из сырья.
const bounds = (Array.isArray(flag.realPromptTurns) && flag.realPromptTurns.length)
? flag.realPromptTurns : realBoundariesFromRaw(rawText);
// Границы спанов — из СЫРЬЯ структурно (meta=1; фолбэк по тексту). Курсор — из флажка.
const bounds = realBoundariesFromRaw(rawText);
const cursor = Number.isFinite(flag.spanCursor) ? flag.spanCursor : -1;
// Закрытые спаны к разбору; при закрытии добиваем и последний открытый (force-close).
const list = spansToDistill(bounds, turn, cursor);
if (closing) {
const all = computeSpans(bounds, turn).map((s, index) => ({ ...s, index }));
@@ -87,7 +85,6 @@ async function main() {
// Обычный ход без новых закрытых спанов — тетрадь не трогаем (отставание на один промпт).
if (!list.length && !closing) { process.exit(0); }
// Видимый сигнал срыва reconcile — в лог дела.
const reLog = join(workDir, '_reconcile.log');
const logReason = (info) => {
try {
@@ -106,44 +103,11 @@ async function main() {
})
: null;
// Разбор каждого завершённого спана по порядку: reconcile + аудит на ПОЛНОМ склеенном спане.
// Разбор каждого завершённого спана по порядку (общий distillSpan: reconcile + аудит на ПОЛНОМ спане).
let lastIndex = cursor;
for (const span of list) {
const spanEx = assembleSpan(rawText, span);
// Снимок реестра СВ ДО reconcile (reconcile перенумеровывает hidden) — вернём после merge.
const svSnapshot = JSON.parse(JSON.stringify({
hidden: proto.hidden || [], acceptance: proto.acceptance || [],
tails: proto.tails || [], nextSvId: proto.nextSvId || 1,
}));
let updated = null;
if (apiKey) {
updated = await reconcileTurn({ proto, ex: spanEx, turn: span.start, session, callModel, diag: (i) => logReason({ turn: span.start, ...i }) });
} else {
logReason({ turn: span.start, reason: 'no-key' });
}
const modelStep = (updated && updated.step) || null;
if (updated && 'step' in updated) delete updated.step;
const step = { turn: span.start, session,
text: buildStepLine({ turn: span.start, endTurn: span.end, user: spanEx.user, assistant: spanEx.assistant,
actions: (spanEx.actions || []).map((a) => a.tool), essence: modelStep }) };
const toWrite = mergeTurnIntoProtocol({ proto, updated, step });
// Реестр СВ — вотчина аудитора: вернуть из снимка ДО reconcile.
preserveRegistry(toWrite, svSnapshot);
// Аудитор скрытых вопросов (9 линз) на ПОЛНОМ спане.
if (apiKey) {
try {
const auditMsgs = buildAuditPrompt(toWrite, spanEx);
const raw = await callModel(auditMsgs);
applyAudit(toWrite, parseAuditResponse(typeof raw === 'string' ? raw : (raw?.text || '')), span.start);
} catch (e) { logReason({ turn: span.start, reason: 'audit-fail', error: e && e.message }); }
}
proto = collapseProtocol(toWrite);
proto = await distillSpan(proto, spanEx, span, { callModel, session, diag: logReason });
lastIndex = span.index;
}
+5 -1
View File
@@ -49,6 +49,10 @@ export function parseLastExchange(transcriptText) {
: (Array.isArray(userContent)
? userContent.filter((b) => b && b.type === 'text').map((b) => b.text).join('\n')
: '');
// Структурный ярлычок: служебное сообщение (гейт-фидбек / загрузка навыка / контекст) помечено
// isMeta:true на самой записи транскрипта. Реальная просьба владельца — без него. Это честный
// разделитель «хозяин vs служебное» (не угадывание по тексту/номеру хода).
const userIsMeta = u >= 0 && entries[u].isMeta === true;
let assistant = '';
const raw = []; // {id, tool, input} — вызовы инструментов
@@ -77,5 +81,5 @@ export function parseLastExchange(transcriptText) {
if (a.id != null && results[a.id] != null) out.result = String(results[a.id] ?? '');
return out;
});
return { user, assistant, actions };
return { user, assistant, actions, userIsMeta };
}
+16
View File
@@ -76,6 +76,22 @@ describe('parseLastExchange — захват выдачи инструмента
expect(ex.actions[0].result).toBe(big); // целиком
expect(ex.actions[0].result.endsWith('…')).toBe(false);
});
it('помечает userIsMeta для служебного сообщения (isMeta:true на записи)', () => {
const t = [
JSON.stringify({ message: { role: 'user', content: 'настоящий' } }),
JSON.stringify({ message: { role: 'assistant', content: [{ type: 'text', text: 'ответ' }] } }),
JSON.stringify({ isMeta: true, message: { role: 'user', content: 'Stop hook feedback: x' } }),
JSON.stringify({ message: { role: 'assistant', content: [{ type: 'text', text: 'продолжение' }] } }),
].join('\n');
const ex = parseLastExchange(t);
expect(ex.user).toBe('Stop hook feedback: x'); // выбор сообщения прежний (последнее текстовое)
expect(ex.userIsMeta).toBe(true); // но помечено как служебное
});
it('реальный промпт — userIsMeta false', () => {
const t = [JSON.stringify({ message: { role: 'user', content: 'привет' } }),
JSON.stringify({ message: { role: 'assistant', content: 'ок' } })].join('\n');
expect(parseLastExchange(t).userIsMeta).toBe(false);
});
it('без совпадающего id результат не привязывается — старая форма {tool,input} цела', () => {
const t = [
JSON.stringify({ message: { role: 'user', content: 'в' } }),