From af262fffbbf1148078d8457c0f3f9e17a96d821d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Fri, 26 Jun 2026 17:37:31 +0300 Subject: [PATCH] =?UTF-8?q?feat(secretary):=20=D0=B5=D0=B4=D0=B8=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=B1=D0=BE=D0=B3=D0=B0=D1=82=D1=8B=D0=B9=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D1=82=D0=B5=D0=BA=D1=81=D1=82=20=D1=80=D0=BE?= =?UTF-8?q?=D0=BB=D1=8F=D0=BC=20(=D1=81=D1=82=D0=B2=D0=BE=D0=BB+=D0=B1?= =?UTF-8?q?=D0=B0=D0=B7=D0=B0+=D0=B2=D0=B5=D1=82=D0=BA=D0=B8+=D0=BA=D0=B0?= =?UTF-8?q?=D0=BD=D0=B4=D0=B8=D0=B4=D0=B0=D1=82=D1=8B+N=20=D1=85=D0=BE?= =?UTF-8?q?=D0=B4=D0=BE=D0=B2+=D1=88=D0=B0=D0=B3=D0=B8-N)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/secretary-distill.mjs | 13 ++- tools/secretary-harvest.mjs | 74 ++++++++++------ tools/secretary-harvest.test.mjs | 132 ++++++++++++++--------------- tools/secretary-reconcile.mjs | 3 + tools/secretary-reconcile.test.mjs | 10 +++ tools/secretary-trunkdiff.mjs | 8 ++ tools/secretary-trunkdiff.test.mjs | 12 +++ 7 files changed, 155 insertions(+), 97 deletions(-) diff --git a/tools/secretary-distill.mjs b/tools/secretary-distill.mjs index 6896888..9f1c16d 100644 --- a/tools/secretary-distill.mjs +++ b/tools/secretary-distill.mjs @@ -8,7 +8,7 @@ import { fluffyPipelineOn } from './secretary-flag.mjs'; import { diffTrunk } from './secretary-trunkdiff.mjs'; import { parseLoose } from './secretary-armor.mjs'; import { resolveModel } from './secretary-models.mjs'; -import { SYS_11, SYS_12, SYS_13, diagFindings, user11, user12, user13, userG, renderExchangeText } from './secretary-harvest.mjs'; +import { SYS_11, SYS_12, SYS_13, diagFindings, user11, user12, user13, userG } from './secretary-harvest.mjs'; import { SYS_G, applyGardener } from './secretary-gardener.mjs'; import { applyHarvest } from './secretary-apply.mjs'; import { slimProtocol } from './secretary-slim.mjs'; @@ -45,24 +45,23 @@ export async function distillSpan(proto, spanEx, { start, end, note = '' }, { ca if (callModel && fluffy) { try { const diff = diffTrunk(proto, toWrite); // движение ствола до/после редактора - const spanText = renderExchangeText(spanEx); const txt = (r) => (typeof r === 'string' ? r : (r && r.text) || ''); const call = (role, system, user) => callModel({ system, user, model: resolveModel(role) }); - // ПОДАЧА ролям — из live-only проекции (надгробия не возим): мёртвое отсеяно, мутации ниже идут по полному proto. + // ПОДАЧА ролям — из live-only проекции (надгробия не возим). spanEx несёт turns — сборщики рендерят N ходов сами. const view = slimProtocol(toWrite); // сбор: 1.1 диагностика ∥ 1.2 ловец, затем 1.3 брейншторм (после них) const [r11, r12] = await Promise.all([ - call('diagnostic', SYS_11, user11(view, spanText, { start, end })), - call('catcher', SYS_12, user12(view, spanText)), + call('diagnostic', SYS_11, user11(view, spanEx)), + call('catcher', SYS_12, user12(view, spanEx)), ]); const d11 = diagFindings(parseLoose(txt(r11), 'lenses')); const d12 = parseLoose(txt(r12), 'dropped'); - const r13 = await call('brainstorm', SYS_13, user13(view, spanText, { start, end }, JSON.stringify(d11.new), JSON.stringify(d12.dropped))); + const r13 = await call('brainstorm', SYS_13, user13(view, spanEx, JSON.stringify(d11.new), JSON.stringify(d12.dropped))); const d13 = parseLoose(txt(r13), 'forks'); // ЭТАП 1: урожай (новые ветки + рождение/заточка/полнота кандидатов) — садовник увидит свежую грядку const withHarvest = applyHarvest(toWrite, start, d11, d12, d13); // ЭТАП 2: садовник — подача из СВЕЖЕЙ live-проекции (видит свежие живые ветки и кандидаты), без надгробий - const rG = await call('gardener', SYS_G, userG(slimProtocol(withHarvest), spanText, diff)); + const rG = await call('gardener', SYS_G, userG(slimProtocol(withHarvest), spanEx, diff)); const dG = parseLoose(txt(rG), 'tend'); return applyGardener(withHarvest, start, dG); } catch (e) { if (typeof diag === 'function') diag({ turn: start, reason: 'fluffy-fail', error: e && e.message }); return collapseProtocol(toWrite); } diff --git a/tools/secretary-harvest.mjs b/tools/secretary-harvest.mjs index c48c6f1..45ae37d 100644 --- a/tools/secretary-harvest.mjs +++ b/tools/secretary-harvest.mjs @@ -119,37 +119,63 @@ function clamp(s) { return s.slice(0, cap) + `…[вырезано ${s.length - cap} знаков]`; } -// Сколько ПОСЛЕДНИХ действий хода показывать ролям/редактору: длинный ход с десятками Read -// не возим целиком (анти-раздувание). Суть хода всё равно фиксирует «step» редактора. -export const MAX_EXCHANGE_ACTIONS = 7; +// Сколько последних ходов спана подавать целиком: роли — 4, садовник/редактор — 7. +export const TURNS_ROLE = 4; +export const TURNS_GARDENER = 7; -/** Текст обмена из спана {user, assistant, actions}. Показываем последние MAX_EXCHANGE_ACTIONS действий. */ -export function renderExchangeText(spanEx) { - const all = spanEx.actions || []; - const shown = all.slice(-MAX_EXCHANGE_ACTIONS); - const head = all.length > shown.length ? ` (показаны последние ${shown.length} из ${all.length} действий)\n` : ''; - const acts = (shown.map((a) => ` • ${a.tool} in=${a.input ?? ''}${a.result != null ? ` → ${clamp(String(a.result))}` : ''}`).join('\n')) || '—'; - return `[ЮЗЕР]: ${spanEx.user || ''}\n[АССИСТЕНТ]: ${spanEx.assistant || ''}\n[ДЕЙСТВИЯ]:\n${head}${acts}`; +/** Последние n ходов спана целиком: [ХОД K] юзер+ассистент+ВСЕ действия. Пометка если ходов больше. */ +export function renderTurns(turns, n) { + const all = Array.isArray(turns) ? turns : []; + const shown = all.slice(-n); + const head = all.length > shown.length ? `(показаны последние ${shown.length} из ${all.length} ходов)\n\n` : ''; + const acts = (list) => ((list || []).map((a) => ` • ${a.tool} in=${a.input ?? ''}${a.result != null ? ` → ${clamp(String(a.result))}` : ''}`).join('\n')) || ' —'; + return head + shown.map((t) => + `[ХОД ${t.turn}]\n[ЮЗЕР]: ${t.user || ''}\n[АССИСТЕНТ]: ${t.assistant || ''}\n[ДЕЙСТВИЯ]:\n${acts(t.actions)}`).join('\n\n'); } -// ── USER-сборщики (берут готовый spanText) ──────────────────────────────── -export function user11(proto, spanText, span = {}) { - return `НЕДАВНИЕ ШАГИ (ствол, последние 10):\n${recentSteps(proto)}\n\n${registry(proto)}\n\n` - + `=== ПОЛНЫЙ ОБМЕН (ходы ${span.start ?? '?'}-${span.end ?? '?'}) ===\n${spanText}`; +/** Краткий лог ВСЕХ ходов минус последние n (они показаны целиком — не дублируем). */ +export function stepsMinusLast(proto, n) { + const all = (proto.steps || []).slice().sort((a, b) => (a.turn || 0) - (b.turn || 0)); + const keep = n > 0 ? all.slice(0, Math.max(0, all.length - n)) : all; + return keep.map((s) => `ход ${s.turn}: ${s.text}`).join('\n') || '(пока нет)'; } -export function user12(proto, spanText) { - const will = ((proto.will || []).filter((e) => !e.struck).map((e) => `- ${e.text}`).join('\n')) || '(нет)'; - return `${trunkForCatcher(proto)}\nВОЛЯ (уже решено — НЕ лови):\n${will}\nЖИВЫЕ ВЕТКИ (уже ведутся — НЕ лови):\n${registry(proto)}${tombstones(proto)}` - + `\n\nВСЕ ШАГИ РАЗГОВОРА:\n${fullSteps(proto)}\n\n=== ТЕКУЩИЙ ОБМЕН ===\n${spanText}`; + +/** Полный ствол: все 6 разделов, зачёркнутые помечены. */ +export function fullTrunk(proto) { + const sec = (name, arr) => `${name}:\n` + (((arr || []).map((e) => ` - ${e.struck ? '[зачёркнуто] ' : ''}${e.text}${e.why ? ' — ' + e.why : ''}`).join('\n')) || ' (пусто)'); + return [ + sec('Решения', proto.decisions), sec('Альтернативы', proto.alternatives), + sec('Последствия', proto.consequences), sec('Воля', proto.will), + sec('Открытые', proto.open), sec('Сделано', proto.doneNext), + ].join('\n'); } -export function user13(proto, spanText, span, found11, found12) { - return `${user11(proto, spanText, span)}\nКАНДИДАТЫ (живые — точи своих, НЕ дублируй):\n${candidatesForPrompt(proto)}${THEMES_BLOCK(proto)}` - + `\n\nВСЕ ШАГИ РАЗГОВОРА:\n${fullSteps(proto)}` + +/** База знаний: строки «знание — источник». */ +export function knowledgeBlock(proto) { + return ((proto.knowledge || []).map((e) => ` - ${e.text}${e.ref ? ' — ' + e.ref : ''}`).join('\n')) || ' (пусто)'; +} + +// ── USER-сборщики (богатый контекст: тема+база+ствол+ветки+кандидаты+N ходов+шаги−N) ── +/** Общий контекст для diagnostic/catcher/brainstorm: n=TURNS_ROLE ходов целиком. */ +function roleContext(proto, spanEx, n) { + return `ТЕМА: ${proto.subject || '(нет)'}\n\n` + + `БАЗА ЗНАНИЙ:\n${knowledgeBlock(proto)}\n\n` + + `СТВОЛ:\n${fullTrunk(proto)}\n\n` + + `${registry(proto)}\n\nЖИВЫЕ КАНДИДАТЫ:\n${candidatesForPrompt(proto)}${THEMES_BLOCK(proto)}\n\n` + + `=== ПОСЛЕДНИЕ ХОДЫ (целиком) ===\n${renderTurns(spanEx && spanEx.turns, n)}\n\n` + + `КРАТКИЙ ЛОГ ОСТАЛЬНЫХ ХОДОВ:\n${stepsMinusLast(proto, n)}`; +} +export function user11(proto, spanEx) { return roleContext(proto, spanEx, TURNS_ROLE); } +export function user12(proto, spanEx) { return roleContext(proto, spanEx, TURNS_ROLE); } +export function user13(proto, spanEx, found11, found12) { + return `${roleContext(proto, spanEx, TURNS_ROLE)}` + `\n\n=== УЖЕ НАЙДЕНО ЭТОТ ХОД (не повторяй) ===\nДиагностика: ${found11}\nБрошенное: ${found12}`; } -export function userG(proto, spanText, diff) { - return `ЧТО ИЗМЕНИЛОСЬ В СТВОЛЕ ЭТОТ ХОД (дифф редактора):\n${diff}\n\n${registry(proto)}\nЖИВЫЕ КАНДИДАТЫ (для моста «повысить»):\n${candidatesForPrompt(proto)}${tombstones(proto)}\n\n` - + `НЕДАВНИЕ ШАГИ:\n${recentSteps(proto)}\n\n=== ПОЛНЫЙ ОБМЕН ===\n${spanText}`; +export function userG(proto, spanEx, diff) { + return `ЧТО ИЗМЕНИЛОСЬ В СТВОЛЕ ЭТОТ ХОД (дифф редактора):\n${diff}\n\n` + + `${registry(proto)}\nЖИВЫЕ КАНДИДАТЫ (для моста «повысить»):\n${candidatesForPrompt(proto)}${THEMES_BLOCK(proto)}\n\n` + + `=== ПОСЛЕДНИЕ ХОДЫ (целиком) ===\n${renderTurns(spanEx && spanEx.turns, TURNS_GARDENER)}\n\n` + + `КРАТКИЙ ЛОГ ОСТАЛЬНЫХ ХОДОВ:\n${stepsMinusLast(proto, TURNS_GARDENER)}`; } // Короткий заголовок пункта (для надгробий/реестра, когда title пуст). diff --git a/tools/secretary-harvest.test.mjs b/tools/secretary-harvest.test.mjs index ec6437d..49ae87c 100644 --- a/tools/secretary-harvest.test.mjs +++ b/tools/secretary-harvest.test.mjs @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { SYS_11, SYS_12, SYS_13, diagFindings, renderExchangeText, user11, user12, user13, themesInUse, fullSteps, tombstones } from './secretary-harvest.mjs'; +import { SYS_11, SYS_12, SYS_13, diagFindings, user11, user12, user13, userG, themesInUse, fullSteps, tombstones } from './secretary-harvest.mjs'; +import { renderTurns, stepsMinusLast, fullTrunk, knowledgeBlock } from './secretary-harvest.mjs'; describe('secretary-harvest промпты', () => { it('SYS_11 требует отчёт по 8 линзам, finding|clean', () => { @@ -34,31 +35,43 @@ describe('diagFindings', () => { }); }); -describe('USER-сборщики', () => { - it('renderExchangeText форматирует обмен с действиями', () => { - const t = renderExchangeText({ user: 'спросил', assistant: 'ответил', actions: [{ tool: 'Read', input: 'f', result: 'r' }] }); - expect(t).toContain('[ЮЗЕР]: спросил'); - expect(t).toContain('[АССИСТЕНТ]: ответил'); - expect(t).toContain('• Read'); +describe('USER-сборщики (богатый контекст)', () => { + const spanEx = (n = 2) => ({ turns: Array.from({ length: n }, (_, i) => ({ turn: i + 1, user: `u${i + 1}`, assistant: `a${i + 1}`, actions: [{ tool: 'Read', input: `f${i + 1}`, result: 'r' }] })) }); + const proto = () => ({ + subject: 'дело', knowledge: [{ ref: 'a.mjs:1', text: 'факт' }], + decisions: [{ text: 'решено-X' }], alternatives: [], consequences: [], will: [{ text: 'воля-один' }], open: [{ text: 'открыто-Y' }], doneNext: [], + hidden: [{ id: 'СВ-1', lens: 'Л1', status: 'открыт', text: 'ветка' }], + candidates: [{ id: 'КД-1', branch: 'идея-кандидат', status: 'жив', тема: 't' }], + steps: Array.from({ length: 6 }, (_, i) => ({ turn: i + 1, text: `шаг-${i + 1}` })), }); - it('user12 даёт ловцу ствол (решения/открытые) + обмен', () => { - const proto = { decisions: [{ text: 'решено-X', struck: false }], open: [{ text: 'открыто-Y', struck: false }] }; - const u = user12(proto, '[ЮЗЕР]: q'); - expect(u).toContain('решено-X'); - expect(u).toContain('открыто-Y'); - expect(u).toContain('[ЮЗЕР]: q'); + it('user11/user12 несут тему, базу, ствол, ветки, кандидатов, 4 хода, шаги−4', () => { + for (const build of [user11, user12]) { + const u = build(proto(), spanEx(6)); + expect(u).toContain('дело'); + expect(u).toContain('факт — a.mjs:1'); + expect(u).toContain('решено-X'); + expect(u).toContain('воля-один'); + expect(u).toContain('СВ-1'); + expect(u).toContain('КД-1'); + expect(u).toContain('[ХОД 6]'); + expect(u).toMatch(/последние 4 из 6 ходов/); + expect(u).toContain('шаг-1'); + expect(u).not.toContain('шаг-6'); + } }); - it('user11 несёт реестр и недавние шаги', () => { - const proto = { hidden: [{ id: 'СВ-1', lens: 'Л1', status: 'открыт', text: 'ветка' }], steps: [{ text: 'Ход 1 — …' }] }; - const u = user11(proto, 'обмен', { start: 3, end: 4 }); + it('user13 = контекст + хвост «уже найдено»', () => { + const u = user13(proto(), spanEx(2), '["находка"]', '["брошено"]'); expect(u).toContain('СВ-1'); - expect(u).toContain('Ход 1'); - }); - it('user13 добавляет уже найденное', () => { - const u = user13({}, 'обмен', { start: 3, end: 4 }, '["находка"]', '["брошено"]'); expect(u).toContain('УЖЕ НАЙДЕНО'); expect(u).toContain('находка'); }); + it('userG = дифф + ветки + кандидаты + 7 ходов + шаги−7', () => { + const u = userG(proto(), spanEx(2), 'ДИФФ-СТРОКА'); + expect(u).toContain('ДИФФ-СТРОКА'); + expect(u).toContain('СВ-1'); + expect(u).toContain('КД-1'); + expect(u).toContain('[ХОД 2]'); + }); }); describe('поля рождения (title/тема/важность)', () => { @@ -92,13 +105,6 @@ describe('брейншторм v2 (точит своих + полнота + ша const s = fullSteps({ steps: [{ turn: 2, text: 'b' }, { turn: 1, text: 'a' }] }); expect(s.indexOf('ход 1')).toBeLessThan(s.indexOf('ход 2')); }); - it('user13 включает шаги, темы и живых кандидатов', () => { - const p = { steps: [{ turn: 1, text: 'шаг-один' }], hidden: [], - candidates: [{ id: 'КД-1', branch: 'идея-кандидат', status: 'жив', тема: 't' }], decisions: [], open: [] }; - const u = user13(p, 'обмен', { start: 1, end: 1 }, '[]', '[]'); - expect(u).toMatch(/шаг-один/); - expect(u).toMatch(/идея-кандидат/); - }); }); describe('ловец прозрел (шаги+воля+реестр+надгробия)', () => { @@ -106,48 +112,42 @@ describe('ловец прозрел (шаги+воля+реестр+надгро const p = { hidden: [{ id: 'СВ-1', status: 'отпал', title: 'мёртвая', endedTurn: 2 }], candidates: [] }; expect(tombstones(p)).toMatch(/СВ-1/); }); - it('user12 включает шаги, волю и реестр живых веток', () => { - const p = { steps: [{ turn: 1, text: 'почта-вскользь' }], - hidden: [{ id: 'СВ-1', lens: 'Л1', status: 'открыт', text: 'ветка' }], - will: [{ text: 'воля-один' }], decisions: [], open: [], candidates: [], acceptance: [], tails: [] }; - const u = user12(p, 'обмен'); - expect(u).toMatch(/почта-вскользь/); - expect(u).toMatch(/воля-один/); - expect(u).toMatch(/СВ-1/); - }); }); -describe('renderExchangeText input cap', () => { - const bigSpan = () => ({ user: 'u', assistant: 'a', actions: [{ tool: 'perplexity', input: 'q', result: 'X'.repeat(9000) }] }); - it('default: no truncation — full result passes through', () => { - delete process.env.SECRETARY_INPUT_CAP; - const t = renderExchangeText(bigSpan()); - expect(t).toContain('X'.repeat(9000)); - expect(t).not.toContain('вырезано'); +describe('harvest — новые кирпичи подачи', () => { + const turns = (n) => Array.from({ length: n }, (_, i) => ({ turn: i + 1, user: `u${i + 1}`, assistant: `a${i + 1}`, actions: [{ tool: 'Read', input: `f${i + 1}`, result: 'r' }] })); + it('renderTurns — последние n ходов целиком, с пометкой если больше', () => { + const t = renderTurns(turns(6), 4); + expect(t).toContain('[ХОД 6]'); + expect(t).toContain('[ХОД 3]'); + expect(t).not.toContain('[ХОД 2]'); + expect(t).toContain('• Read in=f6'); + expect(t).toMatch(/последние 4 из 6 ходов/); }); - it('finite cap truncates the result with a marker', () => { - process.env.SECRETARY_INPUT_CAP = '100'; - const t = renderExchangeText(bigSpan()); - expect(t).toContain('X'.repeat(100)); - expect(t).not.toContain('X'.repeat(101)); - expect(t).toMatch(/вырезано \d+ знаков/); - delete process.env.SECRETARY_INPUT_CAP; - }); -}); - -describe('renderExchangeText — последние 7 действий', () => { - it('режет до последних 7 и помечает сколько было', () => { - const actions = Array.from({ length: 10 }, (_, i) => ({ tool: 'Read', input: `f${i}`, result: `r${i}` })); - const t = renderExchangeText({ user: 'u', assistant: 'a', actions }); - expect(t).toContain('f9'); // последнее показано - expect(t).toContain('f3'); // граница последних 7 (индексы 3..9) - expect(t).not.toContain('f2'); // 8-е с конца отброшено - expect(t).toMatch(/последние 7 из 10/); - }); - it('≤7 действий — без пометки', () => { - const actions = Array.from({ length: 5 }, (_, i) => ({ tool: 'Read', input: `f${i}`, result: `r${i}` })); - const t = renderExchangeText({ user: 'u', assistant: 'a', actions }); - expect(t).toContain('f0'); + it('renderTurns — ≤n ходов: без пометки, все', () => { + const t = renderTurns(turns(2), 4); + expect(t).toContain('[ХОД 1]'); expect(t).not.toMatch(/последние \d+ из/); }); + it('stepsMinusLast — все шаги минус последние n', () => { + const p = { steps: Array.from({ length: 6 }, (_, i) => ({ turn: i + 1, text: `шаг-${i + 1}` })) }; + const s = stepsMinusLast(p, 4); + expect(s).toContain('шаг-1'); + expect(s).toContain('шаг-2'); + expect(s).not.toContain('шаг-3'); + expect(s).not.toContain('шаг-6'); + }); + it('fullTrunk — все 6 разделов, зачёркнутые помечены', () => { + const p = { decisions: [{ text: 'жив-реш' }, { text: 'мёртв-реш', struck: true }], will: [{ text: 'воля' }] }; + const t = fullTrunk(p); + expect(t).toContain('Решения:'); + expect(t).toContain('жив-реш'); + expect(t).toContain('[зачёркнуто] мёртв-реш'); + expect(t).toContain('Альтернативы:'); + expect(t).toContain('Сделано:'); + }); + it('knowledgeBlock — строки знание — источник; пусто → (пусто)', () => { + expect(knowledgeBlock({ knowledge: [{ ref: 'a.mjs:1', text: 'факт' }] })).toContain('факт — a.mjs:1'); + expect(knowledgeBlock({})).toContain('(пусто)'); + }); }); diff --git a/tools/secretary-reconcile.mjs b/tools/secretary-reconcile.mjs index 5dfcea9..943e6fb 100644 --- a/tools/secretary-reconcile.mjs +++ b/tools/secretary-reconcile.mjs @@ -1,4 +1,5 @@ // Секретарь-«редактор»: модель правит весь протокол, хук сторожит потери (спека reconcile). +import { stepsMinusLast } from './secretary-harvest.mjs'; // Сколько ПОСЛЕДНИХ ходов спана подавать редактору целиком (юзер+ассистент+ВСЕ действия хода). const MAX_EXCHANGE_TURNS = 7; @@ -54,6 +55,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), + '', 'Краткий лог остальных ходов:', + stepsMinusLast(protocol, MAX_EXCHANGE_TURNS), '', 'Последние ходы (обмен):', exchangeText, remark ? `\nЗАМЕЧАНИЕ (исправь и верни весь протокол):\n${remark}` : '', diff --git a/tools/secretary-reconcile.test.mjs b/tools/secretary-reconcile.test.mjs index f3790c9..18daa09 100644 --- a/tools/secretary-reconcile.test.mjs +++ b/tools/secretary-reconcile.test.mjs @@ -146,6 +146,16 @@ describe('buildReconcilePrompt', () => { }); }); +describe('buildReconcilePrompt — шаги−7', () => { + it('добавляет краткий лог всех ходов минус последние 7', () => { + const steps = Array.from({ length: 9 }, (_, i) => ({ turn: i + 1, text: `шаг-${i + 1}` })); + const { user } = buildReconcilePrompt({ protocol: { decisions: [], open: [], will: [], doneNext: [], steps }, lastExchange: { turns: [] } }); + expect(user).toContain('шаг-1'); + expect(user).toContain('шаг-2'); + expect(user).not.toContain('шаг-3'); + }); +}); + describe('reconcile — 9 категорий + стабильная тема', () => { it('parse читает alternatives и consequences', () => { const out = parseReconcileResponse('{ "subject":"S", "alternatives":[{"text":"ALT","struck":false}], "consequences":[{"text":"C","struck":false}] }'); diff --git a/tools/secretary-trunkdiff.mjs b/tools/secretary-trunkdiff.mjs index 9292668..9a44f16 100644 --- a/tools/secretary-trunkdiff.mjs +++ b/tools/secretary-trunkdiff.mjs @@ -14,5 +14,13 @@ export function diffTrunk(oldP, newP) { else if (prev.struck && !e.struck) lines.push(`${label} расчёркнут (снова актуален): «${e.text}»`); } } + const oldK = new Map(((oldP.knowledge) || []).map((e) => [String(e.ref || '').trim(), String(e.text || '').trim()])); + for (const e of (newP.knowledge) || []) { + const ref = String(e.ref || '').trim(); + const text = String(e.text || '').trim(); + if (!ref) continue; + if (!oldK.has(ref)) lines.push(`ЗНАНИЕ добавлено: «${text} — ${ref}»`); + else if (oldK.get(ref) !== text) lines.push(`ЗНАНИЕ уточнено: «${text} — ${ref}»`); + } return lines.length ? lines.join('\n') : '(ствол не двигался этот ход)'; } diff --git a/tools/secretary-trunkdiff.test.mjs b/tools/secretary-trunkdiff.test.mjs index 62e71a6..634801b 100644 --- a/tools/secretary-trunkdiff.test.mjs +++ b/tools/secretary-trunkdiff.test.mjs @@ -14,3 +14,15 @@ describe('diffTrunk', () => { expect(d).toContain('ОТКРЫТЫЙ зачёркнут (закрыт ствол): «Q»'); }); }); + +describe('diffTrunk — база знаний', () => { + it('показывает добавленное и уточнённое знание', () => { + const oldP = { knowledge: [{ ref: 'a.mjs:1', text: 'старое' }] }; + const newP = { knowledge: [{ ref: 'a.mjs:1', text: 'уточнённое' }, { ref: 'b.mjs:2', text: 'новое' }] }; + const d = diffTrunk(oldP, newP); + expect(d).toContain('ЗНАНИЕ уточнено'); + expect(d).toContain('a.mjs:1'); + expect(d).toContain('ЗНАНИЕ добавлено'); + expect(d).toContain('b.mjs:2'); + }); +});