Merge main into thedotmack/file-read-timeline-inject

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alex Newman
2026-04-05 03:00:06 -07:00
95 changed files with 11818 additions and 5886 deletions
+1 -1
View File
@@ -39,7 +39,7 @@ export const contextHandler: EventHandler = {
// Pass all projects (parent + worktree if applicable) for unified timeline
const projectsParam = context.allProjects.join(',');
const apiPath = `/api/context/inject?projects=${encodeURIComponent(projectsParam)}`;
const colorApiPath = `${apiPath}&colors=true`;
const colorApiPath = input.platform === 'claude-code' ? `${apiPath}&colors=true` : apiPath;
// Note: Removed AbortSignal.timeout due to Windows Bun cleanup issue (libuv assertion)
// Worker service has its own timeouts, so client-side timeout is redundant
+50 -5
View File
@@ -87,17 +87,18 @@ export const sessionInitHandler: EventHandler = {
// Skip SDK agent re-initialization if context was already injected for this session (#1079)
// The prompt was already saved to the database by /api/sessions/init above —
// no need to re-start the SDK agent on every turn
if (initResult.contextInjected) {
// no need to re-start the SDK agent on every turn.
// Note: we do NOT return here — semantic injection below must run on every prompt.
const skipAgentInit = Boolean(initResult.contextInjected);
if (skipAgentInit) {
logger.info('HOOK', `INIT_COMPLETE | sessionDbId=${sessionDbId} | promptNumber=${promptNumber} | skipped_agent_init=true | reason=context_already_injected`, {
sessionId: sessionDbId
});
return { continue: true, suppressOutput: true };
}
// Only initialize SDK agent for Claude Code (not Cursor)
// Cursor doesn't use the SDK agent - it only needs session/observation storage
if (input.platform !== 'cursor' && sessionDbId) {
if (!skipAgentInit && input.platform !== 'cursor' && sessionDbId) {
// Strip leading slash from commands for memory agent
// /review 101 -> review 101 (more semantic for observations)
const cleanedPrompt = prompt.startsWith('/') ? prompt.substring(1) : prompt;
@@ -115,14 +116,58 @@ export const sessionInitHandler: EventHandler = {
// Log but don't throw - SDK agent failure should not block the user's prompt
logger.failure('HOOK', `SDK agent start failed: ${response.status}`, { sessionDbId, promptNumber });
}
} else if (input.platform === 'cursor') {
} else if (!skipAgentInit && input.platform === 'cursor') {
logger.debug('HOOK', 'session-init: Skipping SDK agent init for Cursor platform', { sessionDbId, promptNumber });
}
// Semantic context injection: query Chroma for relevant past observations
// and inject as additionalContext so Claude receives relevant memory each prompt.
// Controlled by CLAUDE_MEM_SEMANTIC_INJECT setting (default: true).
const semanticInject =
String(settings.CLAUDE_MEM_SEMANTIC_INJECT).toLowerCase() === 'true';
let additionalContext = '';
if (semanticInject && prompt && prompt.length >= 20 && prompt !== '[media prompt]') {
try {
const limit = settings.CLAUDE_MEM_SEMANTIC_INJECT_LIMIT || '5';
const semanticRes = await workerHttpRequest('/api/context/semantic', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ q: prompt, project, limit })
});
if (semanticRes.ok) {
const data = await semanticRes.json() as { context: string; count: number };
if (data.context) {
additionalContext = data.context;
logger.debug('HOOK', `Semantic injection: ${data.count} observations for prompt`, {
sessionId: sessionDbId, count: data.count
});
}
}
} catch (e) {
// Graceful degradation — semantic injection is optional
logger.debug('HOOK', 'Semantic injection unavailable', {
error: e instanceof Error ? e.message : String(e)
});
}
}
logger.info('HOOK', `INIT_COMPLETE | sessionDbId=${sessionDbId} | promptNumber=${promptNumber} | project=${project}`, {
sessionId: sessionDbId
});
// Return with semantic context if available
if (additionalContext) {
return {
continue: true,
suppressOutput: true,
hookSpecificOutput: {
hookEventName: 'UserPromptSubmit',
additionalContext
}
};
}
return { continue: true, suppressOutput: true };
}
};
+63 -6
View File
@@ -1,9 +1,16 @@
/**
* Summarize Handler - Stop
*
* Extracted from summary-hook.ts - sends summary request to worker.
* Transcript parsing stays in the hook because only the hook has access to
* the transcript file path.
* 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';
@@ -13,6 +20,8 @@ import { extractLastMessage } from '../../shared/transcript-parser.js';
import { HOOK_EXIT_CODES, HOOK_TIMEOUTS, getTimeout } from '../../shared/hook-constants.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> {
@@ -43,11 +52,21 @@ export const summarizeHandler: EventHandler = {
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
});
// Send to worker - worker handles privacy check and database operations
// 1. Queue summarize request — worker returns immediately with { status: 'queued' }
const response = await workerHttpRequest('/api/sessions/summarize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -59,11 +78,49 @@ export const summarizeHandler: EventHandler = {
});
if (!response.ok) {
// Return standard response even on failure (matches original behavior)
return { continue: true, suppressOutput: true };
}
logger.debug('HOOK', 'Summary request sent successfully');
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();
while ((Date.now() - waitStart) < MAX_WAIT_FOR_SUMMARY_MS) {
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
try {
const statusResponse = await workerHttpRequest(`/api/sessions/status?contentSessionId=${encodeURIComponent(sessionId)}`, {
timeoutMs: 5000
});
if (statusResponse.ok) {
const status = await statusResponse.json() as { queueLength?: number };
if ((status.queueLength ?? 0) === 0) {
logger.info('HOOK', 'Summary processing complete', {
waitedMs: Date.now() - waitStart
});
break;
}
}
} catch {
// Worker may be busy — keep polling
}
}
// 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 };
}
+3 -1
View File
@@ -23,9 +23,11 @@ export const userMessageHandler: EventHandler = {
const project = basename(input.cwd ?? process.cwd());
// Fetch formatted context directly from worker API
// Only request ANSI colors for platforms that render them (claude-code)
const colorsParam = input.platform === 'claude-code' ? '&colors=true' : '';
try {
const response = await workerHttpRequest(
`/api/context/inject?project=${encodeURIComponent(project)}&colors=true`
`/api/context/inject?project=${encodeURIComponent(project)}${colorsParam}`
);
if (!response.ok) {