diff --git a/src/cli/adapters/gemini-cli.ts b/src/cli/adapters/gemini-cli.ts new file mode 100644 index 00000000..59a7a1e6 --- /dev/null +++ b/src/cli/adapters/gemini-cli.ts @@ -0,0 +1,128 @@ +import type { PlatformAdapter } from '../types.js'; + +/** + * Gemini CLI Platform Adapter + * + * Normalizes Gemini CLI's hook JSON to NormalizedHookInput. + * Gemini CLI supports 11 lifecycle hooks; we register 8: + * + * Lifecycle: + * SessionStart → context (inject memory context) + * SessionEnd → session-complete + * PreCompress → summarize + * Notification → observation (system events like ToolPermission) + * + * Agent: + * BeforeAgent → user-message (captures user prompt) + * AfterAgent → observation (full agent response) + * + * Tool: + * BeforeTool → observation (tool intent before execution) + * AfterTool → observation (tool result after execution) + * + * Unmapped (not useful for memory): + * BeforeModel, AfterModel, BeforeToolSelection — model-level events + * that fire per-LLM-call, too chatty for observation capture. + * + * Base fields (all events): session_id, transcript_path, cwd, hook_event_name, timestamp + * + * Output format: { continue, stopReason, suppressOutput, systemMessage, decision, reason, hookSpecificOutput } + * Advisory hooks (SessionStart, SessionEnd, PreCompress, Notification) ignore flow-control fields. + */ +export const geminiCliAdapter: PlatformAdapter = { + normalizeInput(raw) { + const r = (raw ?? {}) as any; + + // CWD resolution chain: JSON field → env vars → process.cwd() + const cwd = r.cwd + ?? process.env.GEMINI_CWD + ?? process.env.GEMINI_PROJECT_DIR + ?? process.env.CLAUDE_PROJECT_DIR + ?? process.cwd(); + + const sessionId = r.session_id + ?? process.env.GEMINI_SESSION_ID + ?? undefined; + + const hookEventName: string | undefined = r.hook_event_name; + + // Tool fields — present in BeforeTool, AfterTool + let toolName: string | undefined = r.tool_name; + let toolInput: unknown = r.tool_input; + let toolResponse: unknown = r.tool_response; + + // AfterAgent: synthesize observation shape from the full agent response + if (hookEventName === 'AfterAgent' && r.prompt_response) { + toolName = toolName ?? 'GeminiAgent'; + toolInput = toolInput ?? { prompt: r.prompt }; + toolResponse = toolResponse ?? { response: r.prompt_response }; + } + + // BeforeTool: has tool_name and tool_input but no tool_response yet + // Synthesize a marker so observation handler knows this is pre-execution + if (hookEventName === 'BeforeTool' && toolName && !toolResponse) { + toolResponse = { _preExecution: true }; + } + + // Notification: capture as an observation with notification details + if (hookEventName === 'Notification') { + toolName = toolName ?? 'GeminiNotification'; + toolInput = toolInput ?? { + notification_type: r.notification_type, + message: r.message, + }; + toolResponse = toolResponse ?? { details: r.details }; + } + + // Collect platform-specific metadata + const metadata: Record = {}; + if (r.source) metadata.source = r.source; // SessionStart: startup|resume|clear + if (r.reason) metadata.reason = r.reason; // SessionEnd: exit|clear|logout|... + if (r.trigger) metadata.trigger = r.trigger; // PreCompress: auto|manual + if (r.mcp_context) metadata.mcp_context = r.mcp_context; // Tool hooks: MCP server context + if (r.notification_type) metadata.notification_type = r.notification_type; + if (r.stop_hook_active !== undefined) metadata.stop_hook_active = r.stop_hook_active; + if (r.original_request_name) metadata.original_request_name = r.original_request_name; + if (hookEventName) metadata.hook_event_name = hookEventName; + + return { + sessionId, + cwd, + prompt: r.prompt, + toolName, + toolInput, + toolResponse, + transcriptPath: r.transcript_path, + metadata: Object.keys(metadata).length > 0 ? metadata : undefined, + }; + }, + + formatOutput(result) { + // Gemini CLI expects: { continue, stopReason, suppressOutput, systemMessage, decision, reason, hookSpecificOutput } + const output: Record = {}; + + // Flow control — always include `continue` to prevent accidental agent termination + output.continue = result.continue ?? true; + + if (result.suppressOutput !== undefined) { + output.suppressOutput = result.suppressOutput; + } + + if (result.systemMessage) { + // Strip ANSI escape sequences: matches colors, text formatting, and terminal control codes + // Gemini CLI often has issues with ANSI escape sequences in tool output (showing them as raw text) + const ansiRegex = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g; + output.systemMessage = result.systemMessage.replace(ansiRegex, ''); + } + + // hookSpecificOutput is a first-class Gemini CLI field — pass through directly + // This includes additionalContext for context injection in SessionStart, BeforeAgent, AfterTool + if (result.hookSpecificOutput) { + output.hookSpecificOutput = { + additionalContext: result.hookSpecificOutput.additionalContext, + }; + } + + return output; + } +}; diff --git a/src/cli/adapters/index.ts b/src/cli/adapters/index.ts index 8c19039e..9b674167 100644 --- a/src/cli/adapters/index.ts +++ b/src/cli/adapters/index.ts @@ -1,16 +1,19 @@ import type { PlatformAdapter } from '../types.js'; import { claudeCodeAdapter } from './claude-code.js'; import { cursorAdapter } from './cursor.js'; +import { geminiCliAdapter } from './gemini-cli.js'; import { rawAdapter } from './raw.js'; export function getPlatformAdapter(platform: string): PlatformAdapter { switch (platform) { case 'claude-code': return claudeCodeAdapter; case 'cursor': return cursorAdapter; + case 'gemini': + case 'gemini-cli': return geminiCliAdapter; 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, rawAdapter }; +export { claudeCodeAdapter, cursorAdapter, geminiCliAdapter, rawAdapter }; diff --git a/src/cli/handlers/context.ts b/src/cli/handlers/context.ts index 055c028f..3abb75c5 100644 --- a/src/cli/handlers/context.ts +++ b/src/cli/handlers/context.ts @@ -66,9 +66,15 @@ export const contextHandler: EventHandler = { const additionalContext = contextResult.trim(); const coloredTimeline = colorResult.trim(); + const platform = input.platform; - const systemMessage = showTerminalOutput && coloredTimeline - ? `${coloredTimeline}\n\nView Observations Live @ http://localhost:${port}` + // Use colored timeline for display if available, otherwise fall back to + // plain markdown context (especially useful for platforms like Gemini + // where we want to ensure visibility even if colors aren't fetched). + const displayContent = coloredTimeline || (platform === 'gemini-cli' || platform === 'gemini' ? additionalContext : ''); + + const systemMessage = showTerminalOutput && displayContent + ? `${displayContent}\n\nView Observations Live @ http://localhost:${port}` : undefined; return {