From 101c08d4478fd2c1da89ad04e3c1a7cef247778e 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: Mon, 22 Jun 2026 05:48:40 +0300 Subject: [PATCH] =?UTF-8?q?feat(secretary):=20layer1=20+=20=D1=87=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D0=B5=20=D1=8F=D0=B4=D1=80=D0=BE=20=D0=BE?= =?UTF-8?q?=D0=B1=D1=91=D1=80=D1=82=D0=BE=D0=BA=20(extract=20prompt/parse,?= =?UTF-8?q?=20encoding/links/period/index-context,=20TDD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/secretary-extract.mjs | 45 +++++++++++++++++++++++++++++++ tools/secretary-extract.test.mjs | 32 ++++++++++++++++++++++ tools/secretary-hookutil.mjs | 27 +++++++++++++++++++ tools/secretary-hookutil.test.mjs | 30 +++++++++++++++++++++ tools/secretary-layer1.mjs | 13 +++++++++ tools/secretary-layer1.test.mjs | 20 ++++++++++++++ 6 files changed, 167 insertions(+) create mode 100644 tools/secretary-extract.mjs create mode 100644 tools/secretary-extract.test.mjs create mode 100644 tools/secretary-hookutil.mjs create mode 100644 tools/secretary-hookutil.test.mjs create mode 100644 tools/secretary-layer1.mjs create mode 100644 tools/secretary-layer1.test.mjs diff --git a/tools/secretary-extract.mjs b/tools/secretary-extract.mjs new file mode 100644 index 0000000..75ce6ff --- /dev/null +++ b/tools/secretary-extract.mjs @@ -0,0 +1,45 @@ +// LLM-извлечение сути (обёртка зовёт мотор; здесь — чистые prompt-builder + parser). + +/** Собрать запрос к LLM: из последнего обмена + списка открытых дел → {system, user}. */ +export function buildExtractionPrompt({ lastExchange = {}, worksIndex = [] } = {}) { + const system = [ + 'Ты — секретарь протокола работ. Извлеки СУТЬ последнего обмена по 9 пунктам.', + 'Верни ТОЛЬКО JSON без markdown, поля:', + '{ "work":"", "decisions":[{"text","why","turns":[]}],', + ' "supersede":[{"oldText","newText","turns":[]}], "will":[{"text","turns":[]}],', + ' "open":[{"text","turns":[]}], "doneNext":[{"text","done":false,"turns":[]}] }', + 'Если сути нет — все массивы пустые.', + ].join('\n'); + const works = worksIndex.length + ? worksIndex.map((w) => `- ${w.slug}: ${w.title} — ${w.goal}`).join('\n') + : '(нет открытых дел)'; + const acts = (lastExchange.actions ?? []).map((a) => a.tool).join(', ') || '—'; + const user = [ + 'Открытые дела:', works, '', + 'Последний обмен:', + `Пользователь: ${lastExchange.user ?? ''}`, + `Ассистент: ${lastExchange.assistant ?? ''}`, + `Действия: ${acts}`, + '', 'Извлеки суть. Верни JSON.', + ].join('\n'); + return { system, user }; +} + +/** Разобрать ответ LLM в структуру для applyExtraction; null при сбое (тихо). */ +export function parseExtractionResponse(llmText) { + if (typeof llmText !== 'string' || !llmText.trim()) return null; + let s = llmText.trim().replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```$/, '').trim(); + s = s.replace(/,(\s*[}\]])/g, '$1'); // хвостовые запятые — частый quirk LLM + let parsed; + try { parsed = JSON.parse(s); } catch { return null; } + if (!parsed || typeof parsed !== 'object') return null; + const arr = (x) => (Array.isArray(x) ? x : []); + return { + work: typeof parsed.work === 'string' ? parsed.work : null, + decisions: arr(parsed.decisions), + supersede: arr(parsed.supersede), + will: arr(parsed.will), + open: arr(parsed.open), + doneNext: arr(parsed.doneNext), + }; +} diff --git a/tools/secretary-extract.test.mjs b/tools/secretary-extract.test.mjs new file mode 100644 index 0000000..506392a --- /dev/null +++ b/tools/secretary-extract.test.mjs @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; +import { buildExtractionPrompt, parseExtractionResponse } from './secretary-extract.mjs'; + +describe('buildExtractionPrompt', () => { + it('включает дела и обмен в запрос', () => { + const { system, user } = buildExtractionPrompt({ + lastExchange: { user: 'привет', assistant: 'ответ', actions: [{ tool: 'Read' }] }, + worksIndex: [{ slug: 'sec', title: 'Секретарь', goal: 'память сути' }], + }); + expect(system).toContain('JSON'); + expect(user).toContain('sec'); + expect(user).toContain('привет'); + expect(user).toContain('Read'); + }); + it('без дел — помечает отсутствие', () => { + const { user } = buildExtractionPrompt({ lastExchange: {}, worksIndex: [] }); + expect(user).toContain('нет открытых дел'); + }); +}); + +describe('parseExtractionResponse', () => { + it('парсит JSON в обёртке с хвостовой запятой', () => { + const out = parseExtractionResponse('```json\n{ "work":"sec", "decisions":[{"text":"A","turns":[7]}], }\n```'); + expect(out.work).toBe('sec'); + expect(out.decisions[0].text).toBe('A'); + expect(out.supersede).toEqual([]); + }); + it('мусор → null', () => { + expect(parseExtractionResponse('не json вовсе')).toBeNull(); + expect(parseExtractionResponse('')).toBeNull(); + }); +}); diff --git a/tools/secretary-hookutil.mjs b/tools/secretary-hookutil.mjs new file mode 100644 index 0000000..a276ab0 --- /dev/null +++ b/tools/secretary-hookutil.mjs @@ -0,0 +1,27 @@ +// Чистые утилиты для тонких переходников секретаря. + +/** Проверка пригодности файла-хода перед записью ссылок: UTF-8, без BOM, непусто. */ +export function verifyEncoding(content) { + if (typeof content !== 'string' || content.length === 0) return { ok: false, reason: 'empty' }; + if (content.charCodeAt(0) === 0xFEFF) return { ok: false, reason: 'BOM' }; + return { ok: true, reason: 'utf8' }; +} + +/** Провенанс-метка из номеров ходов: [7,12] → "[→7, →12]". */ +export function buildStepLinks(turns) { + const arr = Array.isArray(turns) ? turns.filter((t) => t != null) : []; + if (!arr.length) return ''; + return `[${arr.map((t) => `→${t}`).join(', ')}]`; +} + +/** Период нарезки из состояния флажка и текущего хода. */ +export function computePeriod(flagState = {}, currentTurn = 0) { + const from = Number.isInteger(flagState.startedAtTurn) ? flagState.startedAtTurn : 0; + return { from, to: currentTurn }; +} + +/** Оглавление дел как подсказка для старта сессии. */ +export function renderIndexContext(indexMd) { + const body = typeof indexMd === 'string' && indexMd.trim() ? indexMd.trim() : '(дел пока нет)'; + return `Открытые дела (протоколы секретаря):\n${body}`; +} diff --git a/tools/secretary-hookutil.test.mjs b/tools/secretary-hookutil.test.mjs new file mode 100644 index 0000000..7fdd484 --- /dev/null +++ b/tools/secretary-hookutil.test.mjs @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest'; +import { verifyEncoding, buildStepLinks, computePeriod, renderIndexContext } from './secretary-hookutil.mjs'; + +describe('verifyEncoding', () => { + it('пустое — не ок', () => { expect(verifyEncoding('').ok).toBe(false); }); + it('BOM — не ок', () => { expect(verifyEncoding('текст').ok).toBe(false); }); + it('нормальный UTF-8 — ок', () => { expect(verifyEncoding('текст').ok).toBe(true); }); +}); + +describe('buildStepLinks', () => { + it('номера → метка', () => { expect(buildStepLinks([7, 12])).toBe('[→7, →12]'); }); + it('пусто → пустая строка', () => { expect(buildStepLinks([])).toBe(''); }); +}); + +describe('computePeriod', () => { + it('из флажка и текущего хода', () => { + expect(computePeriod({ startedAtTurn: 3 }, 9)).toEqual({ from: 3, to: 9 }); + }); +}); + +describe('renderIndexContext', () => { + it('оборачивает оглавление', () => { + const out = renderIndexContext('- [X](x/protocol.md) — цель · открыто · 2026-06-22'); + expect(out).toContain('Открытые дела'); + expect(out).toContain('x/protocol.md'); + }); + it('пустое — помечает отсутствие', () => { + expect(renderIndexContext('')).toContain('дел пока нет'); + }); +}); diff --git a/tools/secretary-layer1.mjs b/tools/secretary-layer1.mjs new file mode 100644 index 0000000..711231c --- /dev/null +++ b/tools/secretary-layer1.mjs @@ -0,0 +1,13 @@ +// Чистый билдер сырой записи Слоя 1 (§L1). PII вырезается вызывающим хуком до записи; +// чтение источника (transcript_path) — в хук-обёртке. Здесь — только формат. +export function buildRawRecord({ turn, time, session, user, assistant, actions = [] } = {}) { + const acts = Array.isArray(actions) ? actions : []; + const lines = [`=== ХОД turn=${turn} · ${time} · session=${session} ===`, + '[ЮЗЕР]', String(user ?? ''), '[АССИСТЕНТ]', String(assistant ?? '')]; + for (const a of acts) { + lines.push(`[ДЕЙСТВИЕ] ${a.tool} in=${a.input ?? ''}`); + lines.push(`[ВЫДАЧА] ${a.tool}`, String(a.result ?? '')); + } + lines.push('=== КОНЕЦ ХОДА ===', ''); + return lines.join('\n'); +} diff --git a/tools/secretary-layer1.test.mjs b/tools/secretary-layer1.test.mjs new file mode 100644 index 0000000..52d4952 --- /dev/null +++ b/tools/secretary-layer1.test.mjs @@ -0,0 +1,20 @@ +import { describe, it, expect } from 'vitest'; +import { buildRawRecord } from './secretary-layer1.mjs'; + +describe('buildRawRecord', () => { + it('содержит заголовок с turn, реплики и действие', () => { + const rec = buildRawRecord({ + turn: 7, time: '2026-06-22T10:00:00Z', session: 'abc', + user: 'привет', assistant: 'ответ', + actions: [{ tool: 'Read', input: '{"f":"x"}', result: 'текст' }], + }); + expect(rec).toContain('turn=7'); + expect(rec).toContain('[ЮЗЕР]'); + expect(rec).toContain('[ДЕЙСТВИЕ] Read'); + expect(rec.trim().endsWith('=== КОНЕЦ ХОДА ===')).toBe(true); + }); + it('без действий — блок без [ДЕЙСТВИЕ]', () => { + const rec = buildRawRecord({ turn: 1, time: 't', session: 's', user: 'u', assistant: 'a' }); + expect(rec).not.toContain('[ДЕЙСТВИЕ]'); + }); +});