185 lines
10 KiB
JavaScript
185 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
|
// Stop-переходник секретаря: ВСЕГДА пишет сырьё (Слой 1) с ярлычком служебного хода (meta=1);
|
|
// если секретарь включён — отложенный разбор ПО СПАНАМ. Границы спанов берутся из СЫРЬЯ структурно
|
|
// (realBoundariesFromRaw: служебный = meta=1, фолбэк по тексту) — без угадывания по номерам.
|
|
// Закрытые спаны разбираются один раз (курсор в флажке); при mode:'closing' добивается последний
|
|
// открытый спан + нарезка сырья + гашение флажка. Разбор одного спана — общий distillSpan.
|
|
import { existsSync, readFileSync, appendFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
import { join, dirname } from 'node:path';
|
|
import { homedir } from 'node:os';
|
|
import { assembleExchanges, buildRawFromExchanges } from './secretary-transcript.mjs';
|
|
import { secretaryModeFileName } from './secretary-flag.mjs';
|
|
import { upsertSessionPointer } from './secretary-sessions.mjs';
|
|
import { writeFileAtomic, realBoundariesFromRaw, mergeStepsPreservingText, prepareTurnFiles, spanInterruptNote } from './secretary-layer1.mjs';
|
|
import { formatReconcileLogLine } from './secretary-reconcile.mjs';
|
|
import { EMPTY_PROTOCOL } from './secretary-protocol.mjs';
|
|
import { renderDoc } from './secretary-render-fluffy.mjs';
|
|
import { upsertIndexEntry } from './secretary-index.mjs';
|
|
import { sanitize } from './observer-pii-filter.mjs';
|
|
import { callAnthropicAPI } from './router-classifier.mjs';
|
|
import { computeSpans, spansToDistill, assembleSpan } from './secretary-span.mjs';
|
|
import { distillSpan } from './secretary-distill.mjs';
|
|
|
|
function readStdin() { try { return readFileSync(0, 'utf-8'); } catch { return ''; } }
|
|
function flagPath(session) { return join(homedir(), '.claude', 'runtime', secretaryModeFileName(session)); }
|
|
function readFlag(session) {
|
|
try { return JSON.parse(readFileSync(flagPath(session), 'utf-8')); } catch { return { mode: 'off' }; }
|
|
}
|
|
function writeFlag(session, flag) {
|
|
try { writeFileSync(flagPath(session), JSON.stringify(flag)); } catch { /* ignore */ }
|
|
}
|
|
|
|
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 = assembleExchanges(transcript);
|
|
const turn = ex.length;
|
|
|
|
// Слой 1: ВСЕГДА пересобираем сырьё из всего транскрипта (переживает обрывы; PII вырезается).
|
|
// Метки-обрывы не считаются настоящим промптом; продолжение помечается cont=1, хвост — tail=1.
|
|
try {
|
|
const rawContent = buildRawFromExchanges(ex, { session, sanitize });
|
|
mkdirSync(join(secdir, 'raw'), { recursive: true });
|
|
writeFileAtomic(rawFile, rawContent);
|
|
} catch { /* fail-quiet */ }
|
|
|
|
// Тетрадь (Слой 2) — только если секретарь включён или закрывается.
|
|
const flag = readFlag(session);
|
|
if (flag.mode !== 'on' && flag.mode !== 'closing') { process.exit(0); }
|
|
const closing = flag.mode === 'closing';
|
|
|
|
const work = flag.work || 'general';
|
|
const apiKey = process.env.SECRETARY_LLM_KEY;
|
|
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(); }
|
|
|
|
let rawText = '';
|
|
try { rawText = readFileSync(rawFile, 'utf-8'); } catch { rawText = ''; }
|
|
|
|
// Границы спанов — из СЫРЬЯ структурно (meta=1; фолбэк по тексту). Курсор — из флажка.
|
|
const bounds = realBoundariesFromRaw(rawText);
|
|
const cursor = Number.isFinite(flag.spanCursor) ? flag.spanCursor : -1;
|
|
|
|
const list = spansToDistill(bounds, turn, cursor);
|
|
if (closing) {
|
|
const all = computeSpans(bounds, turn).map((s, index) => ({ ...s, index }));
|
|
const lastOpen = all[all.length - 1];
|
|
if (lastOpen && lastOpen.open && lastOpen.index > cursor)
|
|
list.push({ start: lastOpen.start, end: lastOpen.end, index: lastOpen.index });
|
|
}
|
|
|
|
const reLog = join(workDir, '_reconcile.log');
|
|
const logReason = (info) => {
|
|
try {
|
|
mkdirSync(workDir, { recursive: true });
|
|
const t = new Date().toISOString().slice(0, 19).replace('T', ' ');
|
|
appendFileSync(reLog, formatReconcileLogLine({ time: t, ...info }) + '\n', 'utf-8');
|
|
} catch { /* лог вторичен */ }
|
|
};
|
|
const callModel = apiKey
|
|
? (msgs) => callAnthropicAPI(msgs, {
|
|
apiKey,
|
|
baseUrl: process.env.SECRETARY_LLM_BASE_URL || undefined,
|
|
model: process.env.SECRETARY_LLM_MODEL || undefined,
|
|
perAttemptTimeoutMs: 300_000,
|
|
maxRetries: 0,
|
|
})
|
|
: null;
|
|
|
|
// Догон после жёсткого краха: разобрать недоразобранный хвост прошлых (умерших) сессий этого дела.
|
|
// Сессия мертва → её последний (открытый) спан тоже финальный, поэтому берём ВСЕ спаны за курсором.
|
|
let didCatchUp = false;
|
|
if (Array.isArray(flag.catchUp) && flag.catchUp.length && callModel) {
|
|
const projDir = tp ? dirname(tp) : null;
|
|
for (const prev of flag.catchUp) {
|
|
if (!projDir || !prev || !prev.session) continue;
|
|
let prevTranscript = '';
|
|
try {
|
|
const prevTp = join(projDir, `${prev.session}.jsonl`);
|
|
if (existsSync(prevTp)) prevTranscript = readFileSync(prevTp, 'utf-8');
|
|
} catch { prevTranscript = ''; }
|
|
if (!prevTranscript) continue;
|
|
const prevRaw = buildRawFromExchanges(assembleExchanges(prevTranscript), { session: prev.session, sanitize });
|
|
try { mkdirSync(join(secdir, 'raw'), { recursive: true }); writeFileAtomic(join(secdir, 'raw', `${prev.session}.log`), prevRaw); } catch { /* ignore */ }
|
|
const prevBounds = realBoundariesFromRaw(prevRaw);
|
|
const prevLast = (prevRaw.match(/=== ХОД turn=/g) || []).length;
|
|
const prevCursor = Number.isFinite(prev.cursor) ? prev.cursor : -1;
|
|
for (const span of computeSpans(prevBounds, prevLast).map((s, index) => ({ ...s, index }))) {
|
|
if (span.index <= prevCursor) continue;
|
|
const spanEx = assembleSpan(prevRaw, span);
|
|
const note = spanInterruptNote(prevRaw, span);
|
|
proto = await distillSpan(proto, spanEx, { ...span, note }, { callModel, session: prev.session, diag: logReason });
|
|
}
|
|
}
|
|
writeFlag(session, { ...readFlag(session), catchUp: [] });
|
|
didCatchUp = true;
|
|
}
|
|
|
|
// Обычный ход без новых закрытых спанов и без догона — тетрадь не трогаем (отставание на промпт).
|
|
if (!list.length && !closing && !didCatchUp) { process.exit(0); }
|
|
|
|
// Разбор каждого завершённого спана по порядку (общий distillSpan: reconcile + аудит на ПОЛНОМ спане).
|
|
let lastIndex = cursor;
|
|
for (const span of list) {
|
|
const spanEx = assembleSpan(rawText, span);
|
|
const note = spanInterruptNote(rawText, span);
|
|
proto = await distillSpan(proto, spanEx, { ...span, note }, { callModel, session, diag: logReason });
|
|
lastIndex = span.index;
|
|
}
|
|
|
|
const finalProto = proto;
|
|
const stamp = new Date().toISOString().slice(0, 16).replace('T', ' ');
|
|
mkdirSync(workDir, { recursive: true });
|
|
writeFileAtomic(protoJson, JSON.stringify(finalProto, null, 2));
|
|
writeFileAtomic(join(workDir, 'protocol.md'), renderDoc(finalProto, { work, date: stamp, turn, realPromptTurns: bounds }));
|
|
|
|
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: (finalProto.subject && finalProto.subject.trim()) ? finalProto.subject.trim() : '(дело)',
|
|
status: finalProto.status || 'открыто',
|
|
date: stamp,
|
|
});
|
|
writeFileAtomic(idxFile, upd);
|
|
|
|
if (closing) {
|
|
// Финализация: нарезка сырья на файлы ходов (по ходу) + Шаги по спанам, затем гашение флажка.
|
|
finalProto.steps = mergeStepsPreservingText(finalProto.steps, rawText, session, bounds);
|
|
const { files, steps } = prepareTurnFiles(rawText, finalProto);
|
|
const hodyDir = join(workDir, 'ходы');
|
|
mkdirSync(hodyDir, { recursive: true });
|
|
for (const f of files) writeFileSync(join(hodyDir, f.name), f.content, 'utf-8');
|
|
finalProto.steps = steps;
|
|
writeFileAtomic(protoJson, JSON.stringify(finalProto, null, 2));
|
|
writeFileAtomic(join(workDir, 'protocol.md'), renderDoc(finalProto, { work, date: stamp, turn, realPromptTurns: bounds }));
|
|
writeFlag(session, { mode: 'off' });
|
|
} else {
|
|
// Обычный ход: сохранить продвинутый курсор (прочие поля флажка целы).
|
|
writeFlag(session, { ...readFlag(session), spanCursor: lastIndex });
|
|
// Указатель сессии дела (для догона будущих сессий после краха).
|
|
try {
|
|
const sf = join(workDir, '_sessions.json');
|
|
let arr = [];
|
|
try { if (existsSync(sf)) arr = JSON.parse(readFileSync(sf, 'utf-8')); } catch { arr = []; }
|
|
writeFileAtomic(sf, JSON.stringify(upsertSessionPointer(arr, { session, cursor: lastIndex })));
|
|
} catch { /* указатель вторичен */ }
|
|
}
|
|
} catch { /* fail-quiet: сырьё уже записано */ }
|
|
process.exit(0);
|
|
}
|
|
|
|
const isCli = (process.argv[1] || '').replace(/\\/g, '/').endsWith('/secretary-stop-hook.mjs');
|
|
if (isCli) main();
|