feat(secretary): захват выдачи инструмента (N3) + сверка имени дела при включении (N2)
- parseLastExchange привязывает результат инструмента к действию по tool_use_id, склеивает text-блоки, усекает до 1200 симв.; [ВЫДАЧА] в Слое 1 теперь наполняется - resolveCaseActivation: похожее имя дела (опечатка/подстрока) -> переспросить, не заводя дело-двойник; хук secretary-prompt-hook выводит подсказку с кандидатами - TDD: тесты secretary-transcript/flag/prompt-hook; полный свод зелёный Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -19,3 +19,38 @@ export function detectSecretaryCommand(promptText) {
|
||||
export function secretaryModeFileName(session) {
|
||||
return `secretary-mode-${session || 'unknown'}.json`;
|
||||
}
|
||||
|
||||
// Расстояние Левенштейна (для ловли опечатки в имени дела).
|
||||
function levenshtein(a, b) {
|
||||
const m = a.length, n = b.length;
|
||||
const d = Array.from({ length: m + 1 }, (_, i) => { const row = new Array(n + 1).fill(0); row[0] = i; return row; });
|
||||
for (let j = 0; j <= n; j++) d[0][j] = j;
|
||||
for (let i = 1; i <= m; i++) {
|
||||
for (let j = 1; j <= n; j++) {
|
||||
d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
||||
}
|
||||
}
|
||||
return d[m][n];
|
||||
}
|
||||
|
||||
// «Похоже» = опечатка (правка ≤2 при длине обоих ≥4) ИЛИ сокращение (подстрока, длина короткого ≥3).
|
||||
function isSimilar(a, b) {
|
||||
if (a === b) return false;
|
||||
const short = Math.min(a.length, b.length);
|
||||
if (short >= 3 && (a.includes(b) || b.includes(a))) return true;
|
||||
if (a.length >= 4 && b.length >= 4 && levenshtein(a, b) <= 2) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Сверка введённого имени дела со списком существующих (папки docs/secretary/<дело>).
|
||||
// Точное совпадение → активировать существующее; похоже, но не точно → переспросить;
|
||||
// не похоже / список пуст → активировать как новое (имя как ввёл).
|
||||
export function resolveCaseActivation(requested, existing = []) {
|
||||
const req = String(requested || '').trim().toLowerCase();
|
||||
const list = (existing || []).map((e) => String(e || '').trim()).filter(Boolean);
|
||||
const exact = list.find((e) => e.toLowerCase() === req);
|
||||
if (exact) return { action: 'activate', work: exact };
|
||||
const candidates = list.filter((e) => isSimilar(e.toLowerCase(), req));
|
||||
if (candidates.length > 0) return { action: 'confirm', candidates };
|
||||
return { action: 'activate', work: String(requested || '').trim() };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { detectSecretaryCommand, secretaryModeFileName } from './secretary-flag.mjs';
|
||||
import { detectSecretaryCommand, secretaryModeFileName, resolveCaseActivation } from './secretary-flag.mjs';
|
||||
|
||||
describe('detectSecretaryCommand', () => {
|
||||
it('распознаёт включение', () => {
|
||||
@@ -24,3 +24,28 @@ describe('secretaryModeFileName — флажок по сессии', () => {
|
||||
expect(secretaryModeFileName()).toBe('secretary-mode-unknown.json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveCaseActivation — сверка имени дела со списком существующих', () => {
|
||||
const existing = ['general', 'создание-секретаря', 'строительство-секретаря'];
|
||||
it('точное совпадение — активировать существующее', () => {
|
||||
expect(resolveCaseActivation('создание-секретаря', existing))
|
||||
.toEqual({ action: 'activate', work: 'создание-секретаря' });
|
||||
});
|
||||
it('нет похожих — активировать как новое (имя как ввёл)', () => {
|
||||
expect(resolveCaseActivation('биллинг', existing))
|
||||
.toEqual({ action: 'activate', work: 'биллинг' });
|
||||
});
|
||||
it('опечатка (правка ≤2) — переспросить с кандидатом', () => {
|
||||
const r = resolveCaseActivation('создание-секретар', existing);
|
||||
expect(r.action).toBe('confirm');
|
||||
expect(r.candidates).toContain('создание-секретаря');
|
||||
});
|
||||
it('сокращение (подстрока) — переспросить', () => {
|
||||
const r = resolveCaseActivation('создание', existing);
|
||||
expect(r.action).toBe('confirm');
|
||||
expect(r.candidates).toContain('создание-секретаря');
|
||||
});
|
||||
it('пустой список дел — активировать как новое', () => {
|
||||
expect(resolveCaseActivation('новое', [])).toEqual({ action: 'activate', work: 'новое' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
// UserPromptSubmit-переходник секретаря: ловит «включи/выключи секретаря».
|
||||
// Тонкий shell над чистым detectSecretaryCommand. Нарезка steps/ убрана: навигация идёт
|
||||
// прямо в raw/<session>.log по провенансу с сессией (метка @<session> рядом с [→N]).
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { detectSecretaryCommand, secretaryModeFileName } from './secretary-flag.mjs';
|
||||
import { detectSecretaryCommand, secretaryModeFileName, resolveCaseActivation } from './secretary-flag.mjs';
|
||||
import { prepareTurnFiles, buildStepsFromRaw } from './secretary-layer1.mjs';
|
||||
import { renderProtocol } from './secretary-protocol.mjs';
|
||||
|
||||
@@ -15,6 +15,28 @@ function turnCount(rawFile) {
|
||||
try { return (readFileSync(rawFile, 'utf-8').match(/=== ХОД turn=/g) || []).length; } catch { return 0; }
|
||||
}
|
||||
|
||||
// Список существующих дел: директории в docs/secretary, кроме raw и не-директорий.
|
||||
function listCases(secdir) {
|
||||
try {
|
||||
return readdirSync(secdir, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory() && d.name !== 'raw')
|
||||
.map((d) => d.name);
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
// Решение хука на «включи»: активировать (флажок on) либо переспросить (имя похоже на
|
||||
// существующее дело). Чистая функция — вынесена ради теста; main() её исполняет с реальными fs.
|
||||
export function planActivation({ requested, existing = [], startedAtTurn = 0, session } = {}) {
|
||||
const res = resolveCaseActivation(requested, existing);
|
||||
if (res.action === 'confirm') {
|
||||
const context = `📒 Секретарь: имя дела «${requested}» похоже на существующее: ${res.candidates.join(', ')}.\n`
|
||||
+ 'Если это оно — повтори: «включи секретаря <точное-имя>». '
|
||||
+ 'Если новое дело — повтори с именем, не совпадающим с этими.';
|
||||
return { confirm: true, candidates: res.candidates, context };
|
||||
}
|
||||
return { confirm: false, flag: { mode: 'on', startedAtTurn, work: res.work, session } };
|
||||
}
|
||||
|
||||
function main() {
|
||||
let ev = {};
|
||||
try { ev = JSON.parse(readStdin() || '{}'); } catch { ev = {}; }
|
||||
@@ -30,8 +52,17 @@ function main() {
|
||||
|
||||
if (cmd === 'on') {
|
||||
const m = prompt.match(/секретар[а-я]*\s+(?:для\s+|по\s+)?([a-zA-Zа-яёА-ЯЁ0-9-]{2,})/);
|
||||
const work = (m && m[1]) || 'general';
|
||||
try { writeFileSync(FLAG, JSON.stringify({ mode: 'on', startedAtTurn: turnCount(rawFile), work, session })); } catch { /* ignore */ }
|
||||
const requested = (m && m[1]) || 'general';
|
||||
const plan = planActivation({
|
||||
requested, existing: listCases(secdir),
|
||||
startedAtTurn: turnCount(rawFile), session,
|
||||
});
|
||||
if (plan.confirm) {
|
||||
// Похоже на существующее дело — НЕ включаем, переспрашиваем (защита от дела-двойника).
|
||||
try { process.stdout.write(plan.context + '\n'); } catch { /* fail-quiet */ }
|
||||
process.exit(0);
|
||||
}
|
||||
try { writeFileSync(FLAG, JSON.stringify(plan.flag)); } catch { /* ignore */ }
|
||||
} else if (cmd === 'off') {
|
||||
// Остановка: режем общий сырой лог на отдельные файлы ходов в «<дело>/ходы/» и проставляем
|
||||
// в каждый Шаг ссылку «ходы/turn-N.log» (поднять один ход = открыть один маленький файл).
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { planActivation } from './secretary-prompt-hook.mjs';
|
||||
|
||||
describe('planActivation — решение хука: активировать или переспросить', () => {
|
||||
it('новое имя (нет похожих) — флажок on с work', () => {
|
||||
const r = planActivation({ requested: 'биллинг', existing: ['general'], startedAtTurn: 3, session: 's1' });
|
||||
expect(r.confirm).toBe(false);
|
||||
expect(r.flag).toEqual({ mode: 'on', startedAtTurn: 3, work: 'биллинг', session: 's1' });
|
||||
});
|
||||
it('точное совпадение — флажок on с существующим именем', () => {
|
||||
const r = planActivation({ requested: 'general', existing: ['general'], startedAtTurn: 0, session: 's2' });
|
||||
expect(r.confirm).toBe(false);
|
||||
expect(r.flag.work).toBe('general');
|
||||
});
|
||||
it('похожее имя — переспросить: флажок не ставить, дать кандидатов и подсказку', () => {
|
||||
const r = planActivation({ requested: 'создание', existing: ['создание-секретаря'], startedAtTurn: 1, session: 's3' });
|
||||
expect(r.confirm).toBe(true);
|
||||
expect(r.flag).toBeUndefined();
|
||||
expect(r.candidates).toContain('создание-секретаря');
|
||||
expect(r.context).toContain('создание-секретаря');
|
||||
});
|
||||
});
|
||||
@@ -23,7 +23,24 @@ function isRealUserPrompt(msg) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Последний обмен из стенограммы: { user, assistant, actions:[{tool,input}] }. */
|
||||
// Текст результата инструмента: строка как есть; массив блоков → склейка text-блоков.
|
||||
const MAX_RESULT_CHARS = 1200;
|
||||
function resultText(content) {
|
||||
if (typeof content === 'string') return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content.filter((b) => b && b.type === 'text' && typeof b.text === 'string')
|
||||
.map((b) => b.text).join('\n');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
function truncateResult(s) {
|
||||
const t = String(s ?? '');
|
||||
return t.length > MAX_RESULT_CHARS ? t.slice(0, MAX_RESULT_CHARS) + '…' : t;
|
||||
}
|
||||
|
||||
/** Последний обмен из стенограммы: { user, assistant, actions:[{tool,input,result?}] }.
|
||||
* result привязывается к действию по tool_use.id === tool_result.tool_use_id (усечён до предела);
|
||||
* без совпадения действие остаётся прежней формы {tool,input} — без ключа result. */
|
||||
export function parseLastExchange(transcriptText) {
|
||||
const entries = parseLines(transcriptText);
|
||||
let u = -1;
|
||||
@@ -38,19 +55,31 @@ export function parseLastExchange(transcriptText) {
|
||||
: '');
|
||||
|
||||
let assistant = '';
|
||||
const actions = [];
|
||||
const raw = []; // {id, tool, input} — вызовы инструментов
|
||||
const results = {}; // tool_use_id -> текст результата (из tool_result в сообщениях role:user)
|
||||
for (let i = u + 1; i < entries.length; i++) {
|
||||
const m = entries[i] && entries[i].message;
|
||||
if (!m || m.role !== 'assistant') continue;
|
||||
if (!m) continue;
|
||||
const c = m.content;
|
||||
if (Array.isArray(c)) {
|
||||
for (const b of c) {
|
||||
if (b && b.type === 'text' && b.text) assistant += (assistant ? '\n' : '') + b.text;
|
||||
if (b && b.type === 'tool_use') actions.push({ tool: b.name, input: JSON.stringify(b.input ?? {}) });
|
||||
if (m.role === 'assistant') {
|
||||
if (Array.isArray(c)) {
|
||||
for (const b of c) {
|
||||
if (b && b.type === 'text' && b.text) assistant += (assistant ? '\n' : '') + b.text;
|
||||
if (b && b.type === 'tool_use') raw.push({ id: b.id, tool: b.name, input: JSON.stringify(b.input ?? {}) });
|
||||
}
|
||||
} else if (typeof c === 'string') {
|
||||
assistant += (assistant ? '\n' : '') + c;
|
||||
}
|
||||
} else if (m.role === 'user' && Array.isArray(c)) {
|
||||
for (const b of c) {
|
||||
if (b && b.type === 'tool_result' && b.tool_use_id != null) results[b.tool_use_id] = resultText(b.content);
|
||||
}
|
||||
} else if (typeof c === 'string') {
|
||||
assistant += (assistant ? '\n' : '') + c;
|
||||
}
|
||||
}
|
||||
const actions = raw.map((a) => {
|
||||
const out = { tool: a.tool, input: a.input };
|
||||
if (a.id != null && results[a.id] != null) out.result = truncateResult(results[a.id]);
|
||||
return out;
|
||||
});
|
||||
return { user, assistant, actions };
|
||||
}
|
||||
|
||||
@@ -39,3 +39,50 @@ describe('parseLastExchange', () => {
|
||||
expect(ex.actions).toEqual([{ tool: 'Read', input: '{"f":"a"}' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseLastExchange — захват выдачи инструмента (tool_result по tool_use_id)', () => {
|
||||
it('привязывает результат к действию по совпадающему id', () => {
|
||||
const t = [
|
||||
JSON.stringify({ message: { role: 'user', content: 'вопрос' } }),
|
||||
JSON.stringify({ message: { role: 'assistant', content: [
|
||||
{ type: 'tool_use', id: 'tu_1', name: 'Read', input: { f: 'a' } }] } }),
|
||||
JSON.stringify({ message: { role: 'user', content: [
|
||||
{ type: 'tool_result', tool_use_id: 'tu_1', content: 'СОДЕРЖИМОЕ ФАЙЛА' }] } }),
|
||||
].join('\n');
|
||||
const ex = parseLastExchange(t);
|
||||
expect(ex.actions).toEqual([{ tool: 'Read', input: '{"f":"a"}', result: 'СОДЕРЖИМОЕ ФАЙЛА' }]);
|
||||
});
|
||||
it('результат из массива text-блоков склеивается', () => {
|
||||
const t = [
|
||||
JSON.stringify({ message: { role: 'user', content: 'в' } }),
|
||||
JSON.stringify({ message: { role: 'assistant', content: [
|
||||
{ type: 'tool_use', id: 'tu_9', name: 'Bash', input: {} }] } }),
|
||||
JSON.stringify({ message: { role: 'user', content: [
|
||||
{ type: 'tool_result', tool_use_id: 'tu_9', content: [{ type: 'text', text: 'строка вывода' }] }] } }),
|
||||
].join('\n');
|
||||
const ex = parseLastExchange(t);
|
||||
expect(ex.actions[0].result).toBe('строка вывода');
|
||||
});
|
||||
it('длинный результат усечён и оканчивается маркером …', () => {
|
||||
const big = 'x'.repeat(5000);
|
||||
const t = [
|
||||
JSON.stringify({ message: { role: 'user', content: 'в' } }),
|
||||
JSON.stringify({ message: { role: 'assistant', content: [
|
||||
{ type: 'tool_use', id: 'tu_2', name: 'Read', input: {} }] } }),
|
||||
JSON.stringify({ message: { role: 'user', content: [
|
||||
{ type: 'tool_result', tool_use_id: 'tu_2', content: big }] } }),
|
||||
].join('\n');
|
||||
const ex = parseLastExchange(t);
|
||||
expect(ex.actions[0].result.length).toBeLessThan(big.length);
|
||||
expect(ex.actions[0].result.endsWith('…')).toBe(true);
|
||||
});
|
||||
it('без совпадающего id результат не привязывается — старая форма {tool,input} цела', () => {
|
||||
const t = [
|
||||
JSON.stringify({ message: { role: 'user', content: 'в' } }),
|
||||
JSON.stringify({ message: { role: 'assistant', content: [
|
||||
{ type: 'tool_use', id: 'tu_3', name: 'Read', input: { f: 'z' } }] } }),
|
||||
].join('\n');
|
||||
const ex = parseLastExchange(t);
|
||||
expect(ex.actions).toEqual([{ tool: 'Read', input: '{"f":"z"}' }]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user