#!/usr/bin/env node /** * PreCompact Hook - Triggered before context compression * * Three critical jobs: * 1. Save rich checkpoint to session note — work items, state, meaningful rename * 2. Update TODO.md with a proper ## Continue section for the next session * 3. Save session state to temp file for post-compact injection via SessionStart(compact) * * Uses a CUMULATIVE state file (.compact-state.json) that persists across * compactions. This ensures that even after multiple compactions (where the * transcript becomes thin), we still have rich data for titles, summaries, * and work items from earlier in the session. */ import { isWorkerSession } from "../lib/worker-session.js"; import { existsSync, readFileSync, writeFileSync } from 'fs'; import { basename, dirname, join } from 'path'; import { tmpdir } from 'os'; import { connect } from 'net'; import { randomUUID } from 'crypto'; import { sendNtfyNotification, getCurrentNotePath, createSessionNote, appendCheckpoint, addWorkToSessionNote, isMeaningfulTitle, findNotesDir, renameSessionNote, updateTodoContinue, isProbeSession, WorkItem, } from '../lib/project-utils'; import { getContextFill, formatContextFill } from '../lib/context-fill.js'; import { contentToText, isNoiseFilePath, preferCwdFiles } from '../lib/transcript-text.js'; import { readContextHandoverCache } from '../lib/context-handover-cache.js'; import { bindHandoverEvidence } from '../lib/handover-evidence.js'; interface HookInput { session_id: string; transcript_path: string; cwd?: string; hook_event_name: string; compact_type?: string; trigger?: string; /** Session's scratchpad directory, when the harness provides one — used to * exclude scratch files from the "Files modified" list (see * isNoiseFilePath). */ scratchpad_dir?: string; } const DAEMON_SOCKET = process.env.PAI_SOCKET ?? '/tmp/pai.sock'; const DAEMON_TIMEOUT_MS = 3_000; /** Structured data extracted from a transcript in a single pass. */ interface TranscriptData { userMessages: string[]; summaries: string[]; captures: string[]; lastCompleted: string; filesModified: string[]; /** Count of modified-file paths dropped as noise (tmp/jobs/scratchpad). */ filesExcluded: number; workItems: WorkItem[]; } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- // // contentToText, isNoiseFilePath, and preferCwdFiles live in // ../lib/transcript-text.ts, not here — this file calls `main()` and // `process.exit()` at import time, which makes it unsafe to import from a // test. Splitting the pure, bug-fixed logic out is what lets those two bugs // (the "[object Object]" content bug and the tmp/jobs noise-crowding bug) // carry a real regression test instead of only a manual before/after digest. function getTranscriptStats(transcriptPath: string): { messageCount: number; isLarge: boolean } { try { const content = readFileSync(transcriptPath, 'utf-8'); const lines = content.trim().split('\n'); let userMessages = 0; let assistantMessages = 0; for (const line of lines) { if (!line.trim()) continue; try { const entry = JSON.parse(line); if (entry.type === 'user') userMessages++; else if (entry.type === 'assistant') assistantMessages++; } catch { /* skip */ } } const totalMessages = userMessages + assistantMessages; return { messageCount: totalMessages, isLarge: totalMessages > 50 }; } catch { return { messageCount: 0, isLarge: false }; } } // --------------------------------------------------------------------------- // Unified transcript parser — single pass extracts everything // --------------------------------------------------------------------------- function parseTranscript(transcriptPath: string, scratchpadDir?: string): TranscriptData { const data: TranscriptData = { userMessages: [], summaries: [], captures: [], lastCompleted: '', filesModified: [], filesExcluded: 0, workItems: [], }; try { const raw = readFileSync(transcriptPath, 'utf-8'); const lines = raw.trim().split('\n'); const seenSummaries = new Set(); for (const line of lines) { if (!line.trim()) continue; let entry: any; try { entry = JSON.parse(line); } catch { continue; } // --- User messages --- if (entry.type === 'user' && entry.message?.content) { const text = contentToText(entry.message.content).slice(0, 300); if (text) data.userMessages.push(text); } // --- Assistant content --- if (entry.type === 'assistant' && entry.message?.content) { const text = contentToText(entry.message.content); // Summaries → also create work items const summaryMatch = text.match(/SUMMARY:\s*(.+?)(?:\n|$)/i); if (summaryMatch) { const s = summaryMatch[1].trim(); if (s.length > 5 && !data.summaries.includes(s)) { data.summaries.push(s); if (!seenSummaries.has(s)) { seenSummaries.add(s); const details: string[] = []; const actionsMatch = text.match(/ACTIONS:\s*(.+?)(?=\n[A-Z]+:|$)/is); if (actionsMatch) { const actionLines = actionsMatch[1].split('\n') .map(l => l.replace(/^[-*•]\s*/, '').replace(/^\d+\.\s*/, '').trim()) .filter(l => l.length > 3 && l.length < 100); details.push(...actionLines.slice(0, 3)); } data.workItems.push({ title: s, details: details.length > 0 ? details : undefined, completed: true }); } } } // Captures const captureMatch = text.match(/CAPTURE:\s*(.+?)(?:\n|$)/i); if (captureMatch) { const c = captureMatch[1].trim(); if (c.length > 5 && !data.captures.includes(c)) data.captures.push(c); } // Completed const completedMatch = text.match(/COMPLETED:\s*(.+?)(?:\n|$)/i); if (completedMatch) { data.lastCompleted = completedMatch[1].trim().replace(/\*+/g, ''); if (data.workItems.length === 0 && !seenSummaries.has(data.lastCompleted) && data.lastCompleted.length > 5) { seenSummaries.add(data.lastCompleted); data.workItems.push({ title: data.lastCompleted, completed: true }); } } // File modifications (from tool_use blocks) if (Array.isArray(entry.message.content)) { for (const block of entry.message.content) { if (block.type === 'tool_use') { const tool = block.name; if ((tool === 'Edit' || tool === 'Write') && block.input?.file_path) { const fp = block.input.file_path; if (isNoiseFilePath(fp, scratchpadDir)) { data.filesExcluded++; } else if (!data.filesModified.includes(fp)) { data.filesModified.push(fp); } } } } } } } } catch (err) { console.error(`parseTranscript error: ${err}`); } return data; } // --------------------------------------------------------------------------- // Format session state as human-readable string // --------------------------------------------------------------------------- function formatSessionState(data: TranscriptData, cwd?: string): string | null { const parts: string[] = []; if (cwd) parts.push(`Working directory: ${cwd}`); const recentUser = data.userMessages.slice(-3); if (recentUser.length > 0) { parts.push('\nRecent user requests:'); for (const msg of recentUser) { parts.push(`- ${msg.split('\n')[0].slice(0, 200)}`); } } const recentSummaries = data.summaries.slice(-3); if (recentSummaries.length > 0) { parts.push('\nWork summaries:'); for (const s of recentSummaries) parts.push(`- ${s.slice(0, 150)}`); } const recentCaptures = data.captures.slice(-5); if (recentCaptures.length > 0) { parts.push('\nCaptured context:'); for (const c of recentCaptures) parts.push(`- ${c.slice(0, 150)}`); } // Noise (tmp/jobs/scratchpad) is already excluded at collection time — see // isNoiseFilePath. Here, prefer files inside the session's own working // directory when there are more than fit in the slice, so an unrelated // repo touched in passing doesn't crowd out the files that matter. const files = preferCwdFiles(data.filesModified, cwd).slice(-10); if (files.length > 0) { parts.push('\nFiles modified this session:'); for (const f of files) parts.push(`- ${f}`); } else if (data.filesExcluded > 0) { parts.push( `\nFiles modified this session: none in the working directory ` + `(excluded ${data.filesExcluded} noise path(s) under tmp/jobs/scratchpad)` ); } if (data.lastCompleted) { parts.push(`\nLast completed: ${data.lastCompleted.slice(0, 150)}`); } const result = parts.join('\n'); return result.length > 50 ? result : null; } // --------------------------------------------------------------------------- // Derive a meaningful title for the session note // --------------------------------------------------------------------------- function deriveTitle(data: TranscriptData): string { // Collect candidates in priority order, then pick the first meaningful one. const candidates: string[] = []; // 1. Work item titles (most descriptive of what was accomplished) for (let i = data.workItems.length - 1; i >= 0; i--) { candidates.push(data.workItems[i].title); } // 2. Summaries for (let i = data.summaries.length - 1; i >= 0; i--) { candidates.push(data.summaries[i]); } // 3. Last completed marker if (data.lastCompleted && data.lastCompleted.length > 5) { candidates.push(data.lastCompleted); } // 4. User messages (FIRST meaningful one, not last — first is more likely // to describe the session's purpose; last is often system noise) for (const msg of data.userMessages) { const line = msg.split('\n')[0].trim(); if (line.length > 10 && line.length < 80 && !line.toLowerCase().startsWith('yes') && !line.toLowerCase().startsWith('ok')) { candidates.push(line); } } // 5. Derive from files modified (fallback) if (data.filesModified.length > 0) { const basenames = data.filesModified.slice(-5).map(f => { const b = basename(f); return b.replace(/\.[^.]+$/, ''); }); const unique = [...new Set(basenames)]; candidates.push( unique.length <= 3 ? `Updated ${unique.join(', ')}` : `Modified ${data.filesModified.length} files` ); } // Pick the first candidate that passes the meaningfulness filter for (const raw of candidates) { const cleaned = raw .replace(/[^\w\s-]/g, ' ') // Remove special chars .replace(/\s+/g, ' ') // Normalize whitespace .trim() .substring(0, 60); if (cleaned.length >= 5 && isMeaningfulTitle(cleaned)) { return cleaned; } } // All candidates were garbage — return empty (caller will not rename) return ''; } // --------------------------------------------------------------------------- // Cumulative state — persists across compactions in .compact-state.json // --------------------------------------------------------------------------- const CUMULATIVE_STATE_FILE = '.compact-state.json'; function loadCumulativeState(notesDir: string): TranscriptData | null { try { const filePath = join(notesDir, CUMULATIVE_STATE_FILE); if (!existsSync(filePath)) return null; const raw = JSON.parse(readFileSync(filePath, 'utf-8')); return { userMessages: raw.userMessages || [], summaries: raw.summaries || [], captures: raw.captures || [], lastCompleted: raw.lastCompleted || '', filesModified: raw.filesModified || [], filesExcluded: raw.filesExcluded || 0, workItems: raw.workItems || [], }; } catch { return null; } } /** * Peek at `.compact-state.json`'s `lastUpdated` — the timestamp of THIS * session's previous compaction — without pulling it into TranscriptData's * contract. Read before `saveCumulativeState` overwrites the file for the * current compaction, so callers can tell "a cached handover generated * since the last compaction" from "a cached handover left over from before * it, now stale". Returns null when there is no previous compaction (the * first one this session) — treated as "any cached handover is fresh". */ function readPreviousCompactionTimestamp(notesDir: string): string | null { try { const filePath = join(notesDir, CUMULATIVE_STATE_FILE); if (!existsSync(filePath)) return null; const raw = JSON.parse(readFileSync(filePath, 'utf-8')); return typeof raw.lastUpdated === 'string' ? raw.lastUpdated : null; } catch { return null; } } function mergeTranscriptData(accumulated: TranscriptData | null, current: TranscriptData): TranscriptData { if (!accumulated) return current; const mergeArrays = (a: string[], b: string[]): string[] => { const seen = new Set(a); return [...a, ...b.filter(x => !seen.has(x))]; }; const seenTitles = new Set(accumulated.workItems.map(w => w.title)); const newWorkItems = current.workItems.filter(w => !seenTitles.has(w.title)); return { userMessages: mergeArrays(accumulated.userMessages, current.userMessages).slice(-20), summaries: mergeArrays(accumulated.summaries, current.summaries), captures: mergeArrays(accumulated.captures, current.captures), lastCompleted: current.lastCompleted || accumulated.lastCompleted, filesModified: mergeArrays(accumulated.filesModified, current.filesModified), filesExcluded: accumulated.filesExcluded + current.filesExcluded, workItems: [...accumulated.workItems, ...newWorkItems], }; } function saveCumulativeState(notesDir: string, data: TranscriptData, notePath: string | null): void { try { const filePath = join(notesDir, CUMULATIVE_STATE_FILE); writeFileSync(filePath, JSON.stringify({ ...data, notePath, lastUpdated: new Date().toISOString(), }, null, 2)); console.error(`Cumulative state saved (${data.workItems.length} work items, ${data.filesModified.length} files)`); } catch (err) { console.error(`Failed to save cumulative state: ${err}`); } } // --------------------------------------------------------------------------- // Daemon IPC — enqueue registry-scan work item // --------------------------------------------------------------------------- /** * Enqueue a registry-scan work item with the daemon. * Fire-and-forget — never throws, never blocks the pre-compact hook. */ function enqueueRegistryScan(): Promise { return new Promise((resolve) => { let done = false; let timer: ReturnType | null = null; function finish(): void { if (done) return; done = true; if (timer !== null) { clearTimeout(timer); timer = null; } try { client.destroy(); } catch { /* ignore */ } resolve(); } const client = connect(DAEMON_SOCKET, () => { const msg = JSON.stringify({ id: randomUUID(), method: 'work_queue_enqueue', params: { type: 'registry-scan', priority: 5, payload: {}, }, }) + '\n'; client.write(msg); }); client.on('data', () => finish()); client.on('error', () => finish()); client.on('end', () => finish()); timer = setTimeout(() => finish(), DAEMON_TIMEOUT_MS); }); } // --------------------------------------------------------------------------- // Daemon IPC — enqueue session-summary work item // --------------------------------------------------------------------------- /** * Send a session-summary work item to the daemon via IPC. * Returns true on success, false if the daemon is unreachable. * Times out after DAEMON_TIMEOUT_MS to avoid blocking the hook. */ function enqueueSessionSummary(payload: { cwd: string; sessionId?: string; transcriptPath?: string; }): Promise { return new Promise((resolve) => { let done = false; let buffer = ''; let timer: ReturnType | null = null; function finish(ok: boolean): void { if (done) return; done = true; if (timer !== null) { clearTimeout(timer); timer = null; } try { client.destroy(); } catch { /* ignore */ } resolve(ok); } const client = connect(DAEMON_SOCKET, () => { const msg = JSON.stringify({ id: randomUUID(), method: 'work_queue_enqueue', params: { type: 'session-summary', priority: 4, // lower priority than session-end (2) payload: { cwd: payload.cwd, sessionId: payload.sessionId, transcriptPath: payload.transcriptPath, }, }, }) + '\n'; client.write(msg); }); client.on('data', (chunk: Buffer) => { buffer += chunk.toString(); const nl = buffer.indexOf('\n'); if (nl === -1) return; const line = buffer.slice(0, nl); try { const response = JSON.parse(line) as { ok: boolean; error?: string; result?: { id: string } }; if (response.ok) { console.error(`PRE-COMPACT: Session summary enqueued with daemon (id=${response.result?.id}).`); finish(true); } else { console.error(`PRE-COMPACT: Daemon rejected session-summary: ${response.error}`); finish(false); } } catch { finish(false); } }); client.on('error', (e: NodeJS.ErrnoException) => { if (e.code === 'ENOENT' || e.code === 'ECONNREFUSED') { console.error('PRE-COMPACT: Daemon not running — skipping session summary.'); } else { console.error(`PRE-COMPACT: Daemon socket error: ${e.message}`); } finish(false); }); timer = setTimeout(() => { console.error('PRE-COMPACT: Daemon IPC timed out — skipping session summary.'); finish(false); }, DAEMON_TIMEOUT_MS); }); } // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- async function main() { if (isWorkerSession()) return; // disposable worker: no per-session bookkeeping // Skip probe/health-check sessions (e.g. CodexBar ClaudeProbe) if (isProbeSession()) { process.exit(0); } let hookInput: HookInput | null = null; try { const decoder = new TextDecoder(); let input = ''; const timeoutPromise = new Promise((resolve) => { setTimeout(resolve, 500); }); const readPromise = (async () => { for await (const chunk of process.stdin) { input += decoder.decode(chunk, { stream: true }); } })(); await Promise.race([readPromise, timeoutPromise]); if (input.trim()) { hookInput = JSON.parse(input) as HookInput; } } catch { // Silently handle input errors } const compactType = hookInput?.compact_type || hookInput?.trigger || 'auto'; let ntfyTokenDisplay: string | null = null; if (hookInput?.transcript_path) { const stats = getTranscriptStats(hookInput.transcript_path); // BUG (real headers pulled from this machine's history): "~995073k // tokens", "~2135399k tokens" — 995 million and 2.1 billion. The old // source, calculateSessionTokens, sums usage across EVERY line in the // transcript, which is a lifetime token-spend counter, not a context // fill reading — it necessarily exceeds the window on any long session // and is not what "context compression triggered at ~X tokens" means. // getContextFill reads the most recent usage entry only (or the // statusline's own reading), which is the actual current fill and can // never legitimately exceed the window. formatContextFill still // clamps-and-flags rather than trust that, and reports "unknown" // instead of guessing when neither source has an answer. const fill = getContextFill({ sessionId: hookInput.session_id, transcriptPath: hookInput.transcript_path, }); const tokenDisplay = formatContextFill(fill).text; ntfyTokenDisplay = tokenDisplay; // ----------------------------------------------------------------- // Single-pass transcript parsing + cumulative state merge // ----------------------------------------------------------------- const data = parseTranscript(hookInput.transcript_path, hookInput.scratchpad_dir); // Find notes directory early — needed for cumulative state let notesInfo: { path: string; isLocal: boolean }; try { notesInfo = hookInput.cwd ? findNotesDir(hookInput.cwd) : { path: join(dirname(hookInput.transcript_path), 'Notes'), isLocal: false }; } catch { notesInfo = { path: join(dirname(hookInput.transcript_path), 'Notes'), isLocal: false }; } // Load accumulated state from previous compactions and merge const accumulated = loadCumulativeState(notesInfo.path); // Read BEFORE saveCumulativeState (below) overwrites this file for the // CURRENT compaction — this is the previous one's timestamp, the // freshness line a cached handover has to clear (see the injection step). const previousCompactionAt = readPreviousCompactionTimestamp(notesInfo.path); const merged = mergeTranscriptData(accumulated, data); const state = formatSessionState(merged, hookInput.cwd); if (accumulated) { console.error(`Loaded cumulative state: ${accumulated.workItems.length} work items, ${accumulated.filesModified.length} files from previous compaction(s)`); } // ----------------------------------------------------------------- // Persist session state to numbered session note. // RULE: ONE note per session. NEVER create a new note during compaction. // If the latest note is completed, reopen it (remove completed status) // rather than creating a duplicate. // ----------------------------------------------------------------- let notePath: string | null = null; try { notePath = getCurrentNotePath(notesInfo.path); if (!notePath) { // Truly no note exists at all — create one (first compaction of a session // that started before PAI was installed, or corrupted notes dir) console.error('No session note found — creating one for checkpoint'); notePath = createSessionNote(notesInfo.path, 'Untitled Session'); } else { // If the latest note is completed, reopen it for this session's checkpoints // instead of creating a duplicate. This handles the case where session-stop // finalized a note but the session continued (e.g., user said "end session" // but kept working). try { let noteContent = readFileSync(notePath, 'utf-8'); if (noteContent.includes('**Status:** Completed')) { noteContent = noteContent.replace('**Status:** Completed', '**Status:** In Progress'); writeFileSync(notePath, noteContent); console.error(`Reopened completed note for continued session: ${basename(notePath)}`); } } catch { /* proceed with existing note */ } } // 1. Write rich checkpoint with full session state const checkpointBody = state ? `Context compression triggered at ~${tokenDisplay} tokens with ${stats.messageCount} messages.\n\n${state}` : `Context compression triggered at ~${tokenDisplay} tokens with ${stats.messageCount} messages.`; appendCheckpoint(notePath, checkpointBody); // 2. Write work items to "Work Done" section (uses merged cumulative data) if (merged.workItems.length > 0) { addWorkToSessionNote(notePath, merged.workItems, `Pre-Compact (~${tokenDisplay} tokens)`); console.error(`Added ${merged.workItems.length} work item(s) to session note`); } // 3. Rename session note with a meaningful title (uses merged data for richer titles) const title = deriveTitle(merged); if (title) { const newPath = renameSessionNote(notePath, title); if (newPath !== notePath) { // Update H1 title inside the note to match try { let noteContent = readFileSync(newPath, 'utf-8'); noteContent = noteContent.replace( /^(# Session \d+:)\s*.*$/m, `$1 ${title}` ); writeFileSync(newPath, noteContent); console.error(`Updated note H1 to match rename`); } catch { /* ignore */ } notePath = newPath; } } console.error(`Rich checkpoint saved: ${basename(notePath)}`); } catch (noteError) { console.error(`Could not save checkpoint: ${noteError}`); } // Save cumulative state for next compaction saveCumulativeState(notesInfo.path, merged, notePath); // ----------------------------------------------------------------- // Update TODO.md with proper ## Continue section (like "pause session") // ----------------------------------------------------------------- if (hookInput.cwd && notePath) { try { const noteFilename = basename(notePath); updateTodoContinue(hookInput.cwd, noteFilename, state, tokenDisplay); console.error('TODO.md ## Continue section updated'); } catch (todoError) { console.error(`Could not update TODO.md: ${todoError}`); } } // ----------------------------------------------------------------------- // Save session state to temp file for post-compact injection. // // PreCompact hooks have NO stdout support (Claude Code ignores it). // Instead, we write the injection payload to a temp file keyed by // session_id. The SessionStart(compact) hook reads it and outputs // to stdout, which IS injected into the post-compaction context. // // Always fires (even with thin state) — includes note path so the AI // can enrich the session note post-compaction using its own context. // ----------------------------------------------------------------------- if (hookInput.session_id) { const stateText = state || `Working directory: ${hookInput.cwd || 'unknown'}`; const noteInfo = notePath ? `\nSESSION NOTE: ${notePath}\nIf this note still has a generic title (e.g. "New Session", "Context Compression"),\nrename it based on actual work done and add a rich summary.` : ''; // ------------------------------------------------------------------- // ADDITIVE: a model-written handover (decisions/reasoning/open threads // — see the threshold-triggered context-handover-worker) sits ALONGSIDE // the mechanical scrape above, never replaces it. The scrape is the // floor and stays intact on every path, so this is never worse than // the pre-existing behaviour — only sometimes better. "Fresh" means // generated after this session's PREVIOUS compaction: a handover // cached before that point describes state the last compaction // already accounted for, not what has happened since. // // BUG (live): a production digest was found with NO "HANDOVER SOURCE:" // line at all — its cause was never pinned down for certain (the // deployed hook is a compiled dist/ artifact reached through // ${ADAPTER_DIR}/Hooks/..., a separate build step from editing this // source, which is itself a plausible way for "works when I run the // .ts source" and "missing in production" to diverge). Regardless of // cause: sourceLabel and handoverBlock are now computed in their own // try/catch, so ANY failure in the cache read or the freshness // comparison degrades to an explicit error label rather than being // capable of preventing the line from existing at all. // ------------------------------------------------------------------- let sourceLabel: string; let handoverBlock: string; let handoverSummary: string | null = null; try { const cachedHandover = readContextHandoverCache(hookInput.session_id); const handoverIsFresh = cachedHandover !== null && (!previousCompactionAt || new Date(cachedHandover.generatedAt) > new Date(previousCompactionAt)); handoverSummary = handoverIsFresh ? cachedHandover!.summary : null; sourceLabel = handoverIsFresh ? `model-written handover (${cachedHandover!.model}, generated ${cachedHandover!.generatedAt}, ` + `threshold=${cachedHandover!.threshold}) + mechanical scrape` : 'mechanical scrape only (no fresh model-written handover was available)'; handoverBlock = handoverIsFresh ? [ '', '--- MODEL-WRITTEN HANDOVER (decisions, reasoning, open threads — not in the scrape above) ---', cachedHandover!.summary, '--- end model-written handover ---', ].join('\n') : ''; } catch (err) { console.error(`Failed to resolve handover cache — falling back to scrape-only: ${err}`); sourceLabel = `mechanical scrape only (error resolving handover cache: ${err})`; handoverBlock = ''; } // Evidence binding: which of the handover's identifiers the transcript // actually contains. One footer line, content untouched; its own // failure changes nothing else about the injection. if (handoverSummary !== null && typeof hookInput.transcript_path === 'string') { try { const evidence = bindHandoverEvidence(handoverSummary, null, [hookInput.transcript_path]); handoverBlock += `\n${evidence.footer}`; } catch (err) { console.error(`Evidence check skipped: ${err}`); } } const injection = [ '', `SESSION STATE RECOVERED AFTER COMPACTION (${compactType}, ~${tokenDisplay} tokens)`, `HANDOVER SOURCE: ${sourceLabel}`, '', stateText, noteInfo, handoverBlock, '', 'IMPORTANT: This session state was captured before context compaction.', 'Use it to maintain continuity. Continue the conversation from where', 'it left off without asking the user to repeat themselves.', 'Continue with the last task that you were asked to work on.', '', ].join('\n'); try { const stateFile = join(tmpdir(), `pai-compact-state-${hookInput.session_id}.txt`); writeFileSync(stateFile, injection, 'utf-8'); console.error(`Session state saved to ${stateFile} (${injection.length} chars)`); } catch (err) { console.error(`Failed to save state file: ${err}`); } } } // ----------------------------------------------------------------------- // Enqueue session-summary work item with daemon for AI-powered note generation // ----------------------------------------------------------------------- if (hookInput?.cwd) { try { await enqueueSessionSummary({ cwd: hookInput.cwd, sessionId: hookInput.session_id, transcriptPath: hookInput.transcript_path, }); } catch (err) { console.error(`Could not enqueue session-summary: ${err}`); } } // ----------------------------------------------------------------------- // Enqueue registry-scan so pai session recent stays fresh after compaction // ----------------------------------------------------------------------- try { await enqueueRegistryScan(); } catch { /* non-fatal */ } // Send ntfy.sh notification const ntfyMessage = ntfyTokenDisplay && ntfyTokenDisplay !== 'unknown' ? `Auto-pause: ~${ntfyTokenDisplay} tokens` : 'Context compressing'; await sendNtfyNotification(ntfyMessage); process.exit(0); } main().catch(() => { process.exit(0); });