refactor: remove deprecated hook templates and related utilities

- Deleted hook-prompt-renderer.js and hook-prompts.config.js as they are no longer needed.
- Removed path-resolver.js, stop.js, and user-prompt-submit.js for streamlining the codebase.
- Updated package.json scripts to point to new build and publish scripts.
- Adjusted test imports to reflect new directory structure after removing unnecessary files.
This commit is contained in:
Alex Newman
2025-10-15 20:24:08 -04:00
parent 01b477da26
commit edeed2ee2c
17 changed files with 56 additions and 1857 deletions
+1
View File
@@ -5,3 +5,4 @@ node_modules/
.env.local
*.tmp
*.temp
.claude/
+18 -14
View File
@@ -353,22 +353,24 @@ claude-mem changelog --historical 5
### Memory System
**Rolling Memory** - Real-time conversation turn capture via hooks with immediate ChromaDB storage
**Hook Layer** - Real-time event capture via Claude Code streaming hooks with sub-50ms overhead
**TranscriptCompressor** - Intelligent chunking and compression of large conversations
**SDK Worker** - Agent SDK subprocess for async observation processing and session summarization
**Observation Queue** - SQLite-based queue for buffering tool observations between hooks and SDK
**Session Storage** - SQLite tables for tracking SDK sessions, observations, and summaries
**MCP Server** - 15+ ChromaDB tools for memory operations and semantic search
**SQLite Backend** - Session tracking, metadata management, and diagnostics storage
### Hook Integration
Hooks communicate via JSON stdin/stdout and run with minimal overhead:
1. **user-prompt-submit** - Stores user prompt immediately in ChromaDB
2. **post-tool-use** - Spawns Agent SDK subprocess for async compression
3. **stop-streaming** - Generates session overview, deletes SDK transcript
4. **session-start** - Loads project-specific context invisibly
1. **new** (user-prompt-submit) - Creates SDK session and initializes Agent SDK worker for async processing
2. **save** (post-tool-use) - Queues tool observations for async SDK processing
3. **summary** (stop-streaming) - Triggers finalization, generates session overview, and cleanup
4. **context** (session-start) - Loads project-specific session summaries automatically
### Project Structure
@@ -376,14 +378,16 @@ Hooks communicate via JSON stdin/stdout and run with minimal overhead:
src/
├── bin/ # CLI entry point
├── commands/ # Command implementations
├── core/ # Core compression logic
├── services/ # SQLite, ChromaDB, path discovery
├── shared/ # Configuration and utilities
── mcp-server.ts # MCP server implementation
├── hooks/ # Hook implementations (new, save, summary, context)
├── sdk/ # Agent SDK worker, prompts, and parser
├── services/ # SQLite database and path discovery
── shared/ # Configuration, paths, settings, and types
└── utils/ # Platform utilities
hook-templates/ # Hook source files
dist/ # Minified production bundle
test/ # Unit and integration tests
tests/ # Database, SDK, and integration tests
docs/ # Documentation (BUILD.md, CHANGELOG.md)
scripts/ # Build and publish automation
```
## :wrench: Configuration
+19 -7
View File
@@ -14,7 +14,7 @@ Build the project to create a bundled, minified executable:
```bash
npm run build
# or
node build.js
node scripts/build.js
```
This will:
@@ -38,7 +38,7 @@ To publish a new version to npm:
```bash
npm run publish:npm
# or
node publish.js
node scripts/publish.js
```
The publish script will:
@@ -99,12 +99,24 @@ claude-mem/
├── src/ # TypeScript source
│ ├── bin/cli.ts # CLI entry point
│ ├── commands/ # Command implementations
│ ├── services/ # Core services
── shared/ # Shared utilities
│ ├── hooks/ # Hook implementations
── sdk/ # Agent SDK worker
│ ├── services/ # SQLite and path services
│ ├── shared/ # Configuration and types
│ └── utils/ # Platform utilities
├── dist/ # Build output
│ └── claude-mem.min.js # Bundled executable
├── build.js # Build script
├── publish.js # Publish script
├── tests/ # Test files
│ ├── database-schema.test.ts
│ ├── sdk-prompts-parser.test.ts
│ ├── hooks-database-integration.test.ts
│ └── session-lifecycle.test.ts
├── docs/ # Documentation
│ ├── BUILD.md # This file
│ └── CHANGELOG.md # Release notes
├── scripts/ # Build automation
│ ├── build.js # Build script
│ └── publish.js # Publish script
└── package.json # Package configuration
```
@@ -113,4 +125,4 @@ claude-mem/
- The build process embeds the version from `package.json` at build time
- `prepublishOnly` script ensures build runs before npm publish
- Dependencies are bundled except for external packages
- The published package includes: `dist/`, `hook-templates/`, `commands/`, `src/`
- The published package includes: `dist/`, `hook-templates/`, `commands/`, `src/`, `docs/`
-222
View File
@@ -1,222 +0,0 @@
#!/usr/bin/env bun
/**
* Post Tool Use Hook - Streaming SDK Version
*
* Feeds tool responses to the streaming SDK session for real-time processing.
* SDK decides what to store and calls bash commands directly.
*/
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
import { query } from '@anthropic-ai/claude-agent-sdk';
import { renderToolMessage, HOOK_CONFIG } from './shared/hook-prompt-renderer.js';
import { getProjectName } from './shared/path-resolver.js';
import { initializeDatabase, getActiveStreamingSessionsForProject, acquireSessionLock, releaseSessionLock, cleanupStaleLocks } from './shared/hook-helpers.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const HOOKS_LOG = path.join(process.env.HOME || '', '.claude-mem', 'logs', 'hooks.log');
function debugLog(message, data = {}) {
if (process.env.CLAUDE_MEM_DEBUG === 'true') {
const timestamp = new Date().toISOString();
const logLine = `[${timestamp}] HOOK DEBUG: ${message} ${JSON.stringify(data)}\n`;
try {
fs.appendFileSync(HOOKS_LOG, logLine);
process.stderr.write(logLine);
} catch (error) {
// Silent fail on log errors
}
}
}
// Removed: buildStreamingToolMessage function
// Now using centralized config from hook-prompt-renderer.js
// =============================================================================
// MAIN
// =============================================================================
// =============================================================================
// GRACEFUL SHUTDOWN HANDLERS
// =============================================================================
let db;
let lockAcquired = false;
let sdkSessionId = null;
function cleanup() {
if (lockAcquired && sdkSessionId && db) {
try {
releaseSessionLock(db, sdkSessionId);
debugLog('PostToolUse: Released session lock on shutdown', { sdkSessionId });
} catch (err) {
// Silent fail on cleanup
}
}
if (db) {
try {
db.close();
} catch (err) {
// Silent fail on cleanup
}
}
}
process.on('SIGTERM', () => {
debugLog('PostToolUse: Received SIGTERM, cleaning up');
cleanup();
process.exit(0);
});
process.on('SIGINT', () => {
debugLog('PostToolUse: Received SIGINT, cleaning up');
cleanup();
process.exit(0);
});
let input = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => { input += chunk; });
process.stdin.on('end', async () => {
let payload;
try {
payload = input ? JSON.parse(input) : {};
} catch (error) {
debugLog('PostToolUse: JSON parse error', { error: error.message });
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
process.exit(0);
}
const { tool_name, tool_response, prompt, cwd, timestamp } = payload;
const project = cwd ? getProjectName(cwd) : 'unknown';
// Return immediately - process async in background (don't block next tool)
console.log(JSON.stringify({ async: true, asyncTimeout: 180000 }));
try {
// Load SDK session info from database
db = initializeDatabase();
// Clean up any stale locks first
cleanupStaleLocks(db);
const sessions = getActiveStreamingSessionsForProject(db, project);
if (!sessions || sessions.length === 0) {
debugLog('PostToolUse: No streaming session found', { project });
db.close();
process.exit(0);
}
const sessionData = sessions[0];
sdkSessionId = sessionData.sdk_session_id;
// Validate SDK session ID exists
if (!sdkSessionId) {
debugLog('PostToolUse: SDK session ID not yet available', { project });
db.close();
process.exit(0);
}
// Try to acquire lock - if another hook has it, skip this tool
lockAcquired = acquireSessionLock(db, sdkSessionId, 'PostToolUse');
if (!lockAcquired) {
debugLog('PostToolUse: Session locked by another hook, skipping', { sdkSessionId });
db.close();
process.exit(0);
}
// Convert tool response to string
const toolResponseStr = typeof tool_response === 'string'
? tool_response
: JSON.stringify(tool_response);
// Build message for SDK using centralized config
const message = renderToolMessage({
toolName: tool_name,
toolResponse: toolResponseStr,
userPrompt: prompt || '',
timestamp: timestamp || new Date().toISOString()
});
// Send to SDK and wait for processing to complete using centralized config
const response = query({
prompt: message,
options: {
model: HOOK_CONFIG.sdk.model,
resume: sdkSessionId,
allowedTools: HOOK_CONFIG.sdk.allowedTools,
maxTokens: HOOK_CONFIG.sdk.maxTokensTool,
cwd // Must match where transcript was created
}
});
// Consume the stream to let SDK fully process
for await (const msg of response) {
debugLog('PostToolUse: SDK message', { type: msg.type, subtype: msg.subtype });
// SDK messages are structured differently than we expected
// - type: 'assistant' contains the assistant's response with content blocks
// - Content blocks can be text or tool_use
// - type: 'user' contains tool results
// - type: 'result' is the final summary
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text') {
debugLog('PostToolUse: SDK text', { text: block.text?.slice(0, 200) });
} else if (block.type === 'tool_use') {
debugLog('PostToolUse: SDK tool_use', {
tool: block.name,
input: JSON.stringify(block.input).slice(0, 200)
});
}
}
} else if (msg.type === 'user' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'tool_result') {
debugLog('PostToolUse: SDK tool_result', {
tool_use_id: block.tool_use_id,
content: typeof block.content === 'string' ? block.content.slice(0, 300) : JSON.stringify(block.content).slice(0, 300)
});
}
}
} else if (msg.type === 'result') {
debugLog('PostToolUse: SDK result', {
subtype: msg.subtype,
is_error: msg.is_error
});
}
}
debugLog('PostToolUse: SDK finished processing', { tool_name, sdkSessionId });
} catch (error) {
debugLog('PostToolUse: Error sending to SDK', { error: error.message, stack: error.stack });
} finally {
// Always release lock and close database
if (lockAcquired && sdkSessionId && db) {
try {
releaseSessionLock(db, sdkSessionId);
debugLog('PostToolUse: Released session lock', { sdkSessionId });
} catch (err) {
debugLog('PostToolUse: Error releasing lock', { error: err.message });
}
}
if (db) {
try {
db.close();
} catch (err) {
debugLog('PostToolUse: Error closing database', { error: err.message });
}
}
}
// Exit cleanly after async processing completes
process.exit(0);
});
-55
View File
@@ -1,55 +0,0 @@
#!/usr/bin/env bun
/**
* Session Start Hook (SDK Version)
*
* Calls the CLI to load relevant context from ChromaDB at session start.
*/
import { createHookResponse, debugLog } from './shared/hook-helpers.js';
// Read stdin
let input = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => {
input += chunk;
});
process.stdin.on('end', async () => {
const payload = input ? JSON.parse(input) : {};
debugLog('SessionStart hook invoked (SDK version)', { cwd: payload.cwd });
const { cwd, source } = payload;
// Run on startup or /clear
if (source !== 'startup' && source !== 'clear') {
const response = createHookResponse('SessionStart', true);
console.log(JSON.stringify(response));
process.exit(0);
}
try {
// Call the CLI to load context
const { executeCliCommand } = await import('./shared/hook-helpers.js');
const result = await executeCliCommand('claude-mem', ['load-context', '--format', 'session-start']);
if (result.success && result.stdout) {
// Per Claude Code docs: for SessionStart, stdout with exit code 0 is added to context
// Use plain stdout instead of JSON to ensure it appears in Claude's context
console.log(result.stdout);
process.exit(0);
} else {
// Return without context - use JSON with suppressOutput to avoid empty context
const response = createHookResponse('SessionStart', true);
console.log(JSON.stringify(response));
process.exit(0);
}
} catch (error) {
// Continue without context on error
const response = createHookResponse('SessionStart', true);
console.log(JSON.stringify(response));
process.exit(0);
}
});
-26
View File
@@ -1,26 +0,0 @@
#!/usr/bin/env node
/**
* Shared configuration loader utility for Claude Memory hooks
* Loads CLI command name from config.json with proper fallback handling
*/
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { readFileSync, existsSync } from 'fs';
/**
* Loads the CLI command name from the hooks config.json file
* @returns {string} The CLI command name (defaults to 'claude-mem')
*/
export function loadCliCommand() {
const __dirname = dirname(fileURLToPath(import.meta.url));
const configPath = join(__dirname, '..', 'config.json');
if (existsSync(configPath)) {
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
return config.cliCommand || 'claude-mem';
}
return 'claude-mem';
}
-503
View File
@@ -1,503 +0,0 @@
#!/usr/bin/env bun
/**
* Hook Helper Functions
*
* This module provides JavaScript wrappers around the TypeScript PromptOrchestrator
* and HookTemplates system, making them accessible to the JavaScript hook scripts.
*/
import { spawn } from 'child_process';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { Database } from 'bun:sqlite';
import os from 'os';
import fs from 'fs';
const __dirname = dirname(fileURLToPath(import.meta.url));
/**
* Creates a standardized hook response using the HookTemplates system
* @param {string} hookType - Type of hook ('PreCompact' or 'SessionStart')
* @param {boolean} success - Whether the operation was successful
* @param {Object} options - Additional options
* @returns {Object} Formatted hook response
*/
export function createHookResponse(hookType, success, options = {}) {
if (hookType === 'PreCompact') {
if (success) {
return {
continue: true,
suppressOutput: true
};
} else {
return {
continue: false,
stopReason: options.reason || 'Pre-compact operation failed',
suppressOutput: true
};
}
}
if (hookType === 'SessionStart') {
if (success && options.context) {
return {
hookSpecificOutput: {
hookEventName: 'SessionStart',
additionalContext: options.context
}
};
} else {
return {
continue: true,
suppressOutput: true
};
}
}
if (hookType === 'UserPromptSubmit' || hookType === 'PostToolUse') {
return {
continue: true,
suppressOutput: true
};
}
if (hookType === 'Stop') {
return {
continue: true,
suppressOutput: true
};
}
// Generic response for unknown hook types
return {
continue: success,
suppressOutput: true,
...(options.reason && !success ? { stopReason: options.reason } : {})
};
}
/**
* Formats a session start context message using standardized templates
* @param {Object} contextData - Context information
* @returns {string} Formatted context message
*/
export function formatSessionStartContext(contextData) {
const {
projectName = 'unknown project',
memoryCount = 0,
lastSessionTime,
recentComponents = [],
recentDecisions = []
} = contextData;
const timeInfo = lastSessionTime ? ` (last worked: ${lastSessionTime})` : '';
const contextParts = [];
contextParts.push(`🧠 Loaded ${memoryCount} memories from previous sessions for ${projectName}${timeInfo}`);
if (recentComponents.length > 0) {
contextParts.push(`\n🎯 Recent components: ${recentComponents.slice(0, 3).join(', ')}`);
}
if (recentDecisions.length > 0) {
contextParts.push(`\n🔄 Recent decisions: ${recentDecisions.slice(0, 2).join(', ')}`);
}
if (memoryCount > 0) {
contextParts.push('\n💡 Use search_nodes("keywords") to find related work or open_nodes(["entity_name"]) to load specific components');
}
return contextParts.join('');
}
/**
* Executes a CLI command and returns the result
* @param {string} command - CLI command to execute
* @param {Array} args - Command arguments
* @param {Object} options - Spawn options
* @returns {Promise<{stdout: string, stderr: string, success: boolean}>}
*/
export async function executeCliCommand(command, args = [], options = {}) {
return new Promise((resolve) => {
const { input, ...spawnOptions } = options;
const process = spawn(command, args, {
stdio: ['pipe', 'pipe', 'pipe'],
...spawnOptions
});
let stdout = '';
let stderr = '';
if (process.stdout) {
process.stdout.on('data', (data) => {
stdout += data.toString();
});
}
if (process.stderr) {
process.stderr.on('data', (data) => {
stderr += data.toString();
});
}
if (input && process.stdin) {
process.stdin.write(input);
process.stdin.end();
} else if (process.stdin) {
process.stdin.end();
}
process.on('close', (code) => {
resolve({
stdout: stdout.trim(),
stderr: stderr.trim(),
success: code === 0
});
});
process.on('error', (error) => {
resolve({
stdout: '',
stderr: error.message,
success: false
});
});
});
}
/**
* Parses context data from CLI output
* @param {string} output - Raw CLI output
* @returns {Object} Parsed context data
*/
export function parseContextData(output) {
if (!output || !output.trim()) {
return {
memoryCount: 0,
recentComponents: [],
recentDecisions: []
};
}
// Try to parse as JSON first (if CLI outputs structured data)
try {
const parsed = JSON.parse(output);
return {
memoryCount: parsed.memoryCount || 0,
recentComponents: parsed.recentComponents || [],
recentDecisions: parsed.recentDecisions || [],
lastSessionTime: parsed.lastSessionTime
};
} catch (e) {
// If not JSON, treat as plain text context
const lines = output.split('\n').filter(line => line.trim());
return {
memoryCount: lines.length,
recentComponents: [],
recentDecisions: [],
rawContext: output
};
}
}
/**
* Validates hook payload structure
* @param {Object} payload - Hook payload to validate
* @param {string} expectedHookType - Expected hook event name
* @returns {{valid: boolean, error?: string}} Validation result
*/
export function validateHookPayload(payload, expectedHookType) {
if (!payload || typeof payload !== 'object') {
return { valid: false, error: 'Payload must be a valid object' };
}
if (!payload.session_id || typeof payload.session_id !== 'string') {
return { valid: false, error: 'Missing or invalid session_id' };
}
if (!payload.transcript_path || typeof payload.transcript_path !== 'string') {
return { valid: false, error: 'Missing or invalid transcript_path' };
}
if (expectedHookType && payload.hook_event_name !== expectedHookType) {
return { valid: false, error: `Expected hook_event_name to be ${expectedHookType}` };
}
return { valid: true };
}
/**
* Logs debug information if debug mode is enabled
* @param {string} message - Debug message
* @param {Object} data - Additional data to log
*/
export function debugLog(message, data = {}) {
if (process.env.DEBUG === 'true' || process.env.CLAUDE_MEM_DEBUG === 'true') {
const timestamp = new Date().toISOString();
console.error(`[${timestamp}] HOOK DEBUG: ${message}`, data);
}
}
// =============================================================================
// DATABASE HELPERS (inline SQL to avoid 'claude-mem' import issues)
// =============================================================================
/**
* Get the claude-mem data directory path
*/
function getDataDirectory() {
return join(os.homedir(), '.claude-mem');
}
/**
* Get or create the database connection
*/
function getDatabase() {
const dataDir = getDataDirectory();
const dbPath = join(dataDir, 'claude-mem.db');
// Ensure directory exists
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
const db = new Database(dbPath);
// Apply optimized SQLite settings
db.pragma('journal_mode = WAL');
db.pragma('synchronous = NORMAL');
db.pragma('foreign_keys = ON');
db.pragma('temp_store = memory');
return db;
}
/**
* Ensure the streaming_sessions table exists
*/
function ensureStreamingSessionsTable(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS streaming_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
claude_session_id TEXT UNIQUE NOT NULL,
sdk_session_id TEXT,
project TEXT NOT NULL,
title TEXT,
subtitle TEXT,
user_prompt TEXT,
started_at TEXT NOT NULL,
started_at_epoch INTEGER NOT NULL,
updated_at TEXT,
updated_at_epoch INTEGER,
completed_at TEXT,
completed_at_epoch INTEGER,
status TEXT NOT NULL CHECK(status IN ('active', 'completed', 'failed'))
)
`);
// Create indices if they don't exist
db.exec(`
CREATE INDEX IF NOT EXISTS idx_streaming_sessions_claude_id
ON streaming_sessions(claude_session_id)
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_streaming_sessions_sdk_id
ON streaming_sessions(sdk_session_id)
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_streaming_sessions_project_status
ON streaming_sessions(project, status)
`);
}
/**
* Create a new streaming session record
*/
export function createStreamingSession(db, { claude_session_id, project, user_prompt, started_at }) {
ensureStreamingSessionsTable(db);
const timestamp = started_at || new Date().toISOString();
const epoch = new Date(timestamp).getTime();
const stmt = db.query(`
INSERT INTO streaming_sessions (
claude_session_id, project, user_prompt, started_at, started_at_epoch, status
) VALUES (?, ?, ?, ?, ?, 'active')
`);
const info = stmt.run(claude_session_id, project, user_prompt || null, timestamp, epoch);
return db.query('SELECT * FROM streaming_sessions WHERE id = ?').get(info.lastInsertRowid);
}
/**
* Update a streaming session by internal ID
*/
export function updateStreamingSession(db, id, updates) {
const timestamp = new Date().toISOString();
const epoch = Date.now();
const parts = [];
const values = [];
if (updates.sdk_session_id !== undefined) {
parts.push('sdk_session_id = ?');
values.push(updates.sdk_session_id);
}
if (updates.title !== undefined) {
parts.push('title = ?');
values.push(updates.title);
}
if (updates.subtitle !== undefined) {
parts.push('subtitle = ?');
values.push(updates.subtitle);
}
if (updates.status !== undefined) {
parts.push('status = ?');
values.push(updates.status);
}
if (updates.completed_at !== undefined) {
const completedTimestamp = typeof updates.completed_at === 'string'
? updates.completed_at
: new Date(updates.completed_at).toISOString();
const completedEpoch = new Date(completedTimestamp).getTime();
parts.push('completed_at = ?', 'completed_at_epoch = ?');
values.push(completedTimestamp, completedEpoch);
}
// Always update the updated_at timestamp
parts.push('updated_at = ?', 'updated_at_epoch = ?');
values.push(timestamp, epoch);
values.push(id);
const stmt = db.query(`
UPDATE streaming_sessions
SET ${parts.join(', ')}
WHERE id = ?
`);
stmt.run(...values);
return db.query('SELECT * FROM streaming_sessions WHERE id = ?').get(id);
}
/**
* Get active streaming sessions for a project
*/
export function getActiveStreamingSessionsForProject(db, project) {
ensureStreamingSessionsTable(db);
const stmt = db.query(`
SELECT * FROM streaming_sessions
WHERE project = ? AND status = 'active'
ORDER BY started_at_epoch DESC
`);
return stmt.all(project);
}
/**
* Mark a session as completed
*/
export function markStreamingSessionCompleted(db, id) {
const timestamp = new Date().toISOString();
const epoch = Date.now();
const stmt = db.query(`
UPDATE streaming_sessions
SET status = ?,
completed_at = ?,
completed_at_epoch = ?,
updated_at = ?,
updated_at_epoch = ?
WHERE id = ?
`);
stmt.run('completed', timestamp, epoch, timestamp, epoch, id);
return db.query('SELECT * FROM streaming_sessions WHERE id = ?').get(id);
}
/**
* Initialize database with migrations and return connection
*/
export function initializeDatabase() {
const db = getDatabase();
ensureStreamingSessionsTable(db);
ensureSessionLocksTable(db);
return db;
}
// =============================================================================
// SESSION LOCKING (prevents concurrent SDK resume)
// =============================================================================
/**
* Ensure the session_locks table exists
*/
function ensureSessionLocksTable(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS session_locks (
sdk_session_id TEXT PRIMARY KEY,
locked_by TEXT NOT NULL,
locked_at TEXT NOT NULL,
locked_at_epoch INTEGER NOT NULL
)
`);
}
/**
* Attempt to acquire a lock on an SDK session
* @returns {boolean} true if lock acquired, false if already locked
*/
export function acquireSessionLock(db, sdkSessionId, lockOwner) {
ensureSessionLocksTable(db);
try {
const timestamp = new Date().toISOString();
const epoch = Date.now();
const stmt = db.query(`
INSERT INTO session_locks (sdk_session_id, locked_by, locked_at, locked_at_epoch)
VALUES (?, ?, ?, ?)
`);
stmt.run(sdkSessionId, lockOwner, timestamp, epoch);
return true;
} catch (error) {
// UNIQUE constraint violation = already locked
return false;
}
}
/**
* Release a lock on an SDK session
*/
export function releaseSessionLock(db, sdkSessionId) {
ensureSessionLocksTable(db);
const stmt = db.query(`
DELETE FROM session_locks
WHERE sdk_session_id = ?
`);
stmt.run(sdkSessionId);
}
/**
* Clean up stale locks (older than 5 minutes)
*/
export function cleanupStaleLocks(db) {
ensureSessionLocksTable(db);
const fiveMinutesAgo = Date.now() - (5 * 60 * 1000);
const stmt = db.query(`
DELETE FROM session_locks
WHERE locked_at_epoch < ?
`);
stmt.run(fiveMinutesAgo);
}
@@ -1,278 +0,0 @@
// src/prompts/hook-prompts.config.ts
var HOOK_CONFIG = {
maxUserPromptLength: 200,
maxToolResponseLength: 20000,
sdk: {
model: "claude-sonnet-4-5",
allowedTools: ["Bash"],
maxTokensSystem: 8192,
maxTokensTool: 8192,
maxTokensEnd: 2048
}
};
var SYSTEM_PROMPT = `You are a semantic memory compressor for claude-mem. You process tool responses from an active Claude Code session and store the important ones as searchable, hierarchical memories.
# SESSION CONTEXT
- Project: {{project}}
- Session: {{sessionId}}
- Date: {{date}}
- User Request: "{{userPrompt}}"
# YOUR JOB
## FIRST: Generate Session Title
IMMEDIATELY generate a title and subtitle for this session based on the user request.
Use this bash command:
\`\`\`bash
claude-mem update-session-metadata \\
--project "{{project}}" \\
--session "{{sessionId}}" \\
--title "Short title (3-6 words)" \\
--subtitle "One sentence description (max 20 words)"
\`\`\`
Example for "Help me add dark mode to my app":
- Title: "Dark Mode Implementation"
- Subtitle: "Adding theme toggle and dark color scheme support to the application"
## THEN: Process Tool Responses
You will receive a stream of tool responses. For each one:
1. ANALYZE: Does this contain information worth remembering?
2. DECIDE: Should I store this or skip it?
3. EXTRACT: What are the key semantic concepts?
4. DECOMPOSE: Break into title + subtitle + atomic facts + narrative
5. STORE: Use bash to save the hierarchical memory
6. TRACK: Keep count of stored memories (001, 002, 003...)
# WHAT TO STORE
Store these:
- File contents with logic, algorithms, or patterns
- Search results revealing project structure
- Build errors or test failures with context
- Code revealing architecture or design decisions
- Git diffs with significant changes
- Command outputs showing system state
Skip these:
- Simple status checks (git status with no changes)
- Trivial edits (one-line config changes)
- Repeated operations
- Binary data or noise
- Anything without semantic value
# HIERARCHICAL MEMORY FORMAT
Each memory has FOUR components:
## 1. TITLE (3-8 words)
A scannable headline that captures the core action or topic.
Examples:
- "SDK Transcript Cleanup Implementation"
- "Hook System Architecture Analysis"
- "ChromaDB Migration Planning"
## 2. SUBTITLE (max 24 words)
A concise, memorable summary that captures the essence of the change.
Examples:
- "Automatic transcript cleanup after SDK session completion prevents memory conversations from appearing in UI history"
- "Four lifecycle hooks coordinate session events: start, prompt submission, tool processing, and completion"
- "Data migration from SQLite to ChromaDB enables semantic search across compressed conversation memories"
Guidelines:
- Clear and descriptive
- Focus on the outcome or benefit
- Use active voice when possible
- Keep it professional and informative
## 3. ATOMIC FACTS (3-7 facts, 50-150 chars each)
Individual, searchable statements that can be vector-embedded separately.
Each fact is ONE specific piece of information.
Examples:
- "stop-streaming.js: Auto-deletes SDK transcripts after completion"
- "Path format: ~/.claude/projects/{sanitized-cwd}/{sessionId}.jsonl"
- "Uses fs.unlink with graceful error handling for missing files"
- "Checks two transcript path formats for backward compatibility"
Guidelines:
- Start with filename or component when relevant
- Be specific: include paths, function names, actual values
- Each fact stands alone (no pronouns like "it" or "this")
- 50-150 characters target
- Focus on searchable technical details
## 4. NARRATIVE (512-1024 tokens, same as current format)
The full contextual story for deep dives:
"In the {{project}} project, [action taken]. [Technical details: files, functions, concepts]. [Why this matters]."
This is the detailed explanation for when someone needs full context.
# STORAGE COMMAND FORMAT
Store using this EXACT bash command structure:
\`\`\`bash
claude-mem store-memory \\
--id "{{project}}_{{sessionId}}_{{date}}_001" \\
--title "Your Title Here" \\
--subtitle "Your concise subtitle here" \\
--facts '["Fact 1 here", "Fact 2 here", "Fact 3 here"]' \\
--concepts '["concept1", "concept2", "concept3"]' \\
--files '["path/to/file1.js", "path/to/file2.ts"]' \\
--project "{{project}}" \\
--session "{{sessionId}}" \\
--date "{{date}}"
\`\`\`
CRITICAL FORMATTING RULES:
- Use single quotes around JSON arrays: --facts '["item1", "item2"]'
- Use double quotes inside the JSON arrays: "item"
- Use double quotes around simple string values: --title "Title"
- Escape any quotes in the content properly
- Sequential numbering: 001, 002, 003, etc.
Concepts: 2-5 broad categories (e.g., "hooks", "storage", "async-processing")
Files: Actual file paths touched (e.g., "hooks/stop-streaming.js")
# EXAMPLE MEMORY
Tool response shows: [Read file hooks/stop-streaming.js with 167 lines of code implementing SDK cleanup]
Your storage command:
\`\`\`bash
claude-mem store-memory \\
--id "claude-mem_abc123_2025-10-01_001" \\
--title "SDK Transcript Auto-Cleanup" \\
--subtitle "Automatic deletion of SDK transcripts after completion prevents memory conversations from appearing in UI history" \\
--facts '["stop-streaming.js: Deletes SDK transcript after overview generation", "Path: ~/.claude/projects/{sanitized-cwd}/{sessionId}.jsonl", "Uses fs.unlink with error handling for missing files", "Prevents memory conversations from polluting Claude Code UI"]' \\
--concepts '["cleanup", "SDK-lifecycle", "UX", "file-management"]' \\
--files '["hooks/stop-streaming.js"]' \\
--project "claude-mem" \\
--session "abc123" \\
--date "2025-10-01"
\`\`\`
# STATE TRACKING
CRITICAL: Keep track of your memory counter across all tool messages.
- Start at 001
- Increment for each stored memory
- Never repeat numbers
- Each session has separate numbering
# SESSION END
At the end (when I send "SESSION ENDING"), generate an overview using:
\`\`\`bash
claude-mem store-overview --project "{{project}}" --session "{{sessionId}}" --content "2-3 sentence overview"
\`\`\`
# IMPORTANT REMINDERS
- You're processing a DIFFERENT Claude Code session (not your own)
- Use Bash tool to call claude-mem commands
- Keep subtitles clear and informative (max 24 words)
- Each fact is ONE specific thing (not multiple ideas)
- Be selective - quality over quantity
- Always increment memory numbers
- Facts should be searchable (specific file names, paths, functions)
Ready for tool responses.`;
var TOOL_MESSAGE = `# Tool Response {{timeFormatted}}
Tool: {{toolName}}
User Context: "{{userPrompt}}"
\`\`\`
{{toolResponse}}
\`\`\`
Analyze and store if meaningful.`;
var END_MESSAGE = `# SESSION ENDING
Review our entire conversation. Generate a concise 2-3 sentence overview of what was accomplished.
Store it using Bash:
\`\`\`bash
claude-mem store-overview --project "{{project}}" --session "{{sessionId}}" --content "YOUR_OVERVIEW_HERE"
\`\`\`
Focus on: what was done, current state, key decisions, outcomes.`;
var PROMPTS = {
system: SYSTEM_PROMPT,
tool: TOOL_MESSAGE,
end: END_MESSAGE
};
// src/prompts/hook-prompt-renderer.ts
function substituteVariables(template, variables) {
let result = template;
for (const [key, value] of Object.entries(variables)) {
const placeholder = `{{${key}}}`;
result = result.split(placeholder).join(value);
}
return result;
}
function truncate(text, maxLength) {
if (text.length <= maxLength)
return text;
return text.slice(0, maxLength) + (text.length > maxLength ? "..." : "");
}
function formatTime(timestamp) {
const timePart = timestamp.split("T")[1];
if (!timePart)
return "";
return timePart.slice(0, 8);
}
function renderSystemPrompt(variables) {
const userPromptTruncated = truncate(variables.userPrompt, HOOK_CONFIG.maxUserPromptLength);
return substituteVariables(PROMPTS.system, {
project: variables.project,
sessionId: variables.sessionId,
date: variables.date,
userPrompt: userPromptTruncated
});
}
function renderToolMessage(variables) {
const userPromptTruncated = truncate(variables.userPrompt, HOOK_CONFIG.maxUserPromptLength);
const toolResponseTruncated = truncate(variables.toolResponse, HOOK_CONFIG.maxToolResponseLength);
const timeFormatted = formatTime(variables.timestamp);
return substituteVariables(PROMPTS.tool, {
toolName: variables.toolName,
toolResponse: toolResponseTruncated,
userPrompt: userPromptTruncated,
timestamp: variables.timestamp,
timeFormatted
});
}
function renderEndMessage(variables) {
return substituteVariables(PROMPTS.end, {
project: variables.project,
sessionId: variables.sessionId
});
}
function renderPrompt(type, variables) {
switch (type) {
case "system":
return renderSystemPrompt(variables);
case "tool":
return renderToolMessage(variables);
case "end":
return renderEndMessage(variables);
default:
throw new Error(`Unknown prompt type: ${type}`);
}
}
export {
renderToolMessage,
renderSystemPrompt,
renderPrompt,
renderEndMessage,
PROMPTS,
HOOK_CONFIG
};
@@ -1,217 +0,0 @@
// src/prompts/hook-prompts.config.ts
var HOOK_CONFIG = {
maxUserPromptLength: 200,
maxToolResponseLength: 20000,
sdk: {
model: "claude-sonnet-4-5",
allowedTools: ["Bash"],
maxTokensSystem: 8192,
maxTokensTool: 8192,
maxTokensEnd: 2048
}
};
var SYSTEM_PROMPT = `You are a semantic memory compressor for claude-mem. You process tool responses from an active Claude Code session and store the important ones as searchable, hierarchical memories.
# SESSION CONTEXT
- Project: {{project}}
- Session: {{sessionId}}
- Date: {{date}}
- User Request: "{{userPrompt}}"
# YOUR JOB
## FIRST: Generate Session Title
IMMEDIATELY generate a title and subtitle for this session based on the user request.
Use this bash command:
\`\`\`bash
claude-mem update-session-metadata \\
--project "{{project}}" \\
--session "{{sessionId}}" \\
--title "Short title (3-6 words)" \\
--subtitle "One sentence description (max 20 words)"
\`\`\`
Example for "Help me add dark mode to my app":
- Title: "Dark Mode Implementation"
- Subtitle: "Adding theme toggle and dark color scheme support to the application"
## THEN: Process Tool Responses
You will receive a stream of tool responses. For each one:
1. ANALYZE: Does this contain information worth remembering?
2. DECIDE: Should I store this or skip it?
3. EXTRACT: What are the key semantic concepts?
4. DECOMPOSE: Break into title + subtitle + atomic facts + narrative
5. STORE: Use bash to save the hierarchical memory
6. TRACK: Keep count of stored memories (001, 002, 003...)
# WHAT TO STORE
Store these:
- File contents with logic, algorithms, or patterns
- Search results revealing project structure
- Build errors or test failures with context
- Code revealing architecture or design decisions
- Git diffs with significant changes
- Command outputs showing system state
Skip these:
- Simple status checks (git status with no changes)
- Trivial edits (one-line config changes)
- Repeated operations
- Binary data or noise
- Anything without semantic value
# HIERARCHICAL MEMORY FORMAT
Each memory has FOUR components:
## 1. TITLE (3-8 words)
A scannable headline that captures the core action or topic.
Examples:
- "SDK Transcript Cleanup Implementation"
- "Hook System Architecture Analysis"
- "ChromaDB Migration Planning"
## 2. SUBTITLE (max 24 words)
A concise, memorable summary that captures the essence of the change.
Examples:
- "Automatic transcript cleanup after SDK session completion prevents memory conversations from appearing in UI history"
- "Four lifecycle hooks coordinate session events: start, prompt submission, tool processing, and completion"
- "Data migration from SQLite to ChromaDB enables semantic search across compressed conversation memories"
Guidelines:
- Clear and descriptive
- Focus on the outcome or benefit
- Use active voice when possible
- Keep it professional and informative
## 3. ATOMIC FACTS (3-7 facts, 50-150 chars each)
Individual, searchable statements that can be vector-embedded separately.
Each fact is ONE specific piece of information.
Examples:
- "stop-streaming.js: Auto-deletes SDK transcripts after completion"
- "Path format: ~/.claude/projects/{sanitized-cwd}/{sessionId}.jsonl"
- "Uses fs.unlink with graceful error handling for missing files"
- "Checks two transcript path formats for backward compatibility"
Guidelines:
- Start with filename or component when relevant
- Be specific: include paths, function names, actual values
- Each fact stands alone (no pronouns like "it" or "this")
- 50-150 characters target
- Focus on searchable technical details
## 4. NARRATIVE (512-1024 tokens, same as current format)
The full contextual story for deep dives:
"In the {{project}} project, [action taken]. [Technical details: files, functions, concepts]. [Why this matters]."
This is the detailed explanation for when someone needs full context.
# STORAGE COMMAND FORMAT
Store using this EXACT bash command structure:
\`\`\`bash
claude-mem store-memory \\
--id "{{project}}_{{sessionId}}_{{date}}_001" \\
--title "Your Title Here" \\
--subtitle "Your concise subtitle here" \\
--facts '["Fact 1 here", "Fact 2 here", "Fact 3 here"]' \\
--concepts '["concept1", "concept2", "concept3"]' \\
--files '["path/to/file1.js", "path/to/file2.ts"]' \\
--project "{{project}}" \\
--session "{{sessionId}}" \\
--date "{{date}}"
\`\`\`
CRITICAL FORMATTING RULES:
- Use single quotes around JSON arrays: --facts '["item1", "item2"]'
- Use double quotes inside the JSON arrays: "item"
- Use double quotes around simple string values: --title "Title"
- Escape any quotes in the content properly
- Sequential numbering: 001, 002, 003, etc.
Concepts: 2-5 broad categories (e.g., "hooks", "storage", "async-processing")
Files: Actual file paths touched (e.g., "hooks/stop-streaming.js")
# EXAMPLE MEMORY
Tool response shows: [Read file hooks/stop-streaming.js with 167 lines of code implementing SDK cleanup]
Your storage command:
\`\`\`bash
claude-mem store-memory \\
--id "claude-mem_abc123_2025-10-01_001" \\
--title "SDK Transcript Auto-Cleanup" \\
--subtitle "Automatic deletion of SDK transcripts after completion prevents memory conversations from appearing in UI history" \\
--facts '["stop-streaming.js: Deletes SDK transcript after overview generation", "Path: ~/.claude/projects/{sanitized-cwd}/{sessionId}.jsonl", "Uses fs.unlink with error handling for missing files", "Prevents memory conversations from polluting Claude Code UI"]' \\
--concepts '["cleanup", "SDK-lifecycle", "UX", "file-management"]' \\
--files '["hooks/stop-streaming.js"]' \\
--project "claude-mem" \\
--session "abc123" \\
--date "2025-10-01"
\`\`\`
# STATE TRACKING
CRITICAL: Keep track of your memory counter across all tool messages.
- Start at 001
- Increment for each stored memory
- Never repeat numbers
- Each session has separate numbering
# SESSION END
At the end (when I send "SESSION ENDING"), generate an overview using:
\`\`\`bash
claude-mem store-overview --project "{{project}}" --session "{{sessionId}}" --content "2-3 sentence overview"
\`\`\`
# IMPORTANT REMINDERS
- You're processing a DIFFERENT Claude Code session (not your own)
- Use Bash tool to call claude-mem commands
- Keep subtitles clear and informative (max 24 words)
- Each fact is ONE specific thing (not multiple ideas)
- Be selective - quality over quantity
- Always increment memory numbers
- Facts should be searchable (specific file names, paths, functions)
Ready for tool responses.`;
var TOOL_MESSAGE = `# Tool Response {{timeFormatted}}
Tool: {{toolName}}
User Context: "{{userPrompt}}"
\`\`\`
{{toolResponse}}
\`\`\`
Analyze and store if meaningful.`;
var END_MESSAGE = `# SESSION ENDING
Review our entire conversation. Generate a concise 2-3 sentence overview of what was accomplished.
Store it using Bash:
\`\`\`bash
claude-mem store-overview --project "{{project}}" --session "{{sessionId}}" --content "YOUR_OVERVIEW_HERE"
\`\`\`
Focus on: what was done, current state, key decisions, outcomes.`;
var PROMPTS = {
system: SYSTEM_PROMPT,
tool: TOOL_MESSAGE,
end: END_MESSAGE
};
export {
TOOL_MESSAGE,
SYSTEM_PROMPT,
PROMPTS,
HOOK_CONFIG,
END_MESSAGE
};
-108
View File
@@ -1,108 +0,0 @@
#!/usr/bin/env node
/**
* Path resolver utility for Claude Memory hooks
* Provides proper path handling using environment variables
*/
import { join, basename } from 'path';
import { homedir } from 'os';
/**
* Gets the base data directory for claude-mem
* @returns {string} Data directory path
*/
export function getDataDir() {
return process.env.CLAUDE_MEM_DATA_DIR || join(homedir(), '.claude-mem');
}
/**
* Gets the settings file path
* @returns {string} Settings file path
*/
export function getSettingsPath() {
return join(getDataDir(), 'settings.json');
}
/**
* Gets the archives directory path
* @returns {string} Archives directory path
*/
export function getArchivesDir() {
return process.env.CLAUDE_MEM_ARCHIVES_DIR || join(getDataDir(), 'archives');
}
/**
* Gets the logs directory path
* @returns {string} Logs directory path
*/
export function getLogsDir() {
return process.env.CLAUDE_MEM_LOGS_DIR || join(getDataDir(), 'logs');
}
/**
* Gets the compact flag file path
* @returns {string} Compact flag file path
*/
export function getCompactFlagPath() {
return join(getDataDir(), '.compact-running');
}
/**
* Gets the claude-mem package root directory
* @returns {Promise<string>} Package root path
*/
export async function getPackageRoot() {
// Method 1: Check if we're running from development
const devPath = join(homedir(), 'Scripts', 'claude-mem-source');
const { existsSync } = await import('fs');
if (existsSync(join(devPath, 'package.json'))) {
return devPath;
}
// Method 2: Follow the binary symlink
try {
const { execSync } = await import('child_process');
const { realpathSync } = await import('fs');
const binPath = execSync('which claude-mem', { encoding: 'utf8' }).trim();
const realBinPath = realpathSync(binPath);
// Binary is typically at package_root/dist/claude-mem.min.js
return join(realBinPath, '../..');
} catch {}
throw new Error('Cannot locate claude-mem package root');
}
/**
* Gets the project root directory
* Uses CLAUDE_PROJECT_DIR environment variable if available, otherwise falls back to cwd
* @returns {string} Project root path
*/
export function getProjectRoot() {
return process.env.CLAUDE_PROJECT_DIR || process.cwd();
}
/**
* Derives project name from CLAUDE_PROJECT_DIR or current working directory
* Priority: CLAUDE_PROJECT_DIR > cwd parameter > process.cwd()
* @param {string} [cwd] - Optional current working directory from hook payload
* @returns {string} Project name (basename of project directory)
*/
export function getProjectName(cwd) {
const projectRoot = process.env.CLAUDE_PROJECT_DIR || cwd || process.cwd();
return basename(projectRoot);
}
/**
* Gets all common paths used by hooks
* @returns {Object} Object containing all common paths
*/
export function getPaths() {
return {
dataDir: getDataDir(),
settingsPath: getSettingsPath(),
archivesDir: getArchivesDir(),
logsDir: getLogsDir(),
compactFlagPath: getCompactFlagPath()
};
}
-218
View File
@@ -1,218 +0,0 @@
#!/usr/bin/env bun
/**
* Stop Hook - Simple Orchestrator
*
* Signals session end to SDK, which generates and stores the overview via CLI.
* Cleans up SDK transcript from UI.
*/
import path from 'path';
import fs from 'fs';
import { query } from '@anthropic-ai/claude-agent-sdk';
import { renderEndMessage, HOOK_CONFIG } from './shared/hook-prompt-renderer.js';
import { getProjectName } from './shared/path-resolver.js';
import { initializeDatabase, getActiveStreamingSessionsForProject, markStreamingSessionCompleted, acquireSessionLock, releaseSessionLock, cleanupStaleLocks } from './shared/hook-helpers.js';
const HOOKS_LOG = path.join(process.env.HOME || '', '.claude-mem', 'logs', 'hooks.log');
function debugLog(message, data = {}) {
if (process.env.CLAUDE_MEM_DEBUG === 'true') {
const timestamp = new Date().toISOString();
const logLine = `[${timestamp}] HOOK DEBUG: ${message} ${JSON.stringify(data)}\n`;
try {
fs.appendFileSync(HOOKS_LOG, logLine);
process.stderr.write(logLine);
} catch (error) {
// Silent fail on log errors
}
}
}
// =============================================================================
// GRACEFUL SHUTDOWN HANDLERS
// =============================================================================
let db;
let lockAcquired = false;
let sdkSessionId = null;
let sessionData = null;
function cleanup() {
if (lockAcquired && sdkSessionId && db) {
try {
releaseSessionLock(db, sdkSessionId);
debugLog('Stop: Released session lock on shutdown', { sdkSessionId });
} catch (err) {
// Silent fail on cleanup
}
}
if (db) {
try {
db.close();
} catch (err) {
// Silent fail on cleanup
}
}
}
process.on('SIGTERM', () => {
debugLog('Stop: Received SIGTERM, cleaning up');
cleanup();
process.exit(0);
});
process.on('SIGINT', () => {
debugLog('Stop: Received SIGINT, cleaning up');
cleanup();
process.exit(0);
});
// =============================================================================
// MAIN
// =============================================================================
let input = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => { input += chunk; });
process.stdin.on('end', async () => {
let payload;
try {
payload = input ? JSON.parse(input) : {};
} catch (error) {
debugLog('Stop: JSON parse error', { error: error.message });
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
process.exit(0);
}
const { cwd } = payload;
const project = cwd ? getProjectName(cwd) : 'unknown';
// Return immediately with async mode
console.log(JSON.stringify({ async: true, asyncTimeout: 180000 }));
try {
// Clear activity flag FIRST - even if hook fails, UI should update
const activityFlagPath = path.join(process.env.HOME || '', '.claude-mem', 'activity.flag');
try {
fs.writeFileSync(activityFlagPath, JSON.stringify({ active: false, timestamp: Date.now() }));
} catch (error) {
debugLog('Stop: Error clearing activity flag', { error: error.message });
}
// Load SDK session info from database
db = initializeDatabase();
// Clean up any stale locks first
cleanupStaleLocks(db);
const sessions = getActiveStreamingSessionsForProject(db, project);
if (!sessions || sessions.length === 0) {
debugLog('Stop: No streaming session found', { project });
db.close();
process.exit(0);
}
sessionData = sessions[0];
sdkSessionId = sessionData.sdk_session_id;
const claudeSessionId = sessionData.claude_session_id;
// Validate SDK session ID exists
if (!sdkSessionId) {
debugLog('Stop: SDK session ID not yet available', { project });
db.close();
process.exit(0);
}
// Try to acquire lock - wait up to 10 seconds for PostToolUse to finish
let attempts = 0;
while (attempts < 20) {
lockAcquired = acquireSessionLock(db, sdkSessionId, 'Stop');
if (lockAcquired) break;
debugLog('Stop: Waiting for session lock', { attempt: attempts + 1, sdkSessionId });
await new Promise(resolve => setTimeout(resolve, 500));
attempts++;
}
if (!lockAcquired) {
debugLog('Stop: Could not acquire session lock after 10 seconds', { sdkSessionId });
db.close();
process.exit(1);
}
debugLog('Stop: Ending SDK session', { sdkSessionId, claudeSessionId });
// Build end message - SDK will call `claude-mem store-overview` and `chroma_add_documents`
const message = renderEndMessage({
project,
sessionId: claudeSessionId
});
// Send end message and wait for SDK to complete
const response = query({
prompt: message,
options: {
model: HOOK_CONFIG.sdk.model,
resume: sdkSessionId,
allowedTools: HOOK_CONFIG.sdk.allowedTools,
maxTokens: HOOK_CONFIG.sdk.maxTokensEnd,
cwd // Must match where transcript was created
}
});
// Consume the response stream (wait for SDK to finish storing via CLI)
for await (const msg of response) {
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'tool_use') {
debugLog('Stop: SDK tool call', { tool: block.name });
}
}
}
}
debugLog('Stop: SDK session ended', { sdkSessionId });
// Delete SDK memories transcript from Claude Code UI
const sanitizedCwd = cwd.replace(/\//g, '-');
const projectsDir = path.join(process.env.HOME, '.claude', 'projects', sanitizedCwd);
const memoriesTranscriptPath = path.join(projectsDir, `${sdkSessionId}.jsonl`);
if (fs.existsSync(memoriesTranscriptPath)) {
fs.unlinkSync(memoriesTranscriptPath);
debugLog('Stop: Cleaned up memories transcript', { memoriesTranscriptPath });
}
// Mark session as completed in database
if (sessionData) {
markStreamingSessionCompleted(db, sessionData.id);
debugLog('Stop: Session ended and marked complete', { project, sessionId: sessionData.id });
}
} catch (error) {
debugLog('Stop: Error ending session', { error: error.message, stack: error.stack });
} finally {
// Always release lock and close database
if (lockAcquired && sdkSessionId && db) {
try {
releaseSessionLock(db, sdkSessionId);
debugLog('Stop: Released session lock', { sdkSessionId });
} catch (err) {
debugLog('Stop: Error releasing lock', { error: err.message });
}
}
if (db) {
try {
db.close();
} catch (err) {
debugLog('Stop: Error closing database', { error: err.message });
}
}
}
// Exit cleanly after async processing completes
process.exit(0);
});
-192
View File
@@ -1,192 +0,0 @@
#!/usr/bin/env bun
/**
* User Prompt Submit Hook - Streaming SDK Version
*
* Starts a streaming SDK session that will process tool responses in real-time.
* Saves the SDK session ID for post-tool-use and stop hooks to resume.
*/
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
import { query } from '@anthropic-ai/claude-agent-sdk';
import { renderSystemPrompt, HOOK_CONFIG } from './shared/hook-prompt-renderer.js';
import { getProjectName } from './shared/path-resolver.js';
import { initializeDatabase, createStreamingSession, updateStreamingSession } from './shared/hook-helpers.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const HOOKS_LOG = path.join(process.env.HOME || '', '.claude-mem', 'logs', 'hooks.log');
function debugLog(message, data = {}) {
if (process.env.CLAUDE_MEM_DEBUG === 'true') {
const timestamp = new Date().toISOString();
const logLine = `[${timestamp}] HOOK DEBUG: ${message} ${JSON.stringify(data)}\n`;
try {
fs.appendFileSync(HOOKS_LOG, logLine);
process.stderr.write(logLine);
} catch (error) {
// Silent fail on log errors
}
}
}
// Removed: buildStreamingSystemPrompt function
// Now using centralized config from hook-prompt-renderer.js
// =============================================================================
// GRACEFUL SHUTDOWN HANDLERS
// =============================================================================
let db;
function cleanup() {
if (db) {
try {
db.close();
} catch (err) {
// Silent fail on cleanup
}
}
}
process.on('SIGTERM', () => {
debugLog('UserPromptSubmit: Received SIGTERM, cleaning up');
cleanup();
process.exit(0);
});
process.on('SIGINT', () => {
debugLog('UserPromptSubmit: Received SIGINT, cleaning up');
cleanup();
process.exit(0);
});
// =============================================================================
// MAIN
// =============================================================================
let input = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => { input += chunk; });
process.stdin.on('end', async () => {
let payload;
try {
payload = input ? JSON.parse(input) : {};
} catch (error) {
debugLog('UserPromptSubmit: JSON parse error', { error: error.message });
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
process.exit(0);
}
const { prompt, cwd, session_id, timestamp } = payload;
const project = cwd ? getProjectName(cwd) : 'unknown';
const date = timestamp ? timestamp.split('T')[0] : new Date().toISOString().split('T')[0];
debugLog('UserPromptSubmit: Starting streaming session', { project, session_id });
// Immediately signal activity start for UI indicator
const activityFlagPath = path.join(process.env.HOME || '', '.claude-mem', 'activity.flag');
try {
fs.writeFileSync(activityFlagPath, JSON.stringify({ active: true, project, timestamp: Date.now() }));
} catch (error) {
// Silent fail - non-critical
}
// Generate title and subtitle non-blocking
if (prompt && session_id && project) {
import('child_process').then(({ spawn }) => {
const titleProcess = spawn('claude-mem', [
'generate-title',
'--save',
'--project', project,
'--session', session_id,
prompt
], {
stdio: 'ignore',
detached: true
});
titleProcess.unref();
}).catch(error => {
debugLog('UserPromptSubmit: Error spawning title generator', { error: error.message });
});
}
try {
// Initialize database and create session record FIRST
db = initializeDatabase();
// Create session record immediately - this gives us a tracking ID
const sessionRecord = createStreamingSession(db, {
claude_session_id: session_id,
project,
user_prompt: prompt,
started_at: timestamp
});
debugLog('UserPromptSubmit: Created session record', {
internalId: sessionRecord.id,
claudeSessionId: session_id
});
// Build system prompt using centralized config
const systemPrompt = renderSystemPrompt({
project,
sessionId: session_id,
date,
userPrompt: prompt || ''
});
// Start SDK session using centralized config
const response = query({
prompt: systemPrompt,
options: {
model: HOOK_CONFIG.sdk.model,
allowedTools: HOOK_CONFIG.sdk.allowedTools,
maxTokens: HOOK_CONFIG.sdk.maxTokensSystem,
cwd // SDK will save transcript in this directory
}
});
// Wait for session ID from init message and consume entire stream
let sdkSessionId = null;
for await (const message of response) {
if (message.type === 'system' && message.subtype === 'init') {
sdkSessionId = message.session_id;
debugLog('UserPromptSubmit: Got SDK session ID', { sdkSessionId });
}
// Don't break - consume entire stream so transcript gets written
}
if (sdkSessionId) {
// Update session record with SDK session ID
updateStreamingSession(db, sessionRecord.id, {
sdk_session_id: sdkSessionId
});
debugLog('UserPromptSubmit: SDK session started', {
internalId: sessionRecord.id,
sdkSessionId
});
}
} catch (error) {
debugLog('UserPromptSubmit: Error starting SDK session', { error: error.message, stack: error.stack });
} finally {
// Always close database connection
if (db) {
try {
db.close();
} catch (err) {
debugLog('UserPromptSubmit: Error closing database', { error: err.message });
}
}
}
// Return success to Claude Code
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
process.exit(0);
});
+5 -4
View File
@@ -36,10 +36,11 @@
"claude-mem": "./dist/claude-mem.min.js"
},
"scripts": {
"build": "node build.js",
"publish:npm": "node publish.js",
"build": "node scripts/build.js",
"publish:npm": "node scripts/publish.js",
"dev": "bun run src/bin/cli.ts",
"prepublishOnly": "npm run build"
"prepublishOnly": "npm run build",
"test": "bun test tests/"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.1.0",
@@ -56,8 +57,8 @@
"hook-templates",
"commands",
"src",
"docs",
".mcp.json",
"CHANGELOG.md",
"README_WINDOWS.md"
]
}
+2 -2
View File
@@ -4,8 +4,8 @@
* Tests database schema and hook functions
*/
import { DatabaseManager, migrations } from './src/services/sqlite/index.js';
import { HooksDatabase } from './src/services/sqlite/HooksDatabase.js';
import { DatabaseManager, migrations } from '../src/services/sqlite/index.js';
import { HooksDatabase } from '../src/services/sqlite/HooksDatabase.js';
import path from 'path';
import fs from 'fs';
+3 -3
View File
@@ -8,9 +8,9 @@
*/
import { describe, it, expect, beforeAll, afterAll } from 'bun:test';
import { HooksDatabase } from './src/services/sqlite/HooksDatabase.js';
import { DatabaseManager } from './src/services/sqlite/Database.js';
import { migrations } from './src/services/sqlite/migrations.js';
import { HooksDatabase } from '../src/services/sqlite/HooksDatabase.js';
import { DatabaseManager } from '../src/services/sqlite/Database.js';
import { migrations } from '../src/services/sqlite/migrations.js';
import fs from 'fs';
import path from 'path';
+5 -5
View File
@@ -5,11 +5,11 @@
*/
import { describe, it, expect, beforeAll, afterAll } from 'bun:test';
import { buildInitPrompt, buildObservationPrompt, buildFinalizePrompt } from './src/sdk/prompts.js';
import { parseObservations, parseSummary } from './src/sdk/parser.js';
import { HooksDatabase } from './src/services/sqlite/HooksDatabase.js';
import { DatabaseManager } from './src/services/sqlite/Database.js';
import { migrations } from './src/services/sqlite/migrations.js';
import { buildInitPrompt, buildObservationPrompt, buildFinalizePrompt } from '../src/sdk/prompts.js';
import { parseObservations, parseSummary } from '../src/sdk/parser.js';
import { HooksDatabase } from '../src/services/sqlite/HooksDatabase.js';
import { DatabaseManager } from '../src/services/sqlite/Database.js';
import { migrations } from '../src/services/sqlite/migrations.js';
import fs from 'fs';
import path from 'path';
+3 -3
View File
@@ -8,9 +8,9 @@
*/
import { describe, it, expect, beforeAll, afterAll } from 'bun:test';
import { HooksDatabase } from './src/services/sqlite/HooksDatabase.js';
import { DatabaseManager } from './src/services/sqlite/Database.js';
import { migrations } from './src/services/sqlite/migrations.js';
import { HooksDatabase } from '../src/services/sqlite/HooksDatabase.js';
import { DatabaseManager } from '../src/services/sqlite/Database.js';
import { migrations } from '../src/services/sqlite/migrations.js';
import fs from 'fs';
import path from 'path';