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:
@@ -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);
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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', '=== КОНЕЦ ХОДА ===', '',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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: 'в' } }),
|
||||
|
||||
Reference in New Issue
Block a user