111 lines
6.6 KiB
JavaScript
111 lines
6.6 KiB
JavaScript
#!/usr/bin/env node
|
|
// Stop-переходник секретаря (подпроект B): ВСЕГДА пишет сырьё (Слой 1) с ярлычком служебного хода (meta=1).
|
|
// LLM НЕ зовёт и протокол НЕ пишет — это делает фон-воркер. Хук лишь ставит закрытые спаны (и хвосты
|
|
// мёртвых сессий) в очередь темы и «выстреливает» воркера (detached). Ход владельца возвращается мгновенно.
|
|
// Курсор обработки — вотчина воркера (_worker/cursor.json); хук его только читает, чтобы знать что ставить.
|
|
import { existsSync, readFileSync, 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 } from './secretary-layer1.mjs';
|
|
import { sanitize } from './observer-pii-filter.mjs';
|
|
import { enqueueSpan, readCursor } from './secretary-queue.mjs';
|
|
import { spansToEnqueue } from './secretary-enqueue.mjs';
|
|
import { spawnWorker } from './secretary-spawn.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 вырезается).
|
|
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';
|
|
try {
|
|
const workDir = join(secdir, work);
|
|
let rawText = '';
|
|
try { rawText = readFileSync(rawFile, 'utf-8'); } catch { rawText = ''; }
|
|
|
|
// Границы спанов — из СЫРЬЯ структурно (meta=1; фолбэк по тексту). Курсор обработки — у воркера.
|
|
const bounds = realBoundariesFromRaw(rawText);
|
|
const cursor = readCursor(workDir);
|
|
|
|
// Догон хвостов мёртвых сессий этого дела — тоже через очередь (хук LLM не зовёт).
|
|
// Сессия мертва → её последний (открытый) спан тоже финальный, поэтому closing:true (берём все за курсором).
|
|
if (Array.isArray(flag.catchUp) && flag.catchUp.length) {
|
|
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 job of spansToEnqueue({ rawBounds: prevBounds, lastTurn: prevLast, cursor: prevCursor, session: prev.session, closing: true }))
|
|
enqueueSpan(workDir, job);
|
|
}
|
|
writeFlag(session, { ...readFlag(session), catchUp: [] });
|
|
}
|
|
|
|
// Новые закрытые спаны живой сессии (+ последний открытый при закрытии) → в очередь темы.
|
|
for (const job of spansToEnqueue({ rawBounds: bounds, lastTurn: turn, cursor, session, closing }))
|
|
enqueueSpan(workDir, job);
|
|
|
|
// Выстрел воркера (детач). Уже крутится — упрётся в замок темы и выйдет.
|
|
try { spawnWorker(workDir); } catch { /* выстрел вторичен — следующий ход повторит */ }
|
|
|
|
if (closing) {
|
|
writeFlag(session, { mode: 'off' });
|
|
} else {
|
|
// Указатель сессии дела (для догона будущих сессий после краха). Курсор — вотчина воркера,
|
|
// читаем его текущее значение (может отставать — для догона это безопасно: дедуп + skip по курсору).
|
|
try {
|
|
const sf = join(workDir, '_sessions.json');
|
|
let arr = [];
|
|
try { if (existsSync(sf)) arr = JSON.parse(readFileSync(sf, 'utf-8')); } catch { arr = []; }
|
|
mkdirSync(workDir, { recursive: true });
|
|
writeFileAtomic(sf, JSON.stringify(upsertSessionPointer(arr, { session, cursor: readCursor(workDir) })));
|
|
} catch { /* указатель вторичен */ }
|
|
}
|
|
} catch { /* fail-quiet: сырьё уже записано */ }
|
|
process.exit(0);
|
|
}
|
|
|
|
const isCli = (process.argv[1] || '').replace(/\\/g, '/').endsWith('/secretary-stop-hook.mjs');
|
|
if (isCli) main();
|