Saving uncommitted changes before archiving

This commit is contained in:
Conductor
2026-03-26 19:35:27 -07:00
parent 88636ec012
commit 5621b67ccd
4 changed files with 189 additions and 123 deletions
File diff suppressed because one or more lines are too long
+59 -19
View File
@@ -1,27 +1,39 @@
import type { PlatformAdapter, NormalizedHookInput, HookResult } from '../types.js'; import type { PlatformAdapter } from '../types.js';
/** /**
* Gemini CLI Platform Adapter * Gemini CLI Platform Adapter
* *
* Normalizes Gemini CLI's hook JSON to NormalizedHookInput. * Normalizes Gemini CLI's hook JSON to NormalizedHookInput.
* Gemini CLI has 11 lifecycle hooks; we map 6 of them: * Gemini CLI supports 11 lifecycle hooks; we register 8:
* SessionStart → session-init *
* BeforeAgent → user-message (captures prompt) * Lifecycle:
* AfterAgent → observation (full response) * SessionStart → context (inject memory context)
* AfterTool → observation (tool result)
* PreCompress → summarize
* SessionEnd → session-complete * 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 * Base fields (all events): session_id, transcript_path, cwd, hook_event_name, timestamp
* *
* Output format: { continue, stopReason, suppressOutput, systemMessage, decision, reason } * Output format: { continue, stopReason, suppressOutput, systemMessage, decision, reason, hookSpecificOutput }
* Advisory hooks (SessionStart, SessionEnd, PreCompress) ignore `continue` and `decision`. * Advisory hooks (SessionStart, SessionEnd, PreCompress, Notification) ignore flow-control fields.
*/ */
export const geminiCliAdapter: PlatformAdapter = { export const geminiCliAdapter: PlatformAdapter = {
normalizeInput(raw) { normalizeInput(raw) {
const r = (raw ?? {}) as any; const r = (raw ?? {}) as any;
// Use GEMINI_CWD, GEMINI_PROJECT_DIR, or the JSON cwd field // CWD resolution chain: JSON field → env vars → process.cwd()
const cwd = r.cwd const cwd = r.cwd
?? process.env.GEMINI_CWD ?? process.env.GEMINI_CWD
?? process.env.GEMINI_PROJECT_DIR ?? process.env.GEMINI_PROJECT_DIR
@@ -32,23 +44,47 @@ export const geminiCliAdapter: PlatformAdapter = {
?? process.env.GEMINI_SESSION_ID ?? process.env.GEMINI_SESSION_ID
?? undefined; ?? undefined;
// Map event-specific fields into normalized shape
// AfterTool provides tool_name, tool_input, tool_response
// BeforeAgent/AfterAgent provide prompt (and prompt_response for AfterAgent)
const hookEventName: string | undefined = r.hook_event_name; const hookEventName: string | undefined = r.hook_event_name;
// For AfterAgent, treat the full response as an observation by packing it // Tool fields — present in BeforeTool, AfterTool
// into toolResponse so the observation handler can process it
let toolName: string | undefined = r.tool_name; let toolName: string | undefined = r.tool_name;
let toolInput: unknown = r.tool_input; let toolInput: unknown = r.tool_input;
let toolResponse: unknown = r.tool_response; let toolResponse: unknown = r.tool_response;
// AfterAgent: synthesize observation shape from the full agent response
if (hookEventName === 'AfterAgent' && r.prompt_response) { if (hookEventName === 'AfterAgent' && r.prompt_response) {
toolName = toolName ?? 'GeminiAgent'; toolName = toolName ?? 'GeminiAgent';
toolInput = toolInput ?? { prompt: r.prompt }; toolInput = toolInput ?? { prompt: r.prompt };
toolResponse = toolResponse ?? { response: r.prompt_response }; 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<string, unknown> = {};
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 { return {
sessionId, sessionId,
cwd, cwd,
@@ -57,14 +93,15 @@ export const geminiCliAdapter: PlatformAdapter = {
toolInput, toolInput,
toolResponse, toolResponse,
transcriptPath: r.transcript_path, transcriptPath: r.transcript_path,
metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
}; };
}, },
formatOutput(result) { formatOutput(result) {
// Gemini CLI expects: { continue, stopReason, suppressOutput, systemMessage, decision, reason } // Gemini CLI expects: { continue, stopReason, suppressOutput, systemMessage, decision, reason, hookSpecificOutput }
const output: Record<string, unknown> = {}; const output: Record<string, unknown> = {};
// Always include continue — controls whether the agent proceeds // Flow control — always include `continue` to prevent accidental agent termination
output.continue = result.continue ?? true; output.continue = result.continue ?? true;
if (result.suppressOutput !== undefined) { if (result.suppressOutput !== undefined) {
@@ -75,9 +112,12 @@ export const geminiCliAdapter: PlatformAdapter = {
output.systemMessage = result.systemMessage; output.systemMessage = result.systemMessage;
} }
// hookSpecificOutput carries context injection data // hookSpecificOutput is a first-class Gemini CLI field — pass through directly
// This includes additionalContext for context injection in SessionStart, BeforeAgent, AfterTool
if (result.hookSpecificOutput) { if (result.hookSpecificOutput) {
output.systemMessage = result.hookSpecificOutput.additionalContext || output.systemMessage; output.hookSpecificOutput = {
additionalContext: result.hookSpecificOutput.additionalContext,
};
} }
return output; return output;
+3 -1
View File
@@ -1,7 +1,7 @@
export interface NormalizedHookInput { export interface NormalizedHookInput {
sessionId: string; sessionId: string;
cwd: string; cwd: string;
platform?: string; // 'claude-code' or 'cursor' platform?: string; // 'claude-code', 'cursor', 'gemini-cli', etc.
prompt?: string; prompt?: string;
toolName?: string; toolName?: string;
toolInput?: unknown; toolInput?: unknown;
@@ -10,6 +10,8 @@ export interface NormalizedHookInput {
// Cursor-specific fields // Cursor-specific fields
filePath?: string; // afterFileEdit filePath?: string; // afterFileEdit
edits?: unknown[]; // afterFileEdit edits?: unknown[]; // afterFileEdit
// Platform-specific metadata (source, reason, trigger, mcp_context, etc.)
metadata?: Record<string, unknown>;
} }
export interface HookResult { export interface HookResult {
@@ -1,5 +1,5 @@
/** /**
* GeminiCliHooksInstaller - Gemini CLI integration for claude-mem * GeminiCliHooksInstaller - First-class Gemini CLI integration for claude-mem
* *
* Installs claude-mem hooks into ~/.gemini/settings.json using deep merge * Installs claude-mem hooks into ~/.gemini/settings.json using deep merge
* to preserve any existing user configuration. * to preserve any existing user configuration.
@@ -9,18 +9,23 @@
* "hooks": { * "hooks": {
* "AfterTool": [{ * "AfterTool": [{
* "matcher": "*", * "matcher": "*",
* "hooks": [{ "name": "claude-mem", "type": "command", "command": "...", "timeout": 5000 }] * "hooks": [{ "name": "claude-mem", "type": "command", "command": "...", "timeout": 5000, "description": "..." }]
* }] * }]
* } * }
* } * }
* *
* Events registered: * Registers 8 of 11 Gemini CLI hooks:
* SessionStart — session init * SessionStart — inject memory context (via hookSpecificOutput.additionalContext)
* BeforeAgent — capture user prompt * BeforeAgent — capture user prompt
* AfterAgent — capture full response * AfterAgent — capture full agent response
* BeforeTool — capture tool intent before execution
* AfterTool — capture all tool results (matcher: "*") * AfterTool — capture all tool results (matcher: "*")
* PreCompress — trigger summary * Notification — capture system events (ToolPermission, etc.)
* PreCompress — trigger summary generation
* SessionEnd — finalize session * SessionEnd — finalize session
*
* Skipped (model-level, too chatty):
* BeforeModel, AfterModel, BeforeToolSelection
*/ */
import path from 'path'; import path from 'path';
@@ -39,6 +44,7 @@ interface GeminiHookEntry {
type: 'command'; type: 'command';
command: string; command: string;
timeout: number; timeout: number;
description?: string;
} }
interface GeminiHookMatcher { interface GeminiHookMatcher {
@@ -62,15 +68,25 @@ const HOOK_NAME = 'claude-mem';
const HOOK_TIMEOUT_MS = 5000; const HOOK_TIMEOUT_MS = 5000;
/** /**
* The Gemini CLI events we register hooks for, mapped to our internal event names. * Gemini CLI events → claude-mem internal events.
*
* We register 8 of 11 hooks. Skipped: BeforeModel, AfterModel, BeforeToolSelection
* (model-level events fire per-LLM-call — too chatty for observation capture).
*/ */
const GEMINI_EVENT_TO_CLAUDE_MEM_EVENT: Record<string, string> = { interface GeminiEventConfig {
'SessionStart': 'session-init', claudeMemEvent: string;
'BeforeAgent': 'user-message', description: string;
'AfterAgent': 'observation', }
'AfterTool': 'observation',
'PreCompress': 'summarize', const GEMINI_EVENTS: Record<string, GeminiEventConfig> = {
'SessionEnd': 'session-complete', 'SessionStart': { claudeMemEvent: 'context', description: 'Inject memory context from past sessions' },
'BeforeAgent': { claudeMemEvent: 'session-init', description: 'Initialize session and capture user prompt' },
'AfterAgent': { claudeMemEvent: 'observation', description: 'Capture full agent response' },
'BeforeTool': { claudeMemEvent: 'observation', description: 'Capture tool intent before execution' },
'AfterTool': { claudeMemEvent: 'observation', description: 'Capture tool results after execution' },
'Notification': { claudeMemEvent: 'observation', description: 'Capture system events (permissions, etc.)' },
'PreCompress': { claudeMemEvent: 'summarize', description: 'Generate session summary before compression' },
'SessionEnd': { claudeMemEvent: 'session-complete', description: 'Finalize session and persist memory' },
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -170,19 +186,17 @@ export async function installGeminiCliHooks(): Promise<number> {
} }
// Register each event // Register each event
for (const [geminiEvent, claudeMemEvent] of Object.entries(GEMINI_EVENT_TO_CLAUDE_MEM_EVENT)) { for (const [geminiEvent, config] of Object.entries(GEMINI_EVENTS)) {
const command = buildHookCommand(bunPath, workerServicePath, claudeMemEvent); const command = buildHookCommand(bunPath, workerServicePath, config.claudeMemEvent);
// AfterTool uses matcher: "*" to capture all tool results
const matcherValue = geminiEvent === 'AfterTool' ? '*' : '*';
const newMatcher: GeminiHookMatcher = { const newMatcher: GeminiHookMatcher = {
matcher: matcherValue, matcher: '*',
hooks: [{ hooks: [{
name: HOOK_NAME, name: HOOK_NAME,
type: 'command', type: 'command',
command, command,
timeout: HOOK_TIMEOUT_MS, timeout: HOOK_TIMEOUT_MS,
description: config.description,
}], }],
}; };
@@ -193,25 +207,35 @@ export async function installGeminiCliHooks(): Promise<number> {
// Write merged settings // Write merged settings
writeFileSync(GEMINI_SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n'); writeFileSync(GEMINI_SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n');
console.log(` Updated ${GEMINI_SETTINGS_PATH}`); console.log(` Updated ${GEMINI_SETTINGS_PATH}`);
console.log(` Registered hooks for: ${Object.keys(GEMINI_EVENT_TO_CLAUDE_MEM_EVENT).join(', ')}`); console.log(` Registered hooks for: ${Object.keys(GEMINI_EVENTS).join(', ')}`);
// Inject context into GEMINI.md // Inject context into GEMINI.md
injectGeminiMdContext(); injectGeminiMdContext();
console.log(` console.log(`
Installation complete! Installation complete! (8 hooks registered)
Hooks installed to: ${GEMINI_SETTINGS_PATH} Hooks installed to: ${GEMINI_SETTINGS_PATH}
Using unified CLI: bun worker-service.cjs hook gemini-cli <event> Using unified CLI: bun worker-service.cjs hook gemini-cli <event>
Registered hooks:
SessionStart → Inject memory context from past sessions
BeforeAgent → Capture user prompt for memory
AfterAgent → Capture full agent response
BeforeTool → Capture tool intent before execution
AfterTool → Capture tool results after execution
Notification → Capture system events (permissions, etc.)
PreCompress → Generate session summary before compression
SessionEnd → Finalize session and persist memory
Next steps: Next steps:
1. Start claude-mem worker: claude-mem start 1. Start claude-mem worker: npx claude-mem start
2. Restart Gemini CLI to load the hooks 2. Restart Gemini CLI to load the hooks
3. Memory capture is now automatic! 3. Memory capture is now automatic!
Context Injection: Context Injection:
Context from past sessions is injected via ${GEMINI_MD_PATH} Memory from past sessions is injected via hookSpecificOutput.additionalContext
and automatically included in every Gemini CLI session. on SessionStart, and persisted in ${GEMINI_MD_PATH} for static context.
`); `);
return 0; return 0;
@@ -429,7 +453,7 @@ export function checkGeminiCliHooksStatus(): number {
} }
// Check expected vs actual events // Check expected vs actual events
const expectedEvents = Object.keys(GEMINI_EVENT_TO_CLAUDE_MEM_EVENT); const expectedEvents = Object.keys(GEMINI_EVENTS);
const missingEvents = expectedEvents.filter((e) => !installedEvents.includes(e)); const missingEvents = expectedEvents.filter((e) => !installedEvents.includes(e));
if (missingEvents.length > 0) { if (missingEvents.length > 0) {
console.log(` Warning: Missing events: ${missingEvents.join(', ')}`); console.log(` Warning: Missing events: ${missingEvents.join(', ')}`);