claude-mem 2.0.9 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/claude-mem +0 -0
- package/hooks/hook-utils.js +318 -0
- package/hooks/pre-compact.js +41 -121
- package/hooks/session-end.js +55 -135
- package/hooks/session-start.js +54 -169
- package/package.json +1 -1
- package/src/claude-mem.js +7 -7
package/claude-mem
CHANGED
|
Binary file
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared utilities for Claude Code hooks
|
|
3
|
+
*
|
|
4
|
+
* This module provides common functionality for all hook files to eliminate
|
|
5
|
+
* duplicated code patterns across pre-compact.js, session-start.js, etc.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { spawn } from 'child_process';
|
|
9
|
+
import { readFileSync, existsSync } from 'fs';
|
|
10
|
+
import { join } from 'path';
|
|
11
|
+
import { homedir } from 'os';
|
|
12
|
+
|
|
13
|
+
// Import constants - note: hooks are JavaScript, not TypeScript
|
|
14
|
+
const PACKAGE_NAME = 'claude-mem';
|
|
15
|
+
const CLAUDE_MEM_BASE = '.claude-mem';
|
|
16
|
+
|
|
17
|
+
// =============================================================================
|
|
18
|
+
// HOOK CONFIGURATION LOADING
|
|
19
|
+
// =============================================================================
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Load hook configuration from settings (eliminates ~33 lines per hook)
|
|
23
|
+
*/
|
|
24
|
+
export function loadHookConfig() {
|
|
25
|
+
const settingsPath = join(homedir(), CLAUDE_MEM_BASE, 'settings.json');
|
|
26
|
+
|
|
27
|
+
if (!existsSync(settingsPath)) {
|
|
28
|
+
return {};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const content = readFileSync(settingsPath, 'utf8');
|
|
33
|
+
return JSON.parse(content);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
console.warn(`Warning: Could not load hook config: ${error.message}`);
|
|
36
|
+
return {};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// =============================================================================
|
|
41
|
+
// STDIN JSON READING
|
|
42
|
+
// =============================================================================
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Read and parse JSON from stdin (removes 30+ duplicate lines)
|
|
46
|
+
* Returns a Promise that resolves with the parsed JSON data
|
|
47
|
+
*/
|
|
48
|
+
export function readStdinJson() {
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
let input = '';
|
|
51
|
+
|
|
52
|
+
process.stdin.on('data', chunk => {
|
|
53
|
+
input += chunk;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
process.stdin.on('end', () => {
|
|
57
|
+
try {
|
|
58
|
+
const data = JSON.parse(input);
|
|
59
|
+
resolve(data);
|
|
60
|
+
} catch (error) {
|
|
61
|
+
reject(new Error(`Invalid JSON input: ${error.message}`));
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
process.stdin.on('error', error => {
|
|
66
|
+
reject(new Error(`Stdin error: ${error.message}`));
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// =============================================================================
|
|
72
|
+
// HOOK RESPONSE HELPERS
|
|
73
|
+
// =============================================================================
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Standard hook response objects (matches constants.ts HOOK_RESPONSES pattern)
|
|
77
|
+
*/
|
|
78
|
+
export const HookResponse = {
|
|
79
|
+
/**
|
|
80
|
+
* Success response with optional message (matches HOOK_RESPONSES.SUCCESS pattern)
|
|
81
|
+
*/
|
|
82
|
+
success(hookEventName, message) {
|
|
83
|
+
return {
|
|
84
|
+
hookSpecificOutput: {
|
|
85
|
+
hookEventName,
|
|
86
|
+
status: "success",
|
|
87
|
+
message
|
|
88
|
+
},
|
|
89
|
+
suppressOutput: true
|
|
90
|
+
};
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Skipped response (matches HOOK_RESPONSES.SKIPPED pattern)
|
|
95
|
+
*/
|
|
96
|
+
skipped(hookEventName, message) {
|
|
97
|
+
return {
|
|
98
|
+
hookSpecificOutput: {
|
|
99
|
+
hookEventName,
|
|
100
|
+
status: "skipped",
|
|
101
|
+
message
|
|
102
|
+
},
|
|
103
|
+
suppressOutput: true
|
|
104
|
+
};
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Error response (matches HOOK_RESPONSES.ERROR pattern)
|
|
109
|
+
*/
|
|
110
|
+
error(hookEventName, message) {
|
|
111
|
+
return {
|
|
112
|
+
continue: false,
|
|
113
|
+
stopReason: message,
|
|
114
|
+
suppressOutput: true,
|
|
115
|
+
hookSpecificOutput: {
|
|
116
|
+
hookEventName,
|
|
117
|
+
status: "error",
|
|
118
|
+
message
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Permission response for tool use
|
|
125
|
+
*/
|
|
126
|
+
permission(decision, reason = '') {
|
|
127
|
+
return {
|
|
128
|
+
continue: true,
|
|
129
|
+
permissionDecision: decision, // 'allow' or 'deny'
|
|
130
|
+
permissionDecisionReason: reason
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// =============================================================================
|
|
136
|
+
// CLI SPAWNING UTILITIES
|
|
137
|
+
// =============================================================================
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Spawn CLI command (consolidates 90+ lines of spawn logic)
|
|
141
|
+
*/
|
|
142
|
+
export function spawnCLI(command, args = [], options = {}) {
|
|
143
|
+
return new Promise((resolve, reject) => {
|
|
144
|
+
const defaultOptions = {
|
|
145
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
146
|
+
env: process.env,
|
|
147
|
+
timeout: 30000, // 30 second default timeout
|
|
148
|
+
...options
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// Find CLI executable
|
|
152
|
+
const possibleCommands = [
|
|
153
|
+
PACKAGE_NAME,
|
|
154
|
+
`npx ${PACKAGE_NAME}`,
|
|
155
|
+
join(process.cwd(), 'node_modules', '.bin', PACKAGE_NAME),
|
|
156
|
+
join(homedir(), '.npm-global', 'bin', PACKAGE_NAME)
|
|
157
|
+
];
|
|
158
|
+
|
|
159
|
+
let claudeMemCmd = command;
|
|
160
|
+
if (!claudeMemCmd) {
|
|
161
|
+
// Try to find the best command
|
|
162
|
+
claudeMemCmd = PACKAGE_NAME; // Default fallback
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const child = spawn(claudeMemCmd, args, defaultOptions);
|
|
166
|
+
|
|
167
|
+
let stdout = '';
|
|
168
|
+
let stderr = '';
|
|
169
|
+
|
|
170
|
+
child.stdout?.on('data', (data) => {
|
|
171
|
+
stdout += data.toString();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
child.stderr?.on('data', (data) => {
|
|
175
|
+
stderr += data.toString();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
child.on('close', (code) => {
|
|
179
|
+
if (code === 0) {
|
|
180
|
+
resolve({
|
|
181
|
+
code,
|
|
182
|
+
stdout: stdout.trim(),
|
|
183
|
+
stderr: stderr.trim()
|
|
184
|
+
});
|
|
185
|
+
} else {
|
|
186
|
+
reject(new Error(`Command failed with code ${code}: ${stderr.trim()}`));
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
child.on('error', (error) => {
|
|
191
|
+
reject(new Error(`Failed to spawn command: ${error.message}`));
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// Handle timeout
|
|
195
|
+
if (defaultOptions.timeout) {
|
|
196
|
+
setTimeout(() => {
|
|
197
|
+
child.kill('SIGTERM');
|
|
198
|
+
reject(new Error(`Command timed out after ${defaultOptions.timeout}ms`));
|
|
199
|
+
}, defaultOptions.timeout);
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Run CLI command with specific subcommand and arguments
|
|
206
|
+
*/
|
|
207
|
+
export async function runClaudeMemCommand(subcommand, args = [], options = {}) {
|
|
208
|
+
const fullArgs = [subcommand, ...args];
|
|
209
|
+
return spawnCLI(PACKAGE_NAME, fullArgs, options);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// =============================================================================
|
|
213
|
+
// PROJECT NAME EXTRACTION
|
|
214
|
+
// =============================================================================
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Extract project name from transcript path
|
|
218
|
+
* Consolidates the 3 different implementations found in the codebase
|
|
219
|
+
*/
|
|
220
|
+
export function extractProjectName(transcriptPath) {
|
|
221
|
+
if (!transcriptPath) {
|
|
222
|
+
return 'unknown';
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Standard pattern: Scripts/[project-name]/...
|
|
226
|
+
const match = transcriptPath.match(/Scripts[\\/\\]([^\\/\\]+)/);
|
|
227
|
+
if (match) {
|
|
228
|
+
return match[1];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Fallback: try to extract from directory structure
|
|
232
|
+
const parts = transcriptPath.split(/[\\/]/);
|
|
233
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
234
|
+
if (parts[i] === 'Scripts' && parts[i + 1]) {
|
|
235
|
+
return parts[i + 1];
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return 'unknown';
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// =============================================================================
|
|
243
|
+
// UTILITY HELPERS
|
|
244
|
+
// =============================================================================
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Safe JSON output for hook responses
|
|
248
|
+
*/
|
|
249
|
+
export function outputJSON(data) {
|
|
250
|
+
try {
|
|
251
|
+
console.log(JSON.stringify(data, null, 2));
|
|
252
|
+
} catch (error) {
|
|
253
|
+
console.error(JSON.stringify({
|
|
254
|
+
continue: false,
|
|
255
|
+
stopReason: `Failed to serialize response: ${error.message}`
|
|
256
|
+
}));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Debug logging helper
|
|
262
|
+
*/
|
|
263
|
+
export function debugLog(message) {
|
|
264
|
+
if (process.env.CLAUDE_MEM_DEBUG) {
|
|
265
|
+
console.error(`[DEBUG] ${message}`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Validate hook payload structure
|
|
271
|
+
*/
|
|
272
|
+
export function validateHookPayload(payload, requiredFields = []) {
|
|
273
|
+
const errors = [];
|
|
274
|
+
|
|
275
|
+
if (!payload || typeof payload !== 'object') {
|
|
276
|
+
errors.push('Payload must be an object');
|
|
277
|
+
return errors;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
requiredFields.forEach(field => {
|
|
281
|
+
if (!(field in payload)) {
|
|
282
|
+
errors.push(`Missing required field: ${field}`);
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
return errors;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Create hook execution context
|
|
291
|
+
*/
|
|
292
|
+
export function createHookContext(payload) {
|
|
293
|
+
return {
|
|
294
|
+
sessionId: payload.session_id || 'unknown',
|
|
295
|
+
transcriptPath: payload.transcript_path || null,
|
|
296
|
+
hookEventName: payload.hook_event_name || 'unknown',
|
|
297
|
+
source: payload.source || 'unknown', // For SessionStart hooks
|
|
298
|
+
timestamp: new Date().toISOString(),
|
|
299
|
+
projectName: payload.transcript_path ? extractProjectName(payload.transcript_path) : null
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// =============================================================================
|
|
304
|
+
// EXPORT ALL UTILITIES
|
|
305
|
+
// =============================================================================
|
|
306
|
+
|
|
307
|
+
export default {
|
|
308
|
+
loadHookConfig,
|
|
309
|
+
readStdinJson,
|
|
310
|
+
HookResponse,
|
|
311
|
+
spawnCLI,
|
|
312
|
+
runClaudeMemCommand,
|
|
313
|
+
extractProjectName,
|
|
314
|
+
outputJSON,
|
|
315
|
+
debugLog,
|
|
316
|
+
validateHookPayload,
|
|
317
|
+
createHookContext
|
|
318
|
+
};
|
package/hooks/pre-compact.js
CHANGED
|
@@ -1,138 +1,58 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* OFFICIAL DOCS: Claude Code Hooks API v2025
|
|
7
|
-
* Last Verified: 2025-08-31
|
|
8
|
-
* @see https://docs.anthropic.com/en/docs/claude-code/hooks
|
|
9
|
-
*
|
|
10
|
-
* Claude Memory System - Pre-Compact Hook
|
|
11
|
-
*
|
|
12
|
-
* CRITICAL REQUIREMENTS:
|
|
13
|
-
* - Hook responses MUST use 'continue' field (boolean)
|
|
14
|
-
* - When continue is false, MUST provide 'stopReason' field
|
|
15
|
-
* - DO NOT use 'decision' or 'reason' fields (deprecated pattern)
|
|
16
|
-
* - PreCompact hooks DO NOT support hookSpecificOutput field
|
|
17
|
-
* - Exit codes: 0=success, 1=error shown to user, 2=error with stderr shown
|
|
18
|
-
*
|
|
19
|
-
* Valid Response Format:
|
|
20
|
-
* Success: { "continue": true }
|
|
21
|
-
* Failure: { "continue": false, "stopReason": "error message" }
|
|
22
|
-
*
|
|
23
|
-
* @docs-ref: Official hook response format specification
|
|
24
|
-
* @see https://docs.anthropic.com/claude-code/hooks#response-format
|
|
4
|
+
* Claude Memory System - Pre-Compact Hook (Refactored)
|
|
5
|
+
* Uses shared utilities to eliminate code duplication
|
|
25
6
|
*/
|
|
26
7
|
|
|
27
|
-
import {
|
|
28
|
-
import { join, dirname } from 'path';
|
|
29
|
-
import { fileURLToPath } from 'url';
|
|
30
|
-
import { readFileSync, existsSync } from 'fs';
|
|
31
|
-
|
|
32
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
33
|
-
|
|
34
|
-
// Load configuration to get the CLI command name
|
|
35
|
-
let cliCommand = 'claude-mem'; // Default fallback
|
|
36
|
-
const configPath = join(__dirname, 'config.json');
|
|
37
|
-
if (existsSync(configPath)) {
|
|
38
|
-
try {
|
|
39
|
-
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
40
|
-
cliCommand = config.cliCommand || 'claude-mem';
|
|
41
|
-
} catch (e) {
|
|
42
|
-
// Fallback to default if config read fails
|
|
43
|
-
}
|
|
44
|
-
}
|
|
8
|
+
import { loadHookConfig, readStdinJson, HookResponse, spawnCLI } from './hook-utils.js';
|
|
45
9
|
|
|
46
10
|
async function preCompactHook() {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
11
|
+
try {
|
|
12
|
+
// Load config and read input using utilities
|
|
13
|
+
const config = loadHookConfig();
|
|
14
|
+
const data = await readStdinJson();
|
|
15
|
+
|
|
16
|
+
const transcriptPath = data.transcript_path;
|
|
17
|
+
|
|
18
|
+
if (!transcriptPath) {
|
|
19
|
+
console.log(JSON.stringify(HookResponse.error("No transcript path provided")));
|
|
20
|
+
process.exit(2);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Use spawnCLI utility for compression
|
|
24
|
+
const cliCommand = config.cliCommand || 'claude-mem';
|
|
25
|
+
|
|
55
26
|
try {
|
|
56
|
-
|
|
57
|
-
const transcriptPath = data.transcript_path;
|
|
58
|
-
|
|
59
|
-
if (!transcriptPath) {
|
|
60
|
-
// Output error in Claude Code expected format
|
|
61
|
-
// @docs-ref: continue: false with stopReason for blocking operations
|
|
62
|
-
console.log(JSON.stringify({
|
|
63
|
-
continue: false,
|
|
64
|
-
stopReason: "No transcript path provided"
|
|
65
|
-
}));
|
|
66
|
-
process.exit(2); // Exit 2 tells Claude Code to show error
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// Call the CLI compress command using configured name
|
|
70
|
-
const compressor = spawn(cliCommand, ['compress', transcriptPath], {
|
|
71
|
-
stdio: ['ignore', 'pipe', 'pipe'] // Capture output instead of inherit
|
|
72
|
-
});
|
|
27
|
+
await spawnCLI(cliCommand, ['compress', transcriptPath]);
|
|
73
28
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
compressor.stderr.on('data', (data) => {
|
|
82
|
-
stderr += data.toString();
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
compressor.on('close', (code) => {
|
|
86
|
-
if (code !== 0) {
|
|
87
|
-
// Check if it's the Claude Code executable error
|
|
88
|
-
if (stderr.includes('Claude Code executable not found')) {
|
|
89
|
-
// This is expected when running outside Claude Code context
|
|
90
|
-
// Just output success since we can't compress without Claude SDK
|
|
91
|
-
// @docs-ref: PreCompact hooks should only use continue field, not hookSpecificOutput
|
|
92
|
-
console.log(JSON.stringify({
|
|
93
|
-
continue: true,
|
|
94
|
-
suppressOutput: true,
|
|
95
|
-
systemMessage: "Memory compression skipped - Claude SDK not available in this context"
|
|
96
|
-
}));
|
|
97
|
-
process.exit(0);
|
|
98
|
-
} else {
|
|
99
|
-
// Real error - report it
|
|
100
|
-
// @docs-ref: Use continue: false with stopReason for errors
|
|
101
|
-
console.log(JSON.stringify({
|
|
102
|
-
continue: false,
|
|
103
|
-
stopReason: `Compression failed: ${stderr}`
|
|
104
|
-
}));
|
|
105
|
-
process.exit(2);
|
|
106
|
-
}
|
|
107
|
-
} else {
|
|
108
|
-
// Success! Output confirmation
|
|
109
|
-
// @docs-ref: PreCompact hooks should only use continue field for success
|
|
110
|
-
console.log(JSON.stringify({
|
|
111
|
-
continue: true,
|
|
112
|
-
suppressOutput: true
|
|
113
|
-
}));
|
|
114
|
-
process.exit(0);
|
|
115
|
-
}
|
|
116
|
-
});
|
|
29
|
+
// Success - output response
|
|
30
|
+
console.log(JSON.stringify({
|
|
31
|
+
continue: true,
|
|
32
|
+
suppressOutput: true
|
|
33
|
+
}));
|
|
34
|
+
process.exit(0);
|
|
117
35
|
|
|
118
|
-
|
|
119
|
-
|
|
36
|
+
} catch (error) {
|
|
37
|
+
// Check for expected Claude SDK error
|
|
38
|
+
if (error.message.includes('Claude Code executable not found')) {
|
|
120
39
|
console.log(JSON.stringify({
|
|
121
|
-
continue:
|
|
122
|
-
|
|
40
|
+
continue: true,
|
|
41
|
+
suppressOutput: true,
|
|
42
|
+
systemMessage: "Memory compression skipped - Claude SDK not available in this context"
|
|
123
43
|
}));
|
|
44
|
+
process.exit(0);
|
|
45
|
+
} else {
|
|
46
|
+
// Real error
|
|
47
|
+
console.log(JSON.stringify(HookResponse.error(`Compression failed: ${error.message}`)));
|
|
124
48
|
process.exit(2);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
} catch (error) {
|
|
128
|
-
// @docs-ref: Use continue: false with stopReason for errors
|
|
129
|
-
console.log(JSON.stringify({
|
|
130
|
-
continue: false,
|
|
131
|
-
stopReason: `Hook error: ${error.message}`
|
|
132
|
-
}));
|
|
133
|
-
process.exit(2);
|
|
49
|
+
}
|
|
134
50
|
}
|
|
135
|
-
|
|
51
|
+
|
|
52
|
+
} catch (error) {
|
|
53
|
+
console.log(JSON.stringify(HookResponse.error(`Hook error: ${error.message}`)));
|
|
54
|
+
process.exit(2);
|
|
55
|
+
}
|
|
136
56
|
}
|
|
137
57
|
|
|
138
58
|
preCompactHook();
|
package/hooks/session-end.js
CHANGED
|
@@ -1,157 +1,77 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* Claude Memory System - Session-End Hook (Refactored)
|
|
5
|
+
* Uses shared utilities to eliminate code duplication
|
|
5
6
|
*
|
|
6
|
-
* OFFICIAL DOCS: Claude Code SessionEnd Hook v2025
|
|
7
|
-
* Last Verified: 2025-08-31
|
|
8
|
-
* @see https://docs.anthropic.com/en/docs/claude-code/hooks
|
|
9
|
-
*
|
|
10
|
-
* Claude Memory System - Session End Hook
|
|
11
7
|
* Triggers compression when session ends with reason 'clear'
|
|
12
|
-
*
|
|
13
|
-
* SessionEnd Hook Payload:
|
|
14
|
-
* {
|
|
15
|
-
* "session_id": "string",
|
|
16
|
-
* "transcript_path": "string",
|
|
17
|
-
* "cwd": "string",
|
|
18
|
-
* "hook_event_name": "SessionEnd",
|
|
19
|
-
* "reason": "exit" | "clear" | "error" | ...
|
|
20
|
-
* }
|
|
21
|
-
*
|
|
22
|
-
* Valid Response Format:
|
|
23
|
-
* Success: { "continue": true }
|
|
24
|
-
* Success with message: { "continue": true, "systemMessage": "string" }
|
|
25
|
-
* Failure: { "continue": false, "stopReason": "error message" }
|
|
26
|
-
*
|
|
27
|
-
* Note: SessionEnd hooks should generally always return continue: true
|
|
28
|
-
* to avoid blocking session termination.
|
|
29
|
-
*
|
|
30
|
-
* @docs-ref: SessionEnd hook should not block session ending
|
|
31
|
-
* @see https://docs.anthropic.com/claude-code/hooks#sessionend
|
|
8
|
+
* Always returns continue: true to avoid blocking session termination
|
|
32
9
|
*/
|
|
33
10
|
|
|
34
|
-
import {
|
|
35
|
-
import { join, dirname } from 'path';
|
|
36
|
-
import { fileURLToPath } from 'url';
|
|
37
|
-
import { readFileSync, existsSync } from 'fs';
|
|
38
|
-
|
|
39
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
40
|
-
|
|
41
|
-
// Load configuration to get the CLI command name
|
|
42
|
-
let cliCommand = 'claude-mem'; // Default fallback
|
|
43
|
-
const configPath = join(__dirname, 'config.json');
|
|
44
|
-
if (existsSync(configPath)) {
|
|
45
|
-
try {
|
|
46
|
-
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
47
|
-
cliCommand = config.cliCommand || 'claude-mem';
|
|
48
|
-
} catch (e) {
|
|
49
|
-
// Fallback to default if config read fails
|
|
50
|
-
}
|
|
51
|
-
}
|
|
11
|
+
import { loadHookConfig, readStdinJson, HookResponse, spawnCLI } from './hook-utils.js';
|
|
52
12
|
|
|
53
13
|
async function sessionEndHook() {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
14
|
+
try {
|
|
15
|
+
// Load config and read input using utilities
|
|
16
|
+
const config = loadHookConfig();
|
|
17
|
+
const data = await readStdinJson();
|
|
18
|
+
|
|
19
|
+
const { reason, transcript_path } = data;
|
|
20
|
+
|
|
21
|
+
// Only proceed if reason is 'clear'
|
|
22
|
+
if (reason !== 'clear') {
|
|
23
|
+
console.log(JSON.stringify({ continue: true }));
|
|
24
|
+
process.exit(0);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Validate transcript path
|
|
28
|
+
if (!transcript_path) {
|
|
29
|
+
console.log(JSON.stringify({
|
|
30
|
+
continue: true,
|
|
31
|
+
systemMessage: "Warning: No transcript path provided for compression"
|
|
32
|
+
}));
|
|
33
|
+
process.exit(0);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Use spawnCLI utility for compression
|
|
37
|
+
const cliCommand = config.cliCommand || 'claude-mem';
|
|
38
|
+
|
|
62
39
|
try {
|
|
63
|
-
|
|
64
|
-
const { reason, transcript_path, session_id } = data;
|
|
40
|
+
await spawnCLI(cliCommand, ['compress', transcript_path]);
|
|
65
41
|
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}));
|
|
73
|
-
process.exit(0);
|
|
74
|
-
}
|
|
42
|
+
// Success! Memory compressed before session clear
|
|
43
|
+
console.log(JSON.stringify({
|
|
44
|
+
continue: true,
|
|
45
|
+
suppressOutput: true,
|
|
46
|
+
systemMessage: "Memory compressed successfully before session clear"
|
|
47
|
+
}));
|
|
75
48
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
// Just continue even if we can't compress
|
|
49
|
+
} catch (error) {
|
|
50
|
+
// Check for expected Claude SDK error
|
|
51
|
+
if (error.message.includes('Claude Code executable not found')) {
|
|
80
52
|
console.log(JSON.stringify({
|
|
81
53
|
continue: true,
|
|
82
|
-
|
|
54
|
+
suppressOutput: true
|
|
83
55
|
}));
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
// Call the CLI compress command (same as pre-compact)
|
|
88
|
-
const compressor = spawn(cliCommand, ['compress', transcript_path], {
|
|
89
|
-
stdio: ['ignore', 'pipe', 'pipe']
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
let stdout = '';
|
|
93
|
-
let stderr = '';
|
|
94
|
-
|
|
95
|
-
compressor.stdout.on('data', (data) => {
|
|
96
|
-
stdout += data.toString();
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
compressor.stderr.on('data', (data) => {
|
|
100
|
-
stderr += data.toString();
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
compressor.on('close', (code) => {
|
|
104
|
-
if (code !== 0) {
|
|
105
|
-
// Check if it's the Claude Code executable error
|
|
106
|
-
if (stderr.includes('Claude Code executable not found')) {
|
|
107
|
-
// This is expected when running outside Claude Code context
|
|
108
|
-
// @docs-ref: SessionEnd hooks should not use hookSpecificOutput
|
|
109
|
-
console.log(JSON.stringify({
|
|
110
|
-
continue: true,
|
|
111
|
-
suppressOutput: true
|
|
112
|
-
}));
|
|
113
|
-
process.exit(0);
|
|
114
|
-
} else {
|
|
115
|
-
// Real error - report it but allow session to end
|
|
116
|
-
// @docs-ref: Always allow session to end, use systemMessage for errors
|
|
117
|
-
console.log(JSON.stringify({
|
|
118
|
-
continue: true,
|
|
119
|
-
systemMessage: `Memory compression failed: ${stderr}`
|
|
120
|
-
}));
|
|
121
|
-
process.exit(0);
|
|
122
|
-
}
|
|
123
|
-
} else {
|
|
124
|
-
// Success! Memory compressed before session clear
|
|
125
|
-
// @docs-ref: SessionEnd hooks should not use hookSpecificOutput
|
|
126
|
-
console.log(JSON.stringify({
|
|
127
|
-
continue: true,
|
|
128
|
-
suppressOutput: true,
|
|
129
|
-
systemMessage: "Memory compressed successfully before session clear"
|
|
130
|
-
}));
|
|
131
|
-
process.exit(0);
|
|
132
|
-
}
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
compressor.on('error', (error) => {
|
|
136
|
-
// Report error but allow session to end
|
|
137
|
-
// @docs-ref: Always allow session to end, use systemMessage for errors
|
|
56
|
+
} else {
|
|
57
|
+
// Real error - report it but allow session to end
|
|
138
58
|
console.log(JSON.stringify({
|
|
139
59
|
continue: true,
|
|
140
|
-
systemMessage: `
|
|
60
|
+
systemMessage: `Memory compression failed: ${error.message}`
|
|
141
61
|
}));
|
|
142
|
-
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
} catch (error) {
|
|
146
|
-
// Report error but allow session to end
|
|
147
|
-
// @docs-ref: Always allow session to end, use systemMessage for errors
|
|
148
|
-
console.log(JSON.stringify({
|
|
149
|
-
continue: true,
|
|
150
|
-
systemMessage: `Hook error: ${error.message}`
|
|
151
|
-
}));
|
|
152
|
-
process.exit(0);
|
|
62
|
+
}
|
|
153
63
|
}
|
|
154
|
-
|
|
64
|
+
|
|
65
|
+
process.exit(0);
|
|
66
|
+
|
|
67
|
+
} catch (error) {
|
|
68
|
+
// Report error but allow session to end
|
|
69
|
+
console.log(JSON.stringify({
|
|
70
|
+
continue: true,
|
|
71
|
+
systemMessage: `Hook error: ${error.message}`
|
|
72
|
+
}));
|
|
73
|
+
process.exit(0);
|
|
74
|
+
}
|
|
155
75
|
}
|
|
156
76
|
|
|
157
77
|
sessionEndHook();
|
package/hooks/session-start.js
CHANGED
|
@@ -1,190 +1,75 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* Claude Memory System - Session-Start Hook (Refactored)
|
|
5
|
+
* Uses shared utilities to eliminate code duplication
|
|
5
6
|
*
|
|
6
|
-
*
|
|
7
|
-
* Last Verified: 2025-08-31
|
|
8
|
-
* @see https://docs.anthropic.com/en/docs/claude-code/hooks
|
|
9
|
-
*
|
|
10
|
-
* SessionStart Hook Payload:
|
|
11
|
-
* {
|
|
12
|
-
* "session_id": "string",
|
|
13
|
-
* "transcript_path": "string",
|
|
14
|
-
* "hook_event_name": "SessionStart",
|
|
15
|
-
* "source": "startup" | "compact" | "vscode" | "web"
|
|
16
|
-
* }
|
|
17
|
-
*
|
|
18
|
-
* The 'source' field indicates how the session was initiated:
|
|
19
|
-
* - "startup": New session started normally
|
|
20
|
-
* - "compact": Session started after compaction
|
|
21
|
-
* - "vscode": Session initiated from VS Code
|
|
22
|
-
* - "web": Session initiated from web interface
|
|
23
|
-
*
|
|
24
|
-
* Valid Response Format:
|
|
25
|
-
* Success without context: { "continue": true }
|
|
26
|
-
* Success with context: {
|
|
27
|
-
* "continue": true,
|
|
28
|
-
* "hookSpecificOutput": {
|
|
29
|
-
* "hookEventName": "SessionStart",
|
|
30
|
-
* "additionalContext": "string" // Context to add to session
|
|
31
|
-
* }
|
|
32
|
-
* }
|
|
33
|
-
* Failure: { "continue": false, "stopReason": "error message" }
|
|
34
|
-
*
|
|
35
|
-
* @docs-ref: SessionStart supports hookSpecificOutput.additionalContext
|
|
36
|
-
* @see https://docs.anthropic.com/claude-code/hooks#sessionstart
|
|
7
|
+
* Always loads context regardless of source (startup/compact/vscode/web)
|
|
37
8
|
*/
|
|
38
9
|
|
|
39
|
-
import {
|
|
40
|
-
import {
|
|
41
|
-
import { fileURLToPath } from 'url';
|
|
42
|
-
import { readFileSync, existsSync } from 'fs';
|
|
43
|
-
|
|
44
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
45
|
-
|
|
46
|
-
// Load configuration to get the CLI command name
|
|
47
|
-
let cliCommand = 'claude-mem'; // Default fallback
|
|
48
|
-
const configPath = join(__dirname, 'config.json');
|
|
49
|
-
if (existsSync(configPath)) {
|
|
50
|
-
try {
|
|
51
|
-
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
52
|
-
cliCommand = config.cliCommand || 'claude-mem';
|
|
53
|
-
} catch (e) {
|
|
54
|
-
// Fallback to default if config read fails
|
|
55
|
-
}
|
|
56
|
-
}
|
|
10
|
+
import { loadHookConfig, readStdinJson, HookResponse, spawnCLI } from './hook-utils.js';
|
|
11
|
+
import { dirname, basename } from 'path';
|
|
57
12
|
|
|
58
13
|
async function sessionStartHook() {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* @docs-ref: SessionStart payload includes 'source' field
|
|
72
|
-
* See: https://docs.anthropic.com/en/docs/claude-code/hooks#sessionstart
|
|
73
|
-
*
|
|
74
|
-
* The 'source' field indicates the session start context:
|
|
75
|
-
* - 'startup': New session (load context)
|
|
76
|
-
* - 'compact': Session after compaction (SHOULD load context)
|
|
77
|
-
* - 'vscode': VS Code initiated session
|
|
78
|
-
* - 'web': Web interface initiated session
|
|
79
|
-
*
|
|
80
|
-
* IMPORTANT: We always load context regardless of source.
|
|
81
|
-
* After compaction, users expect to see their compressed memories loaded.
|
|
82
|
-
* The session after compact is effectively a new session with compressed context.
|
|
83
|
-
*
|
|
84
|
-
* There is no specific indicator for /continue command in the payload,
|
|
85
|
-
* so we treat all session starts equally and load available context.
|
|
86
|
-
*/
|
|
87
|
-
// Always proceed to load context for all session types
|
|
14
|
+
try {
|
|
15
|
+
// Load config and read input using utilities
|
|
16
|
+
const config = loadHookConfig();
|
|
17
|
+
const data = await readStdinJson();
|
|
18
|
+
|
|
19
|
+
// Extract project name from transcript path if available
|
|
20
|
+
let projectName = null;
|
|
21
|
+
if (data.transcript_path) {
|
|
22
|
+
const dir = dirname(data.transcript_path);
|
|
23
|
+
const dirName = basename(dir);
|
|
88
24
|
|
|
89
|
-
// Extract project name from
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
// Extract project name from directory pattern
|
|
96
|
-
if (dirName.includes('-Scripts-')) {
|
|
97
|
-
const parts = dirName.split('-Scripts-');
|
|
98
|
-
if (parts.length > 1) {
|
|
99
|
-
projectName = parts[1];
|
|
100
|
-
}
|
|
101
|
-
} else {
|
|
102
|
-
projectName = dirName;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// Sanitize project name to match what compression uses
|
|
106
|
-
// This ensures we match the exact project name in the index
|
|
107
|
-
if (projectName) {
|
|
108
|
-
projectName = projectName.replace(/[^a-zA-Z0-9]/g, '_');
|
|
25
|
+
// Extract project name from directory pattern
|
|
26
|
+
if (dirName.includes('-Scripts-')) {
|
|
27
|
+
const parts = dirName.split('-Scripts-');
|
|
28
|
+
if (parts.length > 1) {
|
|
29
|
+
projectName = parts[1];
|
|
109
30
|
}
|
|
31
|
+
} else {
|
|
32
|
+
projectName = dirName;
|
|
110
33
|
}
|
|
111
34
|
|
|
112
|
-
//
|
|
113
|
-
const args = ['load-context', '--format', 'session-start'];
|
|
114
|
-
|
|
115
|
-
// Add project filter if we have a project name
|
|
35
|
+
// Sanitize project name to match what compression uses
|
|
116
36
|
if (projectName) {
|
|
117
|
-
|
|
37
|
+
projectName = projectName.replace(/[^a-zA-Z0-9]/g, '_');
|
|
118
38
|
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Build command to run
|
|
42
|
+
const args = ['load-context', '--format', 'session-start'];
|
|
43
|
+
if (projectName) {
|
|
44
|
+
args.push('--project', projectName);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Use spawnCLI utility for loading context
|
|
48
|
+
const cliCommand = config.cliCommand || 'claude-mem';
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const result = await spawnCLI(cliCommand, args);
|
|
119
52
|
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
let stdout = '';
|
|
128
|
-
let stderr = '';
|
|
129
|
-
|
|
130
|
-
loader.stdout.on('data', (data) => {
|
|
131
|
-
stdout += data.toString();
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
loader.stderr.on('data', (data) => {
|
|
135
|
-
stderr += data.toString();
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
loader.on('close', (code) => {
|
|
139
|
-
if (code !== 0) {
|
|
140
|
-
// If load-context fails, just continue without context
|
|
141
|
-
// This could happen if no index exists yet
|
|
142
|
-
// @docs-ref: Always return valid response format
|
|
143
|
-
console.log(JSON.stringify({
|
|
144
|
-
continue: true
|
|
145
|
-
}));
|
|
146
|
-
process.exit(0);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// Output the formatted context for Claude to see using proper JSON format
|
|
150
|
-
// @docs-ref: SessionStart supports hookSpecificOutput.additionalContext
|
|
151
|
-
if (stdout && stdout.trim()) {
|
|
152
|
-
console.log(JSON.stringify({
|
|
153
|
-
continue: true,
|
|
154
|
-
hookSpecificOutput: {
|
|
155
|
-
hookEventName: "SessionStart",
|
|
156
|
-
additionalContext: stdout
|
|
157
|
-
}
|
|
158
|
-
}));
|
|
159
|
-
} else {
|
|
160
|
-
// No context to add, just continue
|
|
161
|
-
console.log(JSON.stringify({
|
|
162
|
-
continue: true
|
|
163
|
-
}));
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
process.exit(0);
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
loader.on('error', () => {
|
|
170
|
-
// If there's an error running the command, continue without context
|
|
171
|
-
// We don't want to break the session start
|
|
172
|
-
// @docs-ref: Always return valid response format
|
|
173
|
-
console.log(JSON.stringify({
|
|
174
|
-
continue: true
|
|
175
|
-
}));
|
|
176
|
-
process.exit(0);
|
|
177
|
-
});
|
|
53
|
+
// Output the formatted context if we have any
|
|
54
|
+
if (result.stdout && result.stdout.trim()) {
|
|
55
|
+
console.log(JSON.stringify(HookResponse.success("SessionStart", result.stdout)));
|
|
56
|
+
} else {
|
|
57
|
+
console.log(JSON.stringify({ continue: true }));
|
|
58
|
+
}
|
|
178
59
|
|
|
179
60
|
} catch (error) {
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
console.log(JSON.stringify({
|
|
183
|
-
continue: true
|
|
184
|
-
}));
|
|
185
|
-
process.exit(0);
|
|
61
|
+
// If load-context fails, just continue without context
|
|
62
|
+
// This could happen if no index exists yet
|
|
63
|
+
console.log(JSON.stringify({ continue: true }));
|
|
186
64
|
}
|
|
187
|
-
|
|
65
|
+
|
|
66
|
+
process.exit(0);
|
|
67
|
+
|
|
68
|
+
} catch (error) {
|
|
69
|
+
// Any errors, just continue without additional context
|
|
70
|
+
console.log(JSON.stringify({ continue: true }));
|
|
71
|
+
process.exit(0);
|
|
72
|
+
}
|
|
188
73
|
}
|
|
189
74
|
|
|
190
75
|
sessionStartHook();
|
package/package.json
CHANGED
package/src/claude-mem.js
CHANGED
|
@@ -6,6 +6,7 @@ import path, { join } from 'path';
|
|
|
6
6
|
import os, { homedir } from 'os';
|
|
7
7
|
import { fileURLToPath } from 'url';
|
|
8
8
|
import { createAnalysisPrompt, DEBUG_MESSAGES } from '../dist/constants.js';
|
|
9
|
+
import { log } from '../dist/utils/logger.js';
|
|
9
10
|
|
|
10
11
|
const DEBUG_MODE = true;
|
|
11
12
|
const CLAUDE_MEM_HOME = path.join(homedir(), '.claude-mem');
|
|
@@ -438,9 +439,10 @@ class TranscriptCompressor {
|
|
|
438
439
|
|
|
439
440
|
return archivePath;
|
|
440
441
|
} catch (error) {
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
442
|
+
log.error('COMPRESSION FAILED', error, {
|
|
443
|
+
errorMessage: error.message,
|
|
444
|
+
stack: error.stack
|
|
445
|
+
});
|
|
444
446
|
throw error;
|
|
445
447
|
}
|
|
446
448
|
}
|
|
@@ -823,9 +825,7 @@ async function main() {
|
|
|
823
825
|
const args = process.argv.slice(2);
|
|
824
826
|
|
|
825
827
|
if (args.length === 0) {
|
|
826
|
-
|
|
827
|
-
'Usage: node src/claude-mem.js <transcript-path> [session-id]'
|
|
828
|
-
);
|
|
828
|
+
log.error('Usage: node src/claude-mem.js <transcript-path> [session-id]');
|
|
829
829
|
closeDebugLog();
|
|
830
830
|
process.exit(1);
|
|
831
831
|
}
|
|
@@ -847,7 +847,7 @@ async function main() {
|
|
|
847
847
|
debugLog(`❌ Compression failed: ${error.message}`);
|
|
848
848
|
debugLog(`💥 Stack trace: ${error.stack}`);
|
|
849
849
|
closeDebugLog();
|
|
850
|
-
|
|
850
|
+
log.error('Compression failed', error);
|
|
851
851
|
process.exit(1);
|
|
852
852
|
}
|
|
853
853
|
}
|