diff --git a/tools/secretary-distill.mjs b/tools/secretary-distill.mjs new file mode 100644 index 0000000..bc8d135 --- /dev/null +++ b/tools/secretary-distill.mjs @@ -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); +} diff --git a/tools/secretary-distill.test.mjs b/tools/secretary-distill.test.mjs new file mode 100644 index 0000000..4f03db9 --- /dev/null +++ b/tools/secretary-distill.test.mjs @@ -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([]); + }); +}); diff --git a/tools/secretary-layer1.mjs b/tools/secretary-layer1.mjs index 316f99c..efd11fd 100644 --- a/tools/secretary-layer1.mjs +++ b/tools/secretary-layer1.mjs @@ -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); } diff --git a/tools/secretary-layer1.test.mjs b/tools/secretary-layer1.test.mjs index 9b02836..9735785 100644 --- a/tools/secretary-layer1.test.mjs +++ b/tools/secretary-layer1.test.mjs @@ -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', '=== КОНЕЦ ХОДА ===', '', diff --git a/tools/secretary-prompt-hook.mjs b/tools/secretary-prompt-hook.mjs index 5f19915..72b9bfe 100644 --- a/tools/secretary-prompt-hook.mjs +++ b/tools/secretary-prompt-hook.mjs @@ -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); diff --git a/tools/secretary-prompt-hook.test.mjs b/tools/secretary-prompt-hook.test.mjs index 9e93919..dd9ca70 100644 --- a/tools/secretary-prompt-hook.test.mjs +++ b/tools/secretary-prompt-hook.test.mjs @@ -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(); }); }); diff --git a/tools/secretary-span.mjs b/tools/secretary-span.mjs index 1109f2e..dcb350c 100644 --- a/tools/secretary-span.mjs +++ b/tools/secretary-span.mjs @@ -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) { diff --git a/tools/secretary-span.test.mjs b/tools/secretary-span.test.mjs index 79ee328..30abe4a 100644 --- a/tools/secretary-span.test.mjs +++ b/tools/secretary-span.test.mjs @@ -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({ diff --git a/tools/secretary-stop-hook.mjs b/tools/secretary-stop-hook.mjs index df1ddb3..bd732e3 100644 --- a/tools/secretary-stop-hook.mjs +++ b/tools/secretary-stop-hook.mjs @@ -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; } diff --git a/tools/secretary-transcript.mjs b/tools/secretary-transcript.mjs index 682189e..52980b3 100644 --- a/tools/secretary-transcript.mjs +++ b/tools/secretary-transcript.mjs @@ -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 }; } diff --git a/tools/secretary-transcript.test.mjs b/tools/secretary-transcript.test.mjs index 7ae65a0..d57cf6f 100644 --- a/tools/secretary-transcript.test.mjs +++ b/tools/secretary-transcript.test.mjs @@ -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: 'в' } }),