diff --git a/tools/secretary-reconcile.mjs b/tools/secretary-reconcile.mjs index 40546b4..5dfcea9 100644 --- a/tools/secretary-reconcile.mjs +++ b/tools/secretary-reconcile.mjs @@ -1,8 +1,7 @@ // Секретарь-«редактор»: модель правит весь протокол, хук сторожит потери (спека reconcile). -// Сколько ПОСЛЕДНИХ действий хода подавать редактору (анти-раздувание: длинный ход с десятками -// Read не возим целиком). Держим в синхроне с MAX_EXCHANGE_ACTIONS из secretary-harvest.mjs. -const MAX_EXCHANGE_ACTIONS = 7; +// Сколько ПОСЛЕДНИХ ходов спана подавать редактору целиком (юзер+ассистент+ВСЕ действия хода). +const MAX_EXCHANGE_TURNS = 7; /** Запрос к модели-редактору: весь протокол + обмен → весь обновлённый протокол. */ export function buildReconcilePrompt({ protocol = {}, lastExchange = {}, remark = null } = {}) { @@ -36,11 +35,18 @@ export function buildReconcilePrompt({ protocol = {}, lastExchange = {}, remark ].join('\n'); const sec = (name, arr) => `${name}:\n` + ((arr || []).map((e) => ` - ${e.struck ? '[зачёркнуто] ' : ''}${e.text}${e.why ? ' — ' + e.why : ''}`).join('\n') || ' (пусто)'); - const allActs = lastExchange.actions || []; - const shownActs = allActs.slice(-MAX_EXCHANGE_ACTIONS); - const actsHead = allActs.length > shownActs.length ? ` (показаны последние ${shownActs.length} из ${allActs.length} действий)\n` : ''; - const acts = actsHead + ((shownActs.map((a) => - ` • ${a.tool} in=${a.input ?? ''}${a.result != null ? `\n → ${String(a.result).replace(/\n/g, '\n ')}` : ''}`).join('\n')) || '—'); + const renderActs = (acts) => ((acts || []).map((a) => + ` • ${a.tool} in=${a.input ?? ''}${a.result != null ? `\n → ${String(a.result).replace(/\n/g, '\n ')}` : ''}`).join('\n')) || ' —'; + let exchangeText; + if (Array.isArray(lastExchange.turns) && lastExchange.turns.length) { + const all = lastExchange.turns; + const shown = all.slice(-MAX_EXCHANGE_TURNS); + const head = all.length > shown.length ? `(показаны последние ${shown.length} из ${all.length} ходов)\n\n` : ''; + exchangeText = head + shown.map((t) => + `[ХОД ${t.turn}]\n[ЮЗЕР]: ${t.user || ''}\n[АССИСТЕНТ]: ${t.assistant || ''}\n[ДЕЙСТВИЯ]:\n${renderActs(t.actions)}`).join('\n\n'); + } else { + exchangeText = `[ЮЗЕР]: ${lastExchange.user || ''}\n[АССИСТЕНТ]: ${lastExchange.assistant || ''}\n[ДЕЙСТВИЯ]:\n${renderActs(lastExchange.actions)}`; + } const knowLines = ((protocol.knowledge || []).map((e) => ` - ${e.text}${e.ref ? ' — ' + e.ref : ''}`).join('\n')) || ' (пусто)'; const user = [ `Тема дела: ${protocol.subject || '(нет)'}`, @@ -48,10 +54,8 @@ export function buildReconcilePrompt({ protocol = {}, lastExchange = {}, remark sec('Решения', protocol.decisions), sec('Альтернативы', protocol.alternatives), sec('Последствия', protocol.consequences), sec('Воля', protocol.will), sec('Открытые', protocol.open), sec('Сделано', protocol.doneNext), - '', 'Последний обмен:', - `[ЮЗЕР]: ${lastExchange.user || ''}`, - `[АССИСТЕНТ]: ${lastExchange.assistant || ''}`, - `Действия (с содержимым):\n${acts}`, + '', 'Последние ходы (обмен):', + exchangeText, remark ? `\nЗАМЕЧАНИЕ (исправь и верни весь протокол):\n${remark}` : '', '', 'Верни ВЕСЬ обновлённый протокол как JSON.', ].join('\n'); diff --git a/tools/secretary-reconcile.test.mjs b/tools/secretary-reconcile.test.mjs index 812f9eb..f3790c9 100644 --- a/tools/secretary-reconcile.test.mjs +++ b/tools/secretary-reconcile.test.mjs @@ -125,13 +125,24 @@ describe('buildReconcilePrompt', () => { expect(user).toContain('{"f":"x"}'); expect(user).toContain('СОДЕРЖИМОЕ'); }); - it('Последний обмен — только последние 7 действий с пометкой', () => { - const actions = Array.from({ length: 10 }, (_, i) => ({ tool: 'Read', input: `f${i}`, result: `R${i}` })); - const { user } = buildReconcilePrompt({ protocol: { decisions: [], open: [], will: [], doneNext: [] }, lastExchange: { user: 'u', assistant: 'a', actions } }); - expect(user).toContain('f9'); - expect(user).toContain('f3'); - expect(user).not.toContain('f2'); - expect(user).toMatch(/последние 7 из 10/); + it('обмен редактора — последние 7 ходов целиком, действия не урезаны', () => { + const turns = Array.from({ length: 9 }, (_, i) => ({ + turn: i + 1, user: `u${i + 1}`, assistant: `a${i + 1}`, + actions: [{ tool: 'Read', input: `fA${i + 1}`, result: 'r' }, { tool: 'Read', input: `fB${i + 1}`, result: 'r' }], + })); + const { user } = buildReconcilePrompt({ protocol: { decisions: [], open: [], will: [], doneNext: [] }, lastExchange: { turns } }); + expect(user).toMatch(/последние 7 из 9 ходов/); + expect(user).toContain('[ХОД 9]'); + expect(user).toContain('[ХОД 3]'); + expect(user).not.toContain('[ХОД 2]'); + expect(user).toContain('fA9'); + expect(user).toContain('fB9'); + }); + it('обмен редактора — фолбэк на единый блок, если turns нет', () => { + const { user } = buildReconcilePrompt({ protocol: { decisions: [], open: [], will: [], doneNext: [] }, lastExchange: { user: 'спросил', assistant: 'ответил', actions: [{ tool: 'Read', input: 'x', result: 'СОДЕРЖ' }] } }); + expect(user).toContain('спросил'); + expect(user).toContain('ответил'); + expect(user).toContain('СОДЕРЖ'); }); }); diff --git a/tools/secretary-span.mjs b/tools/secretary-span.mjs index dcb350c..393497f 100644 --- a/tools/secretary-span.mjs +++ b/tools/secretary-span.mjs @@ -52,5 +52,6 @@ export function assembleSpan(rawText, { start, end }) { 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 }; + const turns = parsed.map((p) => ({ turn: p.turn, user: p.user, assistant: p.assistant, actions: p.actions })); + return { user: startTurn.user || '', assistant, actions, turns }; } diff --git a/tools/secretary-span.test.mjs b/tools/secretary-span.test.mjs index 30abe4a..0ac6617 100644 --- a/tools/secretary-span.test.mjs +++ b/tools/secretary-span.test.mjs @@ -76,4 +76,11 @@ describe('assembleSpan', () => { expect(ex.user).toBe('настоящий промпт'); expect(ex.actions).toHaveLength(1); }); + it('возвращает по-ходовый список turns (для редактора)', () => { + const ex = assembleSpan(raw, { start: 3, end: 4 }); + expect(ex.turns).toEqual([ + { turn: 3, user: 'настоящий промпт', assistant: 'первый ответ', actions: [{ tool: 'Read', input: 'a', result: 'r1' }] }, + { turn: 4, user: 'Stop hook feedback: x', assistant: 'второй ответ', actions: [{ tool: 'Grep', input: 'b', result: 'r2' }] }, + ]); + }); });