feat(secretary): единый богатый контекст ролям (ствол+база+ветки+кандидаты+N ходов+шаги-N)

This commit is contained in:
Дмитрий
2026-06-26 17:37:31 +03:00
parent fa9e2a0b30
commit af262fffbb
7 changed files with 155 additions and 97 deletions
+6 -7
View File
@@ -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); }
+50 -24
View File
@@ -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 пуст).
+66 -66
View File
@@ -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('(пусто)');
});
});
+3
View File
@@ -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}` : '',
+10
View File
@@ -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}] }');
+8
View File
@@ -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') : '(ствол не двигался этот ход)';
}
+12
View File
@@ -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');
});
});