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:
@@ -3,6 +3,7 @@ import { claudeCodeAdapter } from './claude-code.js';
|
||||
import { cursorAdapter } from './cursor.js';
|
||||
import { geminiCliAdapter } from './gemini-cli.js';
|
||||
import { rawAdapter } from './raw.js';
|
||||
import { windsurfAdapter } from './windsurf.js';
|
||||
|
||||
export function getPlatformAdapter(platform: string): PlatformAdapter {
|
||||
switch (platform) {
|
||||
@@ -10,10 +11,11 @@ export function getPlatformAdapter(platform: string): PlatformAdapter {
|
||||
case 'cursor': return cursorAdapter;
|
||||
case 'gemini':
|
||||
case 'gemini-cli': return geminiCliAdapter;
|
||||
case 'windsurf': return windsurfAdapter;
|
||||
case 'raw': return rawAdapter;
|
||||
// Codex CLI and other compatible platforms use the raw adapter (accepts both camelCase and snake_case fields)
|
||||
default: return rawAdapter;
|
||||
}
|
||||
}
|
||||
|
||||
export { claudeCodeAdapter, cursorAdapter, geminiCliAdapter, rawAdapter };
|
||||
export { claudeCodeAdapter, cursorAdapter, geminiCliAdapter, rawAdapter, windsurfAdapter };
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { PlatformAdapter, NormalizedHookInput, HookResult } from '../types.js';
|
||||
|
||||
// Maps Windsurf stdin format — JSON envelope with agent_action_name + tool_info payload
|
||||
//
|
||||
// Common envelope (all hooks):
|
||||
// { agent_action_name, trajectory_id, execution_id, timestamp, tool_info: { ... } }
|
||||
//
|
||||
// Event-specific tool_info payloads:
|
||||
// pre_user_prompt: { user_prompt: string }
|
||||
// post_write_code: { file_path, edits: [{ old_string, new_string }] }
|
||||
// post_run_command: { command_line, cwd }
|
||||
// post_mcp_tool_use: { mcp_server_name, mcp_tool_name, mcp_tool_arguments, mcp_result }
|
||||
// post_cascade_response: { response }
|
||||
export const windsurfAdapter: PlatformAdapter = {
|
||||
normalizeInput(raw) {
|
||||
const r = (raw ?? {}) as any;
|
||||
const toolInfo = r.tool_info ?? {};
|
||||
const actionName: string = r.agent_action_name ?? '';
|
||||
|
||||
const base: NormalizedHookInput = {
|
||||
sessionId: r.trajectory_id ?? r.execution_id,
|
||||
cwd: toolInfo.cwd ?? process.cwd(),
|
||||
platform: 'windsurf',
|
||||
};
|
||||
|
||||
switch (actionName) {
|
||||
case 'pre_user_prompt':
|
||||
return {
|
||||
...base,
|
||||
prompt: toolInfo.user_prompt,
|
||||
};
|
||||
|
||||
case 'post_write_code':
|
||||
return {
|
||||
...base,
|
||||
toolName: 'Write',
|
||||
filePath: toolInfo.file_path,
|
||||
edits: toolInfo.edits,
|
||||
toolInput: {
|
||||
file_path: toolInfo.file_path,
|
||||
edits: toolInfo.edits,
|
||||
},
|
||||
};
|
||||
|
||||
case 'post_run_command':
|
||||
return {
|
||||
...base,
|
||||
cwd: toolInfo.cwd ?? base.cwd,
|
||||
toolName: 'Bash',
|
||||
toolInput: { command: toolInfo.command_line },
|
||||
};
|
||||
|
||||
case 'post_mcp_tool_use':
|
||||
return {
|
||||
...base,
|
||||
toolName: toolInfo.mcp_tool_name ?? 'mcp_tool',
|
||||
toolInput: toolInfo.mcp_tool_arguments,
|
||||
toolResponse: toolInfo.mcp_result,
|
||||
};
|
||||
|
||||
case 'post_cascade_response':
|
||||
return {
|
||||
...base,
|
||||
toolName: 'cascade_response',
|
||||
toolResponse: toolInfo.response,
|
||||
};
|
||||
|
||||
default:
|
||||
// Unknown action — pass through what we can
|
||||
return base;
|
||||
}
|
||||
},
|
||||
|
||||
formatOutput(result) {
|
||||
// Windsurf exit codes: 0 = success, 2 = block (pre-hooks only)
|
||||
// The CLI layer handles exit codes; here we just return a simple continue flag
|
||||
return { continue: result.continue ?? true };
|
||||
},
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+3
-1
@@ -1,7 +1,7 @@
|
||||
export interface NormalizedHookInput {
|
||||
sessionId: string;
|
||||
cwd: string;
|
||||
platform?: string; // 'claude-code' or 'cursor'
|
||||
platform?: string; // 'claude-code', 'cursor', 'gemini-cli', etc.
|
||||
prompt?: string;
|
||||
toolName?: string;
|
||||
toolInput?: unknown;
|
||||
@@ -10,6 +10,8 @@ export interface NormalizedHookInput {
|
||||
// Cursor-specific fields
|
||||
filePath?: string; // afterFileEdit
|
||||
edits?: unknown[]; // afterFileEdit
|
||||
// Platform-specific metadata (source, reason, trigger, mcp_context, etc.)
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface HookResult {
|
||||
|
||||
Reference in New Issue
Block a user