Refactor hooks codebase: reduce complexity and improve maintainability (#204)

* refactor: Clean up hook-response and new-hook files

- Removed 'PreCompact' hook type and associated logic from hook-response.ts for improved type safety.
- Deleted extensive architecture comments in new-hook.ts to streamline code readability.
- Simplified debug logging in new-hook.ts to reduce verbosity.
- Enhanced ensureWorkerRunning function in worker-utils.ts with a final health check before throwing errors.
- Added a new documentation file outlining the hooks cleanup process and future improvements.

* Refactor cleanup and user message hooks

- Updated cleanup-hook.js to improve error handling and remove unnecessary input checks.
- Simplified user-message-hook.js by removing time-sensitive announcements and streamlining output.
- Enhanced logging functionality in both hooks for better debugging and clarity.

* Refactor error handling in hooks to use centralized error handler

- Introduced `handleWorkerError` function in `src/shared/hook-error-handler.ts` to manage worker-related errors.
- Updated `context-hook.ts`, `new-hook.ts`, `save-hook.ts`, and `summary-hook.ts` to utilize the new error handler, simplifying error management and improving code readability.
- Removed repetitive error handling logic from individual hooks, ensuring consistent user-friendly messages for connection issues.

* Refactor user-message and summary hooks to utilize shared transcript parser; introduce hook exit codes

- Moved user message extraction logic to a new shared module `transcript-parser.ts` for better code reuse.
- Updated `summary-hook.ts` to use the new `extractLastMessage` function for retrieving user and assistant messages.
- Replaced direct exit code usage in `user-message-hook.ts` with constants from `hook-constants.ts` for improved readability and maintainability.
- Added `HOOK_EXIT_CODES` to `hook-constants.ts` to standardize exit codes across hooks.

* Refactor hook input interfaces to enforce required fields

- Updated `SessionStartInput`, `UserPromptSubmitInput`, `PostToolUseInput`, and `StopInput` interfaces to require `session_id`, `transcript_path`, and `cwd` fields, ensuring better type safety and clarity in hook inputs.
- Removed optional index signatures from these interfaces to prevent unintended properties and improve code maintainability.
- Adjusted related hook implementations to align with the new interface definitions.

* Refactor save-hook to remove tool skipping logic; enhance summary-hook to handle spinner stopping with error logging; update SessionRoutes to load skip tools from settings; add CLAUDE_MEM_SKIP_TOOLS to SettingsDefaultsManager for configurable tool exclusion.

* Document CLAUDE_MEM_SKIP_TOOLS setting in public docs

Added documentation for the new CLAUDE_MEM_SKIP_TOOLS configuration
setting in response to PR review feedback. Users can now discover and
customize which tools are excluded from observations.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Alex Newman
2025-12-09 22:45:22 -05:00
committed by GitHub
parent 84c4d62812
commit eaba21329c
24 changed files with 342 additions and 396 deletions
+1 -15
View File
@@ -13,9 +13,6 @@ import { HOOK_TIMEOUTS } from '../shared/hook-constants.js';
export interface SessionEndInput {
session_id: string;
cwd: string;
transcript_path?: string;
hook_event_name: string;
reason: 'exit' | 'clear' | 'logout' | 'prompt_input_exit' | 'other';
}
@@ -28,22 +25,11 @@ async function cleanupHook(input?: SessionEndInput): Promise<void> {
happy_path_error__with_fallback('[cleanup-hook] Hook fired', {
session_id: input?.session_id,
cwd: input?.cwd,
reason: input?.reason
});
// Handle standalone execution (no input provided)
if (!input) {
console.log('No input provided - this script is designed to run as a Claude Code SessionEnd hook');
console.log('\nExpected input format:');
console.log(JSON.stringify({
session_id: "string",
cwd: "string",
transcript_path: "string",
hook_event_name: "SessionEnd",
reason: "exit"
}, null, 2));
process.exit(0);
throw new Error('cleanup-hook requires input from Claude Code');
}
const { session_id, reason } = input;
+5 -10
View File
@@ -10,14 +10,13 @@ import path from "path";
import { stdin } from "process";
import { ensureWorkerRunning, getWorkerPort } from "../shared/worker-utils.js";
import { HOOK_TIMEOUTS } from "../shared/hook-constants.js";
import { handleWorkerError } from "../shared/hook-error-handler.js";
export interface SessionStartInput {
session_id?: string;
transcript_path?: string;
cwd?: string;
session_id: string;
transcript_path: string;
cwd: string;
hook_event_name?: string;
source?: "startup" | "resume" | "clear" | "compact";
[key: string]: any;
}
async function contextHook(input?: SessionStartInput): Promise<string> {
@@ -41,11 +40,7 @@ async function contextHook(input?: SessionStartInput): Promise<string> {
const result = await response.text();
return result.trim();
} catch (error: any) {
// Only show restart message for connection errors, not HTTP errors
if (error.cause?.code === 'ECONNREFUSED' || error.name === 'TimeoutError' || error.message.includes('fetch failed')) {
throw new Error("There's a problem with the worker. If you just updated, type `pm2 restart claude-mem-worker` in your terminal to continue");
}
throw error;
handleWorkerError(error);
}
}
+1 -16
View File
@@ -1,4 +1,4 @@
export type HookType = 'PreCompact' | 'SessionStart' | 'UserPromptSubmit' | 'PostToolUse' | 'Stop' | string;
export type HookType = 'SessionStart' | 'UserPromptSubmit' | 'PostToolUse' | 'Stop';
export interface HookResponseOptions {
reason?: string;
@@ -20,21 +20,6 @@ function buildHookResponse(
success: boolean,
options: HookResponseOptions
): HookResponse {
if (hookType === 'PreCompact') {
if (success) {
return {
continue: true,
suppressOutput: true
};
}
return {
continue: false,
stopReason: options.reason || 'Pre-compact operation failed',
suppressOutput: true
};
}
if (hookType === 'SessionStart') {
if (success && options.context) {
return {
+5 -57
View File
@@ -1,49 +1,14 @@
/**
* New Hook - UserPromptSubmit
*
* DUAL PURPOSE HOOK: Handles BOTH session initialization AND continuation
* ==========================================================================
*
* CRITICAL ARCHITECTURE FACTS (NEVER FORGET):
*
* 1. SESSION ID THREADING - The Single Source of Truth
* - Claude Code assigns ONE session_id per conversation
* - ALL hooks in that conversation receive the SAME session_id
* - We ALWAYS use this session_id - NEVER generate our own
* - This is how NEW hook, SAVE hook, and SUMMARY hook stay connected
*
* 2. NO EXISTENCE CHECKS NEEDED
* - createSDKSession is idempotent (INSERT OR IGNORE)
* - Prompt #1: Creates new database row, returns new ID
* - Prompt #2+: Row exists, returns existing ID
* - We NEVER need to check "does session exist?" - just use the session_id
*
* 3. CONTINUATION LOGIC LOCATION
* - This hook does NOT contain continuation prompt logic
* - That lives in SDKAgent.ts (lines 125-127)
* - SDKAgent checks promptNumber to choose init vs continuation prompt
* - BOTH prompts receive the SAME session_id from this hook
*
* 4. UNIFIED WITH SAVE HOOK
* - SAVE hook uses: db.createSDKSession(session_id, '', '')
* - NEW hook uses: db.createSDKSession(session_id, project, prompt)
* - Both use session_id from hook context - this keeps everything connected
*
* This is KISS in action: Use the session_id we're given, trust idempotent
* database operations, and let SDKAgent handle init vs continuation logic.
*/
import path from 'path';
import { stdin } from 'process';
import { createHookResponse } from './hook-response.js';
import { ensureWorkerRunning, getWorkerPort } from '../shared/worker-utils.js';
import { happy_path_error__with_fallback } from '../utils/silent-debug.js';
import { handleWorkerError } from '../shared/hook-error-handler.js';
export interface UserPromptSubmitInput {
session_id: string;
cwd: string;
prompt: string;
[key: string]: any;
}
@@ -59,25 +24,12 @@ async function newHook(input?: UserPromptSubmitInput): Promise<void> {
}
const { session_id, cwd, prompt } = input;
// Debug: Log what we received
happy_path_error__with_fallback('[new-hook] Input received', {
session_id,
cwd,
cwd_type: typeof cwd,
cwd_length: cwd?.length,
has_cwd: !!cwd,
prompt_length: prompt?.length
});
const project = path.basename(cwd);
happy_path_error__with_fallback('[new-hook] Project extracted', {
happy_path_error__with_fallback('[new-hook] Input received', {
session_id,
project,
project_type: typeof project,
project_length: project?.length,
is_empty: project === '',
cwd_was: cwd
prompt_length: prompt?.length
});
const port = getWorkerPort();
@@ -116,11 +68,7 @@ async function newHook(input?: UserPromptSubmitInput): Promise<void> {
console.error(`[new-hook] Session ${sessionDbId}, prompt #${promptNumber}`);
} catch (error: any) {
// Only show restart message for connection errors, not HTTP errors
if (error.cause?.code === 'ECONNREFUSED' || error.name === 'TimeoutError' || error.message.includes('fetch failed')) {
throw new Error("There's a problem with the worker. If you just updated, type `pm2 restart claude-mem-worker` in your terminal to continue");
}
throw error;
handleWorkerError(error);
}
console.log(createHookResponse('UserPromptSubmit', true));
+2 -20
View File
@@ -12,6 +12,7 @@ import { logger } from '../utils/logger.js';
import { ensureWorkerRunning, getWorkerPort } from '../shared/worker-utils.js';
import { HOOK_TIMEOUTS } from '../shared/hook-constants.js';
import { happy_path_error__with_fallback } from '../utils/silent-debug.js';
import { handleWorkerError } from '../shared/hook-error-handler.js';
export interface PostToolUseInput {
session_id: string;
@@ -19,18 +20,8 @@ export interface PostToolUseInput {
tool_name: string;
tool_input: any;
tool_response: any;
[key: string]: any;
}
// Tools to skip (low value or too frequent)
const SKIP_TOOLS = new Set([
'ListMcpResourcesTool', // MCP infrastructure
'SlashCommand', // Command invocation (observe what it produces, not the call)
'Skill', // Skill invocation (observe what it produces, not the call)
'TodoWrite', // Task management meta-tool
'AskUserQuestion' // User interaction, not substantive work
]);
/**
* Save Hook Main Logic - Fire-and-forget HTTP client
*/
@@ -44,11 +35,6 @@ async function saveHook(input?: PostToolUseInput): Promise<void> {
const { session_id, cwd, tool_name, tool_input, tool_response } = input;
if (SKIP_TOOLS.has(tool_name)) {
console.log(createHookResponse('PostToolUse', true));
return;
}
const port = getWorkerPort();
const toolStr = logger.formatTool(tool_name, tool_input);
@@ -86,11 +72,7 @@ async function saveHook(input?: PostToolUseInput): Promise<void> {
logger.debug('HOOK', 'Observation sent successfully', { toolName: tool_name });
} catch (error: any) {
// Only show restart message for connection errors, not HTTP errors
if (error.cause?.code === 'ECONNREFUSED' || error.name === 'TimeoutError' || error.message.includes('fetch failed')) {
throw new Error("There's a problem with the worker. If you just updated, type `pm2 restart claude-mem-worker` in your terminal to continue");
}
throw error;
handleWorkerError(error);
}
console.log(createHookResponse('PostToolUse', true));
+20 -120
View File
@@ -10,122 +10,18 @@
*/
import { stdin } from 'process';
import { readFileSync, existsSync } from 'fs';
import { createHookResponse } from './hook-response.js';
import { logger } from '../utils/logger.js';
import { ensureWorkerRunning, getWorkerPort } from '../shared/worker-utils.js';
import { HOOK_TIMEOUTS } from '../shared/hook-constants.js';
import { happy_path_error__with_fallback } from '../utils/silent-debug.js';
import { handleWorkerError } from '../shared/hook-error-handler.js';
import { extractLastMessage } from '../shared/transcript-parser.js';
export interface StopInput {
session_id: string;
cwd: string;
transcript_path?: string;
[key: string]: any;
}
/**
* Extract last user message from transcript JSONL file
*/
function extractLastUserMessage(transcriptPath: string): string {
if (!transcriptPath || !existsSync(transcriptPath)) {
return '';
}
try {
const content = readFileSync(transcriptPath, 'utf-8').trim();
if (!content) {
return '';
}
const lines = content.split('\n');
// Parse JSONL and find last user message
for (let i = lines.length - 1; i >= 0; i--) {
try {
const line = JSON.parse(lines[i]);
// Claude Code transcript format: {type: "user", message: {role: "user", content: [...]}}
if (line.type === 'user' && line.message?.content) {
const content = line.message.content;
// Extract text content (handle both string and array formats)
if (typeof content === 'string') {
return content;
} else if (Array.isArray(content)) {
const textParts = content
.filter((c: any) => c.type === 'text')
.map((c: any) => c.text);
return textParts.join('\n');
}
}
} catch (parseError) {
// Skip malformed lines
continue;
}
}
} catch (error) {
logger.error('HOOK', 'Failed to read transcript', { transcriptPath }, error as Error);
}
return '';
}
/**
* Extract last assistant message from transcript JSONL file
* Filters out system-reminder tags to avoid polluting summaries
*/
function extractLastAssistantMessage(transcriptPath: string): string {
if (!transcriptPath || !existsSync(transcriptPath)) {
return '';
}
try {
const content = readFileSync(transcriptPath, 'utf-8').trim();
if (!content) {
return '';
}
const lines = content.split('\n');
// Parse JSONL and find last assistant message
for (let i = lines.length - 1; i >= 0; i--) {
try {
const line = JSON.parse(lines[i]);
// Claude Code transcript format: {type: "assistant", message: {role: "assistant", content: [...]}}
if (line.type === 'assistant' && line.message?.content) {
let text = '';
const content = line.message.content;
// Extract text content (handle both string and array formats)
if (typeof content === 'string') {
text = content;
} else if (Array.isArray(content)) {
const textParts = content
.filter((c: any) => c.type === 'text')
.map((c: any) => c.text);
text = textParts.join('\n');
}
// Filter out system-reminder tags and their content
text = text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '');
// Clean up excessive whitespace
text = text.replace(/\n{3,}/g, '\n\n').trim();
return text;
}
} catch (parseError) {
// Skip malformed lines
continue;
}
}
} catch (error) {
logger.error('HOOK', 'Failed to read transcript', { transcriptPath }, error as Error);
}
return '';
transcript_path: string;
}
/**
@@ -149,8 +45,8 @@ async function summaryHook(input?: StopInput): Promise<void> {
{ session_id },
input.transcript_path || ''
);
const lastUserMessage = extractLastUserMessage(transcriptPath);
const lastAssistantMessage = extractLastAssistantMessage(transcriptPath);
const lastUserMessage = extractLastMessage(transcriptPath, 'user');
const lastAssistantMessage = extractLastMessage(transcriptPath, 'assistant', true);
logger.dataIn('HOOK', 'Stop: Requesting summary', {
workerPort: port,
@@ -181,18 +77,22 @@ async function summaryHook(input?: StopInput): Promise<void> {
logger.debug('HOOK', 'Summary request sent successfully');
} catch (error: any) {
// Only show restart message for connection errors, not HTTP errors
if (error.cause?.code === 'ECONNREFUSED' || error.name === 'TimeoutError' || error.message.includes('fetch failed')) {
throw new Error("There's a problem with the worker. If you just updated, type `pm2 restart claude-mem-worker` in your terminal to continue");
}
throw error;
handleWorkerError(error);
} finally {
// Notify worker to stop spinner (fire-and-forget)
fetch(`http://127.0.0.1:${port}/api/processing`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isProcessing: false })
}).catch(() => {});
// Stop processing spinner
try {
const spinnerResponse = await fetch(`http://127.0.0.1:${port}/api/processing`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isProcessing: false }),
signal: AbortSignal.timeout(2000)
});
if (!spinnerResponse.ok) {
logger.warn('HOOK', 'Failed to stop spinner', { status: spinnerResponse.status });
}
} catch (error: any) {
logger.warn('HOOK', 'Could not stop spinner', { error: error.message });
}
}
console.log(createHookResponse('Stop', true));
+2 -44
View File
@@ -8,6 +8,7 @@
*/
import { basename } from "path";
import { ensureWorkerRunning, getWorkerPort } from "../shared/worker-utils.js";
import { HOOK_EXIT_CODES } from "../shared/hook-constants.js";
try {
// Ensure worker is running
@@ -28,55 +29,12 @@ try {
const output = await response.text();
// If it's after Dec 5, 2025 7pm EST, patch this out
const now = new Date();
const amaEndDate = new Date('2025-12-06T00:00:00Z'); // Dec 5, 2025 7pm EST
// Product Hunt launch announcement - expires Dec 5, 2025 12am EST (05:00 UTC)
const phLaunchEndDate = new Date('2025-12-05T05:00:00Z');
let productHuntAnnouncement = "";
if (now < phLaunchEndDate) {
productHuntAnnouncement = `
🚀 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 🚀
We launched on Product Hunt!
https://tinyurl.com/claude-mem-ph
⭐ Your upvote means the world - thank you!
🚀 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 🚀
`;
}
let amaAnnouncement = "";
if (now < amaEndDate) {
// Check if we're during the live event (Dec 1-5, 5pm-7pm EST daily)
const estOffset = 5 * 60; // EST is UTC-5
const nowUtcMinutes = now.getUTCHours() * 60 + now.getUTCMinutes();
const estHour = Math.floor((nowUtcMinutes - estOffset + 1440) % 1440 / 60);
const day = now.getUTCDate();
const month = now.getUTCMonth();
const year = now.getUTCFullYear();
const isDec1to5 = year === 2025 && month === 11 && day >= 1 && day <= 5;
const isDuringLiveHours = estHour >= 17 && estHour < 19; // 5pm-7pm EST
if (isDec1to5 && isDuringLiveHours) {
amaAnnouncement = "\n 🔴 LIVE NOW: AMA w/ Dev (@thedotmack) until 7pm EST\n";
} else {
amaAnnouncement = "\n LIVE AMA w/ Dev (@thedotmack) Dec 1st5th, 5pm to 7pm EST\n";
}
}
console.error(
"\n\n📝 Claude-Mem Context Loaded\n" +
" ️ Note: This appears as stderr but is informational only\n\n" +
output +
"\n\n💡 New! Wrap all or part of any message with <private> ... </private> to prevent storing sensitive information in your observation history.\n" +
"\n💬 Community https://discord.gg/J4wttp9vDu" +
productHuntAnnouncement +
amaAnnouncement +
`\n📺 Watch live in browser http://localhost:${port}/\n`
);
@@ -103,4 +61,4 @@ This message was not added to your startup context, so you can continue working
`);
}
process.exit(3);
process.exit(HOOK_EXIT_CODES.USER_MESSAGE_ONLY);