Files
brain/tools/secretary-worker.mjs
T

86 lines
5.0 KiB
JavaScript

#!/usr/bin/env node
// tools/secretary-worker.mjs
// Фон-воркер секретаря: ЕДИНСТВЕННЫЙ писатель протокола темы. Берёт замок темы (занят → выход),
// разбирает спаны из очереди по порядку (skip уже обработанных), пишет protocol.json/.md + оглавление,
// двигает курсор. Вне харнесс-таймаута: тяжёлые модели и полный вход — без 900с-стены.
import { existsSync, readFileSync, mkdirSync } from 'node:fs';
import { join, basename } from 'node:path';
import { writeFileAtomic } from './secretary-layer1.mjs';
import { EMPTY_PROTOCOL } from './secretary-protocol.mjs';
import { assembleSpan } from './secretary-span.mjs';
import { distillSpan } from './secretary-distill.mjs';
import { renderDoc } from './secretary-render-fluffy.mjs';
import { writeIndexSafely } from './secretary-index.mjs';
import { acquireThemeLock } from './secretary-lock.mjs';
import { dequeueSpan, readCursor, writeCursor } from './secretary-queue.mjs';
import { callAnthropicAPI } from './router-classifier.mjs';
function readProto(workDir) {
try { return JSON.parse(readFileSync(join(workDir, 'protocol.json'), 'utf-8')); } catch { return EMPTY_PROTOCOL(); }
}
/** Сердце воркера. Все эффекты через deps — тестируется без процесса и без LLM.
* deps: { secdir, distill(proto, spanEx, span, ctx), render(proto, opts), acquire(workDir), now() }. */
export async function runWorker(workDir, deps) {
const {
secdir,
acquire = (wd) => acquireThemeLock(wd, { graceful: true }),
distill = (proto, spanEx, span, ctx) => distillSpan(proto, spanEx, span, ctx),
render = (proto, opts) => renderDoc(proto, opts),
now = () => Date.now(),
callModel = null,
} = deps || {};
const release = await acquire(workDir);
if (!release) return; // другой воркер уже крутится — он добьёт очередь
try {
const work = basename(workDir);
let job;
while ((job = dequeueSpan(workDir)) !== null) {
const span = job.span;
if (span.index <= readCursor(workDir)) continue; // уже обработан (идемпотентный повтор постановки)
try {
const rawFile = join(secdir, 'raw', `${job.session}.log`);
const rawText = existsSync(rawFile) ? readFileSync(rawFile, 'utf-8') : '';
const spanEx = assembleSpan(rawText, span);
let proto = readProto(workDir);
proto = await distill(proto, spanEx, { ...span }, { callModel, session: job.session });
mkdirSync(workDir, { recursive: true });
writeFileAtomic(join(workDir, 'protocol.json'), JSON.stringify(proto, null, 2));
const stamp = new Date(now()).toISOString().slice(0, 16).replace('T', ' ');
writeFileAtomic(join(workDir, 'protocol.md'), render(proto, { work, date: stamp }));
await writeIndexSafely(join(secdir, 'содержание.md'), {
slug: work, title: work,
goal: (proto.subject && proto.subject.trim()) ? proto.subject.trim() : '(дело)',
status: proto.status || 'открыто', date: stamp,
});
} catch { /* мягкая деградация: спан ядовит (окно модели/парс) — не зацикливаемся, идём дальше */ }
writeCursor(workDir, span.index); // курсор двигаем всегда (даже при деградации) — at-least-once без петли
}
} finally { await release(); }
}
/** Модель для вызова: per-call (req.model из distillSpan = resolveModel(role)) > SECRETARY_LLM_MODEL > undefined. */
export function pickModel(req, env = process.env) {
return (req && typeof req === 'object' && req.model) || env.SECRETARY_LLM_MODEL || undefined;
}
// Реальный callModel: 900с/вызов, 2 повтора. Тяжёлые модели — в фоне, стены нет.
// NB: callAnthropicAPI хардкодит max_tokens=65536 (knob не пробрасывается) — отдельной опции вывода нет.
// Модель — per-call (req.model = resolveModel(role) из distillSpan), иначе 5 слотов decision#4 мертвы.
function realCallModel() {
const apiKey = process.env.SECRETARY_LLM_KEY;
if (!apiKey) return null;
const baseUrl = process.env.SECRETARY_LLM_BASE_URL || undefined;
const perAttemptTimeoutMs = Number(process.env.SECRETARY_LLM_TIMEOUT_MS) || 900_000;
const maxRetries = Number(process.env.SECRETARY_LLM_RETRIES) || 2;
return (req) => callAnthropicAPI(req, { apiKey, baseUrl, model: pickModel(req), perAttemptTimeoutMs, maxRetries });
}
const isCli = (process.argv[1] || '').replace(/\\/g, '/').endsWith('/secretary-worker.mjs');
if (isCli) {
const workDir = process.argv[2];
const secdir = join(workDir, '..');
runWorker(workDir, { secdir, callModel: realCallModel() }).then(() => process.exit(0)).catch(() => process.exit(1));
}