diff --git a/.gitignore b/.gitignore index 90004bca..9d966c34 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ node_modules/ .env.local *.tmp *.temp +.claude/ \ No newline at end of file diff --git a/README.md b/README.md index 1b2c658e..2e57c3a0 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/BUILD.md b/docs/BUILD.md index 2169eae2..875db179 100644 --- a/docs/BUILD.md +++ b/docs/BUILD.md @@ -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/` diff --git a/hook-templates/post-tool-use.js b/hook-templates/post-tool-use.js deleted file mode 100755 index 20910878..00000000 --- a/hook-templates/post-tool-use.js +++ /dev/null @@ -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); -}); \ No newline at end of file diff --git a/hook-templates/session-start.js b/hook-templates/session-start.js deleted file mode 100755 index 39cf1742..00000000 --- a/hook-templates/session-start.js +++ /dev/null @@ -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); - } -}); \ No newline at end of file diff --git a/hook-templates/shared/config-loader.js b/hook-templates/shared/config-loader.js deleted file mode 100644 index 099746d2..00000000 --- a/hook-templates/shared/config-loader.js +++ /dev/null @@ -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'; -} \ No newline at end of file diff --git a/hook-templates/shared/hook-helpers.js b/hook-templates/shared/hook-helpers.js deleted file mode 100644 index 6dfd905c..00000000 --- a/hook-templates/shared/hook-helpers.js +++ /dev/null @@ -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); -} diff --git a/hook-templates/shared/hook-prompt-renderer.js b/hook-templates/shared/hook-prompt-renderer.js deleted file mode 100644 index fdf8ef7b..00000000 --- a/hook-templates/shared/hook-prompt-renderer.js +++ /dev/null @@ -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 -}; diff --git a/hook-templates/shared/hook-prompts.config.js b/hook-templates/shared/hook-prompts.config.js deleted file mode 100644 index 6d97cf1c..00000000 --- a/hook-templates/shared/hook-prompts.config.js +++ /dev/null @@ -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 -}; diff --git a/hook-templates/shared/path-resolver.js b/hook-templates/shared/path-resolver.js deleted file mode 100644 index 81bdf033..00000000 --- a/hook-templates/shared/path-resolver.js +++ /dev/null @@ -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} 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() - }; -} \ No newline at end of file diff --git a/hook-templates/stop.js b/hook-templates/stop.js deleted file mode 100755 index 24cb94eb..00000000 --- a/hook-templates/stop.js +++ /dev/null @@ -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); -}); diff --git a/hook-templates/user-prompt-submit.js b/hook-templates/user-prompt-submit.js deleted file mode 100755 index 87bf00e2..00000000 --- a/hook-templates/user-prompt-submit.js +++ /dev/null @@ -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); -}); \ No newline at end of file diff --git a/package.json b/package.json index ce0acd7a..335fc632 100644 --- a/package.json +++ b/package.json @@ -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" ] } diff --git a/tests/database-schema.test.ts b/tests/database-schema.test.ts index 6612bdd1..78c34139 100644 --- a/tests/database-schema.test.ts +++ b/tests/database-schema.test.ts @@ -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'; diff --git a/tests/hooks-database-integration.test.ts b/tests/hooks-database-integration.test.ts index de16de7b..ba567ec7 100644 --- a/tests/hooks-database-integration.test.ts +++ b/tests/hooks-database-integration.test.ts @@ -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'; diff --git a/tests/sdk-prompts-parser.test.ts b/tests/sdk-prompts-parser.test.ts index 5272d20d..ef1c69c0 100644 --- a/tests/sdk-prompts-parser.test.ts +++ b/tests/sdk-prompts-parser.test.ts @@ -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'; diff --git a/tests/session-lifecycle.test.ts b/tests/session-lifecycle.test.ts index 6260eb6c..a85d25d4 100644 --- a/tests/session-lifecycle.test.ts +++ b/tests/session-lifecycle.test.ts @@ -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';