mirror of
https://github.com/thedotmack/claude-mem
synced 2026-04-25 17:15:04 +02:00
170 lines
7.5 KiB
TypeScript
170 lines
7.5 KiB
TypeScript
/**
|
|
* Summarize Handler - Stop
|
|
*
|
|
* Runs in the Stop hook (120s timeout, not capped like SessionEnd).
|
|
* This is the ONLY place where we can reliably wait for async work.
|
|
*
|
|
* Flow:
|
|
* 1. Queue summarize request to worker
|
|
* 2. Poll worker until summary processing completes
|
|
* 3. Call /api/sessions/complete to clean up session
|
|
*
|
|
* SessionEnd (1.5s cap from Claude Code) is just a lightweight fallback —
|
|
* all real work must happen here in Stop.
|
|
*/
|
|
|
|
import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js';
|
|
import { ensureWorkerRunning, workerHttpRequest } from '../../shared/worker-utils.js';
|
|
import { logger } from '../../utils/logger.js';
|
|
import { extractLastMessage } from '../../shared/transcript-parser.js';
|
|
import { HOOK_EXIT_CODES, HOOK_TIMEOUTS, getTimeout } from '../../shared/hook-constants.js';
|
|
import { normalizePlatformSource } from '../../shared/platform-source.js';
|
|
|
|
const SUMMARIZE_TIMEOUT_MS = getTimeout(HOOK_TIMEOUTS.DEFAULT);
|
|
const POLL_INTERVAL_MS = 500;
|
|
const MAX_WAIT_FOR_SUMMARY_MS = 110_000; // 110s — fits within Stop hook's 120s timeout
|
|
|
|
export const summarizeHandler: EventHandler = {
|
|
async execute(input: NormalizedHookInput): Promise<HookResult> {
|
|
// Skip summaries in subagent context — subagents do not own the session summary.
|
|
// Gate on agentId only: that field is present exclusively for Task-spawned subagents.
|
|
// agentType alone (no agentId) indicates `--agent`-started main sessions, which still
|
|
// own their summary. Do this BEFORE ensureWorkerRunning() so a subagent Stop hook
|
|
// does not bootstrap the worker.
|
|
if (input.agentId) {
|
|
logger.debug('HOOK', 'Skipping summary: subagent context detected', {
|
|
sessionId: input.sessionId,
|
|
agentId: input.agentId,
|
|
agentType: input.agentType
|
|
});
|
|
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
|
|
}
|
|
|
|
// Ensure worker is running before any other logic
|
|
const workerReady = await ensureWorkerRunning();
|
|
if (!workerReady) {
|
|
// Worker not available - skip summary gracefully
|
|
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
|
|
}
|
|
|
|
const { sessionId, transcriptPath } = input;
|
|
|
|
// Validate required fields before processing
|
|
if (!transcriptPath) {
|
|
// No transcript available - skip summary gracefully (not an error)
|
|
logger.debug('HOOK', `No transcriptPath in Stop hook input for session ${sessionId} - skipping summary`);
|
|
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
|
|
}
|
|
|
|
// Extract last assistant message from transcript (the work Claude did)
|
|
// Note: "user" messages in transcripts are mostly tool_results, not actual user input.
|
|
// The user's original request is already stored in user_prompts table.
|
|
let lastAssistantMessage = '';
|
|
try {
|
|
lastAssistantMessage = extractLastMessage(transcriptPath, 'assistant', true);
|
|
} catch (err) {
|
|
logger.warn('HOOK', `Stop hook: failed to extract last assistant message for session ${sessionId}: ${err instanceof Error ? err.message : err}`);
|
|
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
|
|
}
|
|
|
|
// Skip summary if transcript has no assistant message (prevents repeated
|
|
// empty summarize requests that pollute logs — upstream bug)
|
|
if (!lastAssistantMessage || !lastAssistantMessage.trim()) {
|
|
logger.debug('HOOK', 'No assistant message in transcript - skipping summary', {
|
|
sessionId,
|
|
transcriptPath
|
|
});
|
|
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
|
|
}
|
|
|
|
logger.dataIn('HOOK', 'Stop: Requesting summary', {
|
|
hasLastAssistantMessage: !!lastAssistantMessage
|
|
});
|
|
|
|
const platformSource = normalizePlatformSource(input.platform);
|
|
|
|
// 1. Queue summarize request — worker returns immediately with { status: 'queued' }
|
|
let response: Response;
|
|
try {
|
|
response = await workerHttpRequest('/api/sessions/summarize', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
contentSessionId: sessionId,
|
|
last_assistant_message: lastAssistantMessage,
|
|
platformSource
|
|
}),
|
|
timeoutMs: SUMMARIZE_TIMEOUT_MS
|
|
});
|
|
} catch (err) {
|
|
// Network error, worker crash, or timeout — exit gracefully instead of
|
|
// bubbling to hook runner which exits code 2 and blocks session exit (#1901)
|
|
logger.warn('HOOK', `Stop hook: summarize request failed: ${err instanceof Error ? err.message : err}`);
|
|
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
|
|
}
|
|
|
|
if (!response.ok) {
|
|
return { continue: true, suppressOutput: true };
|
|
}
|
|
|
|
logger.debug('HOOK', 'Summary request queued, waiting for completion');
|
|
|
|
// 2. Poll worker until pending work for this session is done.
|
|
// This keeps the Stop hook alive (120s timeout) so the SDK agent
|
|
// can finish processing the summary before SessionEnd kills the session.
|
|
const waitStart = Date.now();
|
|
let summaryStored: boolean | null = null;
|
|
while ((Date.now() - waitStart) < MAX_WAIT_FOR_SUMMARY_MS) {
|
|
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
|
|
let statusResponse: Response;
|
|
let status: { queueLength?: number; summaryStored?: boolean | null };
|
|
try {
|
|
statusResponse = await workerHttpRequest(`/api/sessions/status?contentSessionId=${encodeURIComponent(sessionId)}`, { timeoutMs: 5000 });
|
|
status = await statusResponse.json() as { queueLength?: number; summaryStored?: boolean | null };
|
|
} catch (pollError) {
|
|
// Worker may be busy — keep polling
|
|
logger.debug('HOOK', 'Summary status poll failed, retrying', { error: pollError instanceof Error ? pollError.message : String(pollError) });
|
|
continue;
|
|
}
|
|
|
|
const queueLength = status.queueLength ?? 0;
|
|
// Only treat an empty queue as completion when the session exists (non-404).
|
|
// A 404 means the session was not found — not that processing finished.
|
|
if (queueLength === 0 && statusResponse.status !== 404) {
|
|
summaryStored = status.summaryStored ?? null;
|
|
logger.info('HOOK', 'Summary processing complete', {
|
|
waitedMs: Date.now() - waitStart,
|
|
summaryStored
|
|
});
|
|
// Warn when the agent processed a summarize request but produced no storable summary.
|
|
// This is the silent-failure path described in #1633: queue empties but no summary record exists.
|
|
if (summaryStored === false) {
|
|
logger.warn('HOOK', 'Summary was not stored: LLM response likely lacked valid <summary> tags (#1633)', {
|
|
sessionId,
|
|
waitedMs: Date.now() - waitStart
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
// 3. Complete the session — clean up active sessions map.
|
|
// This runs here in Stop (120s timeout) instead of SessionEnd (1.5s cap)
|
|
// so it reliably fires after summary work is done.
|
|
try {
|
|
await workerHttpRequest('/api/sessions/complete', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ contentSessionId: sessionId }),
|
|
timeoutMs: 10_000
|
|
});
|
|
logger.info('HOOK', 'Session completed in Stop hook', { contentSessionId: sessionId });
|
|
} catch (err) {
|
|
logger.warn('HOOK', `Stop hook: session-complete failed: ${err instanceof Error ? err.message : err}`);
|
|
}
|
|
|
|
return { continue: true, suppressOutput: true };
|
|
}
|
|
};
|