Files
brain/tools/secretary-stop-hook.mjs
T
Дмитрий 99ca60777b feat(secretary): 5 минут на ответ модели в Stop-хуке (perAttemptTimeoutMs 300s, maxRetries 0)
Секретарь пишет длинный протокол — 30с/попытку мало. Даём один заход на 5 минут
без ×5 повторов (иначе потолок раздувается до 25 мин). Общий 30с-дефолт
router/судьи/наставника не тронут — override только в callModel секретаря.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:25:46 +03:00

98 lines
5.1 KiB
JavaScript

#!/usr/bin/env node
// Stop-переходник секретаря: ВСЕГДА пишет сырьё (Слой 1); если секретарь включён —
// онлайн-выжимка в протокол дела через НОВЫЙ мотор (SECRETARY_LLM_KEY).
// Тонкий shell над parseLastExchange / buildRawRecord / reconcileTurn (модель-редактор) /
// renderProtocol / upsertIndexEntry.
import { existsSync, readFileSync, writeFileSync, appendFileSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { parseLastExchange } from './secretary-transcript.mjs';
import { secretaryModeFileName } from './secretary-flag.mjs';
import { buildRawRecord, buildStepLine } from './secretary-layer1.mjs';
import { reconcileTurn } from './secretary-reconcile.mjs';
import { renderProtocol, EMPTY_PROTOCOL } from './secretary-protocol.mjs';
import { upsertIndexEntry } from './secretary-index.mjs';
import { sanitize } from './observer-pii-filter.mjs';
import { callAnthropicAPI } from './router-classifier.mjs';
function readStdin() { try { return readFileSync(0, 'utf-8'); } catch { return ''; } }
function readFlag(session) {
const f = join(homedir(), '.claude', 'runtime', secretaryModeFileName(session));
try { return JSON.parse(readFileSync(f, 'utf-8')); } catch { return { mode: 'off' }; }
}
function turnCount(rawFile) {
if (!existsSync(rawFile)) return 0;
try { return (readFileSync(rawFile, 'utf-8').match(/=== ХОД turn=/g) || []).length; } catch { return 0; }
}
async function main() {
let ev = {};
try { ev = JSON.parse(readStdin() || '{}'); } catch { ev = {}; }
const session = ev.session_id || ev.sessionId || 'unknown';
const tp = ev.transcript_path || ev.transcriptPath;
let transcript = '';
try { if (tp && existsSync(tp)) transcript = readFileSync(tp, 'utf-8'); } catch { transcript = ''; }
const secdir = join(process.cwd(), 'docs', 'secretary');
const rawFile = join(secdir, 'raw', `${session}.log`);
const ex = parseLastExchange(transcript);
const turn = turnCount(rawFile) + 1;
// Слой 1: всегда пишем сырьё (PII вырезается перед записью).
try {
const rec = sanitize(buildRawRecord({
turn, time: new Date().toISOString(), session,
user: ex.user, assistant: ex.assistant, actions: ex.actions,
}));
mkdirSync(join(secdir, 'raw'), { recursive: true });
appendFileSync(rawFile, rec + '\n', 'utf-8');
} catch { /* fail-quiet */ }
// Онлайн-выжимка только если секретарь включён и есть НОВЫЙ ключ.
const flag = readFlag(session);
const apiKey = process.env.SECRETARY_LLM_KEY;
if (flag.mode !== 'on' || !apiKey) { process.exit(0); }
const work = flag.work || 'general';
try {
const workDir = join(secdir, work);
const protoJson = join(workDir, 'protocol.json');
let proto = EMPTY_PROTOCOL();
try { if (existsSync(protoJson)) proto = JSON.parse(readFileSync(protoJson, 'utf-8')); } catch { proto = EMPTY_PROTOCOL(); }
// Модель-редактор правит ВЕСЬ протокол; сторож следит, что ничего не пропало (спека reconcile).
const callModel = (msgs) => callAnthropicAPI(msgs, {
apiKey,
baseUrl: process.env.SECRETARY_LLM_BASE_URL || undefined,
model: process.env.SECRETARY_LLM_MODEL || undefined,
perAttemptTimeoutMs: 300_000, // 5 минут на один ответ модели (секретарь пишет длинный протокол)
maxRetries: 0, // одна попытка, без ×5 повторов (иначе «5 мин» → до 25 мин)
});
const updated = await reconcileTurn({ proto, ex, turn, session, callModel });
if (updated) {
const stamp = new Date().toISOString().slice(0, 16).replace('T', ' ');
// Шаги (Слой 1) ведёт хук: по строке на ход «спросил → ответил» (модель их не трогает).
updated.steps = [...(Array.isArray(updated.steps) ? updated.steps : []),
{ turn, session, text: buildStepLine({ turn, user: ex.user, assistant: ex.assistant, actions: (ex.actions || []).map((a) => a.tool) }) }];
mkdirSync(workDir, { recursive: true });
writeFileSync(protoJson, JSON.stringify(updated, null, 2), 'utf-8');
writeFileSync(join(workDir, 'protocol.md'), renderProtocol(updated, { work, date: stamp }), 'utf-8');
const idxFile = join(secdir, 'содержание.md');
let idxMd = '';
try { if (existsSync(idxFile)) idxMd = readFileSync(idxFile, 'utf-8'); } catch { idxMd = ''; }
const upd = upsertIndexEntry(idxMd, {
slug: work, title: work,
goal: (updated.subject && updated.subject.trim()) ? updated.subject.trim() : '(дело)',
status: updated.status || 'открыто',
date: stamp,
});
writeFileSync(idxFile, upd, 'utf-8');
}
} catch { /* fail-quiet: сырьё уже записано */ }
process.exit(0);
}
const isCli = (process.argv[1] || '').replace(/\\/g, '/').endsWith('/secretary-stop-hook.mjs');
if (isCli) main();