Files
claude-mem/REFACTOR-PLAN.md
T
Alex Newman 7fac3e3bb6 Add comprehensive documentation for Claude Code hooks and streaming input modes
- Introduced a detailed reference for implementing hooks in Claude Code, covering configuration, project-specific scripts, plugin hooks, and various hook events.
- Explained the input modes available in the Claude Agent SDK, emphasizing the benefits of streaming input mode and providing implementation examples for both streaming and single message input.
- Highlighted security considerations and best practices for writing hooks, along with debugging tips and execution details.
2025-10-15 15:51:25 -04:00

33 KiB

Claude-Mem v4.0 Architecture Specification

Vision

A clean, hook-driven memory system where Claude Code hooks call CLI commands directly. All business logic lives in TypeScript, no separate hook files needed.


System Overview

┌─────────────────┐
│  Claude Code    │
│     Hooks       │
└────────┬────────┘
         │ JSON via stdin
         ▼
┌─────────────────┐
│  CLI Commands   │
│  (TypeScript)   │
└────────┬────────┘
         │
         ├──► SQLite Database
         │    • streaming_sessions table
         │    • session_locks table
         │
         └──► Claude SDK
              • Streaming memory agent
              • Real-time processing

Core Principles:

  • CLI commands are the only interface
  • All state stored in SQLite
  • SDK agent processes in-memory, writes to SQLite
  • No command-to-command calls (no CLI calling CLI)
  • No hook files to distribute

Database Schema

Table: streaming_sessions

Tracks all memory sessions with their metadata and final summaries.

CREATE TABLE streaming_sessions (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  claude_session_id TEXT UNIQUE NOT NULL,      -- From Claude Code
  sdk_session_id TEXT,                          -- From SDK init
  project TEXT NOT NULL,                        -- Project name
  title TEXT,                                   -- Generated title
  subtitle TEXT,                                -- Generated subtitle
  user_prompt TEXT,                             -- Initial prompt
  started_at TEXT NOT NULL,                     -- ISO timestamp
  started_at_epoch INTEGER NOT NULL,            -- Unix ms
  updated_at TEXT,                              -- Last update
  updated_at_epoch INTEGER,
  completed_at TEXT,                            -- Session end
  completed_at_epoch INTEGER,
  status TEXT NOT NULL CHECK(status IN ('active', 'completed', 'failed'))
);

CREATE INDEX idx_sessions_claude_id ON streaming_sessions(claude_session_id);
CREATE INDEX idx_sessions_sdk_id ON streaming_sessions(sdk_session_id);
CREATE INDEX idx_sessions_project_status ON streaming_sessions(project, status);

Table: session_locks

Prevents concurrent SDK session access.

CREATE TABLE session_locks (
  sdk_session_id TEXT PRIMARY KEY,
  locked_by TEXT NOT NULL,                      -- Command name: 'save' or 'summary'
  locked_at TEXT NOT NULL,                      -- ISO timestamp
  locked_at_epoch INTEGER NOT NULL              -- Unix ms
);

Lock lifecycle:

  • Acquired before resuming SDK session
  • Released after SDK stream completes
  • Auto-cleaned if older than 5 minutes (stale lock)

The Four Commands

All commands accept JSON from stdin and output hook-appropriate responses to stdout.

1. claude-mem context

Purpose: Load recent session history for context injection

Hook: SessionStart (matcher: "startup")

Input:

{
  "hook_event_name": "SessionStart",
  "session_id": "abc123",
  "transcript_path": "/path/to/transcript.jsonl",
  "cwd": "/path/to/project",
  "source": "startup"
}

Flow:

  1. Check source field → only process if "startup" or "clear"
  2. Extract project name from cwd (e.g., /Users/alex/myappmyapp)
  3. Open SQLite database at ~/.claude-mem/claude-mem.db
  4. Query recent completed sessions:
    SELECT title, subtitle, user_prompt, started_at
    FROM streaming_sessions
    WHERE project = ? AND status = 'completed'
    ORDER BY started_at_epoch DESC
    LIMIT 10
    
  5. Format as human-readable text
  6. Output to stdout (plain text, NOT JSON)

Output:

===============================================================================
What's new | Wednesday, October 15, 2025 at 03:18 PM EDT
===============================================================================
Recent sessions for myapp:

• 2025-10-15 14:30: User Authentication Implementation
  Added JWT tokens and refresh logic

• 2025-10-14 10:15: Database Schema Refactor
  Migrated to normalized structure

• 2025-10-13 16:45: API Documentation
  Created OpenAPI specs for all endpoints

===============================================================================

Exit code: 0

Special behavior: Claude Code injects stdout into conversation context automatically for SessionStart hooks.


2. claude-mem new

Purpose: Start new streaming memory session

Hook: UserPromptSubmit

Input:

{
  "hook_event_name": "UserPromptSubmit",
  "session_id": "abc123",
  "transcript_path": "/path/to/transcript.jsonl",
  "cwd": "/path/to/project",
  "prompt": "User's question or task",
  "timestamp": "2025-10-15T19:30:00Z"
}

Flow:

  1. Cleanup orphaned sessions

    UPDATE streaming_sessions
    SET status = 'failed'
    WHERE project = ? AND status = 'active'
    
  2. Create session record

    INSERT INTO streaming_sessions (
      claude_session_id, project, user_prompt,
      started_at, started_at_epoch, status
    ) VALUES (?, ?, ?, ?, ?, 'active')
    
  3. Build SDK system prompt

    const systemPrompt = `
    You are a memory assistant for project "${project}".
    Session: ${session_id}
    Date: ${date}
    
    The user said: "${user_prompt}"
    
    Your job: Analyze the work being done and remember important details.
    You will receive tool outputs. Extract what matters:
    - Key decisions made
    - Patterns discovered
    - Problems solved
    - Technical insights
    
    Store memories directly to SQLite using your available functions.
    `;
    
  4. Start SDK session

    const response = query({
      prompt: systemPrompt,
      options: {
        model: 'claude-sonnet-4-5-20250929',
        allowedTools: ['Bash'],  // SDK can write directly to SQLite
        maxTokens: 4096,
        cwd: payload.cwd
      }
    });
    
  5. Wait for SDK init and extract session ID

    for await (const msg of response) {
      if (msg.type === 'system' && msg.subtype === 'init') {
        sdkSessionId = msg.session_id;
        break;
      }
    }
    
  6. Update database with SDK session ID

    UPDATE streaming_sessions
    SET sdk_session_id = ?, updated_at = ?, updated_at_epoch = ?
    WHERE id = ?
    
  7. Set activity flag (for UI indicators)

    fs.writeFileSync(
      '~/.claude-mem/activity.flag',
      JSON.stringify({ active: true, project, timestamp: Date.now() })
    );
    
  8. Generate title asynchronously (non-blocking background task)

    // Use Claude SDK to generate a short title and subtitle
    // Store results back to streaming_sessions table
    // Runs detached, doesn't block the hook
    generateTitleInBackground(session_id, user_prompt, project);
    
  9. Output success response

Output:

{"continue": true, "suppressOutput": true}

Exit code: 0


3. claude-mem save

Purpose: Feed tool results to streaming memory agent

Hook: PostToolUse (matcher: "*")

Input:

{
  "hook_event_name": "PostToolUse",
  "session_id": "abc123",
  "transcript_path": "/path/to/transcript.jsonl",
  "cwd": "/path/to/project",
  "tool_name": "Read",
  "tool_input": {"file_path": "/path/to/file.ts"},
  "tool_response": {"content": "file contents..."},
  "timestamp": "2025-10-15T19:30:05Z"
}

Flow:

CRITICAL: This is an async operation to avoid blocking the main Claude Code session.

  1. Immediately return async response

    console.log(JSON.stringify({ async: true, asyncTimeout: 180000 }));
    
  2. Then process in background:

    a. Find active session

    SELECT id, sdk_session_id, claude_session_id
    FROM streaming_sessions
    WHERE project = ? AND status = 'active'
    ORDER BY started_at_epoch DESC
    LIMIT 1
    

    b. Validate SDK session exists

    • If no sdk_session_id yet, skip (session still initializing)

    c. Clean stale locks

    DELETE FROM session_locks
    WHERE locked_at_epoch < ?  -- 5 minutes ago
    

    d. Attempt to acquire lock

    INSERT INTO session_locks (sdk_session_id, locked_by, locked_at, locked_at_epoch)
    VALUES (?, 'save', ?, ?)
    
    • If insert fails (UNIQUE constraint), skip this tool
    • Lock prevents concurrent SDK access

    e. Build tool observation message

    const message = `
    TOOL OBSERVATION
    ===============
    Tool: ${tool_name}
    Input: ${JSON.stringify(tool_input, null, 2)}
    Output: ${JSON.stringify(tool_response, null, 2)}
    
    Analyze this result. If it contains important information, update the session record.
    You have access to Bash to run SQL commands against ~/.claude-mem/claude-mem.db
    `;
    

    f. Resume SDK session

    const response = query({
      prompt: message,
      options: {
        model: 'claude-sonnet-4-5-20250929',
        resume: sdkSessionId,  // Continue existing session
        allowedTools: ['Bash'],
        maxTokens: 2048,
        cwd: payload.cwd
      }
    });
    

    g. Consume SDK stream

    for await (const msg of response) {
      // SDK processes the tool result
      // May run Bash commands to update SQLite
      // E.g., INSERT INTO session_notes (session_id, note, timestamp) VALUES (...)
    }
    

    h. Release lock

    DELETE FROM session_locks
    WHERE sdk_session_id = ?
    

Output:

{"async": true, "asyncTimeout": 180000}

Exit code: 0

Notes:

  • Non-critical: If locked or session not ready, just skip
  • Next tool will catch up
  • SDK decides what's worth remembering

4. claude-mem summary

Purpose: Generate and store final session overview

Hook: Stop

Input:

{
  "hook_event_name": "Stop",
  "session_id": "abc123",
  "transcript_path": "/path/to/transcript.jsonl",
  "cwd": "/path/to/project"
}

Flow:

CRITICAL: This is an async operation but MUST complete successfully.

  1. Immediately return async response

    console.log(JSON.stringify({ async: true, asyncTimeout: 180000 }));
    
  2. Clear activity flag

    fs.writeFileSync(
      '~/.claude-mem/activity.flag',
      JSON.stringify({ active: false, timestamp: Date.now() })
    );
    
  3. Then process in background:

    a. Find active session

    SELECT id, sdk_session_id, claude_session_id, title, subtitle
    FROM streaming_sessions
    WHERE project = ? AND status = 'active'
    ORDER BY started_at_epoch DESC
    LIMIT 1
    

    b. Validate SDK session exists

    • If no sdk_session_id, exit (session never fully initialized)

    c. Clean stale locks

    DELETE FROM session_locks
    WHERE locked_at_epoch < ?  -- 5 minutes ago
    

    d. Acquire lock (with retry)

    // Wait up to 10 seconds for 'save' to finish
    for (let i = 0; i < 20; i++) {
      try {
        // INSERT INTO session_locks ...
        lockAcquired = true;
        break;
      } catch {
        await sleep(500);
      }
    }
    if (!lockAcquired) throw new Error('Could not acquire lock');
    

    e. Build finalization message

    const message = `
    SESSION ENDING
    =============
    Project: ${project}
    Session: ${claude_session_id}
    Title: ${title || 'Untitled'}
    Subtitle: ${subtitle || ''}
    
    Generate a comprehensive overview of this session.
    
    Required format:
    - One-line title (if not already set)
    - One-line subtitle (if not already set)
    - Key accomplishments
    - Technical decisions
    - Problems solved
    
    Store the overview by updating the streaming_sessions record in SQLite.
    Use Bash to run the SQL UPDATE command.
    `;
    

    f. Resume SDK session

    const response = query({
      prompt: message,
      options: {
        model: 'claude-sonnet-4-5-20250929',
        resume: sdkSessionId,
        allowedTools: ['Bash'],
        maxTokens: 4096,
        cwd: payload.cwd
      }
    });
    

    g. Consume SDK stream

    for await (const msg of response) {
      // SDK generates overview and updates database
      // Runs SQL like:
      // UPDATE streaming_sessions
      // SET title = ?, subtitle = ?
      // WHERE id = ?
    }
    

    h. Mark session complete

    UPDATE streaming_sessions
    SET status = 'completed',
        completed_at = ?,
        completed_at_epoch = ?
    WHERE id = ?
    

    i. Delete SDK transcript (keep UI clean)

    const transcriptPath = `~/.claude/projects/${sanitizedCwd}/${sdkSessionId}.jsonl`;
    if (fs.existsSync(transcriptPath)) {
      fs.unlinkSync(transcriptPath);
    }
    

    j. Release lock

    DELETE FROM session_locks
    WHERE sdk_session_id = ?
    

Output:

{"async": true, "asyncTimeout": 180000}

Exit code: 0

Notes:

  • MUST acquire lock (waits up to 10s)
  • MUST complete successfully
  • Cleanup is important for user experience

TypeScript Module Structure

src/lib/stdin-reader.ts

export async function readStdinJson(): Promise<any> {
  const chunks: string[] = [];
  for await (const chunk of process.stdin) {
    chunks.push(chunk.toString());
  }
  const input = chunks.join('');
  return input.trim() ? JSON.parse(input) : {};
}

src/lib/database.ts

import { Database } from 'bun:sqlite';
import path from 'path';
import os from 'os';
import fs from 'fs';

export interface StreamingSession {
  id: number;
  claude_session_id: string;
  sdk_session_id: string | null;
  project: string;
  title: string | null;
  subtitle: string | null;
  user_prompt: string | null;
  started_at: string;
  started_at_epoch: number;
  updated_at: string | null;
  updated_at_epoch: number | null;
  completed_at: string | null;
  completed_at_epoch: number | null;
  status: 'active' | 'completed' | 'failed';
}

function getDataDirectory(): string {
  return path.join(os.homedir(), '.claude-mem');
}

export function initializeDatabase(): Database {
  const dataDir = getDataDirectory();
  const dbPath = path.join(dataDir, 'claude-mem.db');

  if (!fs.existsSync(dataDir)) {
    fs.mkdirSync(dataDir, { recursive: true });
  }

  const db = new Database(dbPath);

  // Optimize SQLite settings
  db.pragma('journal_mode = WAL');
  db.pragma('synchronous = NORMAL');
  db.pragma('foreign_keys = ON');
  db.pragma('temp_store = memory');

  ensureTables(db);

  return db;
}

function ensureTables(db: Database): void {
  // Create streaming_sessions table
  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
  db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_claude_id ON streaming_sessions(claude_session_id)`);
  db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_sdk_id ON streaming_sessions(sdk_session_id)`);
  db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_project_status ON streaming_sessions(project, status)`);

  // Create session_locks table
  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
    )
  `);
}

export function createStreamingSession(
  db: Database,
  data: {
    claude_session_id: string;
    project: string;
    user_prompt: string | null;
    started_at: string;
  }
): StreamingSession {
  const epoch = new Date(data.started_at).getTime();

  const stmt = db.prepare(`
    INSERT INTO streaming_sessions (
      claude_session_id, project, user_prompt, started_at, started_at_epoch, status
    ) VALUES (?, ?, ?, ?, ?, 'active')
  `);

  const result = stmt.run(
    data.claude_session_id,
    data.project,
    data.user_prompt,
    data.started_at,
    epoch
  );

  return db.prepare('SELECT * FROM streaming_sessions WHERE id = ?')
    .get(result.lastInsertRowid) as StreamingSession;
}

export function updateStreamingSession(
  db: Database,
  id: number,
  updates: Partial<StreamingSession>
): void {
  const timestamp = new Date().toISOString();
  const epoch = Date.now();

  const fields: string[] = [];
  const values: any[] = [];

  if (updates.sdk_session_id !== undefined) {
    fields.push('sdk_session_id = ?');
    values.push(updates.sdk_session_id);
  }
  if (updates.title !== undefined) {
    fields.push('title = ?');
    values.push(updates.title);
  }
  if (updates.subtitle !== undefined) {
    fields.push('subtitle = ?');
    values.push(updates.subtitle);
  }
  if (updates.status !== undefined) {
    fields.push('status = ?');
    values.push(updates.status);
  }

  fields.push('updated_at = ?', 'updated_at_epoch = ?');
  values.push(timestamp, epoch);

  values.push(id);

  const stmt = db.prepare(`
    UPDATE streaming_sessions
    SET ${fields.join(', ')}
    WHERE id = ?
  `);

  stmt.run(...values);
}

export function getActiveStreamingSessionsForProject(
  db: Database,
  project: string
): StreamingSession[] {
  const stmt = db.prepare(`
    SELECT * FROM streaming_sessions
    WHERE project = ? AND status = 'active'
    ORDER BY started_at_epoch DESC
  `);

  return stmt.all(project) as StreamingSession[];
}

export function getRecentCompletedSessions(
  db: Database,
  project: string,
  limit: number = 10
): StreamingSession[] {
  const stmt = db.prepare(`
    SELECT * FROM streaming_sessions
    WHERE project = ? AND status = 'completed'
    ORDER BY started_at_epoch DESC
    LIMIT ?
  `);

  return stmt.all(project, limit) as StreamingSession[];
}

export function markStreamingSessionCompleted(
  db: Database,
  id: number
): void {
  const timestamp = new Date().toISOString();
  const epoch = Date.now();

  const stmt = db.prepare(`
    UPDATE streaming_sessions
    SET status = 'completed',
        completed_at = ?,
        completed_at_epoch = ?,
        updated_at = ?,
        updated_at_epoch = ?
    WHERE id = ?
  `);

  stmt.run(timestamp, epoch, timestamp, epoch, id);
}

export function markOrphanedSessionsFailed(
  db: Database,
  project: string
): void {
  const stmt = db.prepare(`
    UPDATE streaming_sessions
    SET status = 'failed'
    WHERE project = ? AND status = 'active'
  `);

  stmt.run(project);
}

export function acquireSessionLock(
  db: Database,
  sdkSessionId: string,
  lockOwner: string
): boolean {
  try {
    const timestamp = new Date().toISOString();
    const epoch = Date.now();

    const stmt = db.prepare(`
      INSERT INTO session_locks (sdk_session_id, locked_by, locked_at, locked_at_epoch)
      VALUES (?, ?, ?, ?)
    `);

    stmt.run(sdkSessionId, lockOwner, timestamp, epoch);
    return true;
  } catch {
    return false; // UNIQUE constraint violation = already locked
  }
}

export function releaseSessionLock(
  db: Database,
  sdkSessionId: string
): void {
  const stmt = db.prepare(`
    DELETE FROM session_locks
    WHERE sdk_session_id = ?
  `);

  stmt.run(sdkSessionId);
}

export function cleanupStaleLocks(db: Database): void {
  const fiveMinutesAgo = Date.now() - (5 * 60 * 1000);

  const stmt = db.prepare(`
    DELETE FROM session_locks
    WHERE locked_at_epoch < ?
  `);

  stmt.run(fiveMinutesAgo);
}

src/lib/path-resolver.ts

import path from 'path';

export function getProjectName(cwd: string): string {
  return path.basename(cwd);
}

src/lib/prompt-builder.ts

export function buildSystemPrompt(params: {
  project: string;
  sessionId: string;
  date: string;
  userPrompt: string;
}): string {
  return `You are a memory assistant for project "${params.project}".

Session ID: ${params.sessionId}
Date: ${params.date}

The user said: "${params.userPrompt}"

Your job is to analyze the work being done and remember important details.
You will receive tool outputs as the session progresses.

Extract what matters:
- Key decisions made
- Patterns discovered
- Problems solved
- Technical insights

You have access to Bash to write directly to the SQLite database at ~/.claude-mem/claude-mem.db
Store important observations as you see them.`;
}

export function buildToolMessage(params: {
  toolName: string;
  toolInput: any;
  toolResponse: any;
  timestamp: string;
}): string {
  return `TOOL OBSERVATION
===============
Time: ${params.timestamp}
Tool: ${params.toolName}

Input:
${JSON.stringify(params.toolInput, null, 2)}

Output:
${JSON.stringify(params.toolResponse, null, 2)}

Analyze this result. If it contains important information worth remembering, use Bash to update the database.`;
}

export function buildEndMessage(params: {
  project: string;
  sessionId: string;
  title: string | null;
  subtitle: string | null;
}): string {
  return `SESSION ENDING
=============
Project: ${params.project}
Session: ${params.sessionId}
Current Title: ${params.title || 'Not set'}
Current Subtitle: ${params.subtitle || 'Not set'}

Generate a comprehensive overview of this session.

Required:
1. A concise title (if not already set)
2. A brief subtitle (if not already set)
3. Key accomplishments
4. Technical decisions made
5. Problems solved

Use Bash to UPDATE the streaming_sessions record with the title and subtitle if they're not already set.`;
}

src/commands/hook-handlers.ts

import { query } from '@anthropic-ai/claude-agent-sdk';
import { initializeDatabase, Database } from '../lib/database';
import * as db from '../lib/database';
import { getProjectName } from '../lib/path-resolver';
import { buildSystemPrompt, buildToolMessage, buildEndMessage } from '../lib/prompt-builder';
import fs from 'fs';
import path from 'path';
import os from 'os';

export async function handleContext(payload: any): Promise<void> {
  // Only process startup/clear
  if (payload.source !== 'startup' && payload.source !== 'clear') {
    return;
  }

  const project = getProjectName(payload.cwd);
  const database = initializeDatabase();

  const sessions = db.getRecentCompletedSessions(database, project, 10);

  if (sessions.length === 0) {
    console.log(`===============================================================================
What's new | ${new Date().toLocaleString('en-US', {
  weekday: 'long', month: 'long', day: 'numeric', year: 'numeric',
  hour: 'numeric', minute: '2-digit', timeZoneName: 'short'
})}
===============================================================================
No previous sessions found for this project.
Start working and claude-mem will automatically capture context for future sessions.
===============================================================================`);
    database.close();
    return;
  }

  console.log(`===============================================================================
What's new | ${new Date().toLocaleString('en-US', {
  weekday: 'long', month: 'long', day: 'numeric', year: 'numeric',
  hour: 'numeric', minute: '2-digit', timeZoneName: 'short'
})}
===============================================================================
Recent sessions for ${project}:
`);

  for (const session of sessions) {
    const date = new Date(session.started_at).toISOString().split('T')[0];
    const title = session.title || 'Untitled';
    const subtitle = session.subtitle || '';
    console.log(`• ${date}: ${title}`);
    if (subtitle) {
      console.log(`  ${subtitle}`);
    }
    console.log();
  }

  console.log(`===============================================================================`);
  database.close();
}

export async function handleNew(payload: any): Promise<void> {
  const project = getProjectName(payload.cwd);
  const database = initializeDatabase();

  // Mark any orphaned sessions as failed
  db.markOrphanedSessionsFailed(database, project);

  // Create new session
  const session = db.createStreamingSession(database, {
    claude_session_id: payload.session_id,
    project,
    user_prompt: payload.prompt || null,
    started_at: payload.timestamp || new Date().toISOString()
  });

  // Build system prompt
  const date = new Date().toISOString().split('T')[0];
  const systemPrompt = buildSystemPrompt({
    project,
    sessionId: payload.session_id,
    date,
    userPrompt: payload.prompt || ''
  });

  // Start SDK session
  const response = query({
    prompt: systemPrompt,
    options: {
      model: 'claude-sonnet-4-5-20250929',
      allowedTools: ['Bash'],
      maxTokens: 4096,
      cwd: payload.cwd
    }
  });

  // Extract SDK session ID
  let sdkSessionId: string | null = null;
  for await (const msg of response) {
    if (msg.type === 'system' && msg.subtype === 'init') {
      sdkSessionId = msg.session_id;
      break;
    }
  }

  // Update with SDK session ID
  if (sdkSessionId) {
    db.updateStreamingSession(database, session.id, { sdk_session_id: sdkSessionId });
  }

  // Set activity flag
  const activityFlagPath = path.join(os.homedir(), '.claude-mem', 'activity.flag');
  fs.writeFileSync(activityFlagPath, JSON.stringify({
    active: true,
    project,
    timestamp: Date.now()
  }));

  database.close();

  // Output hook response
  console.log(JSON.stringify({ continue: true, suppressOutput: true }));
}

export async function handleSave(payload: any): Promise<void> {
  // Return immediately
  console.log(JSON.stringify({ async: true, asyncTimeout: 180000 }));

  // Process async
  const project = getProjectName(payload.cwd);
  const database = initializeDatabase();

  try {
    db.cleanupStaleLocks(database);

    const sessions = db.getActiveStreamingSessionsForProject(database, project);
    if (sessions.length === 0) {
      database.close();
      return;
    }

    const session = sessions[0];
    if (!session.sdk_session_id) {
      database.close();
      return;
    }

    // Try to acquire lock
    const lockAcquired = db.acquireSessionLock(database, session.sdk_session_id, 'save');
    if (!lockAcquired) {
      database.close();
      return;
    }

    // Build tool message
    const message = buildToolMessage({
      toolName: payload.tool_name,
      toolInput: payload.tool_input,
      toolResponse: payload.tool_response,
      timestamp: payload.timestamp || new Date().toISOString()
    });

    // Resume SDK session
    const response = query({
      prompt: message,
      options: {
        model: 'claude-sonnet-4-5-20250929',
        resume: session.sdk_session_id,
        allowedTools: ['Bash'],
        maxTokens: 2048,
        cwd: payload.cwd
      }
    });

    // Consume stream
    for await (const msg of response) {
      // SDK processes
    }

    db.releaseSessionLock(database, session.sdk_session_id);
  } catch (error) {
    console.error('Error in save:', error);
  } finally {
    database.close();
  }
}

export async function handleSummary(payload: any): Promise<void> {
  // Return immediately
  console.log(JSON.stringify({ async: true, asyncTimeout: 180000 }));

  // Clear activity flag
  const activityFlagPath = path.join(os.homedir(), '.claude-mem', 'activity.flag');
  fs.writeFileSync(activityFlagPath, JSON.stringify({
    active: false,
    timestamp: Date.now()
  }));

  const project = getProjectName(payload.cwd);
  const database = initializeDatabase();

  try {
    db.cleanupStaleLocks(database);

    const sessions = db.getActiveStreamingSessionsForProject(database, project);
    if (sessions.length === 0) {
      database.close();
      return;
    }

    const session = sessions[0];
    if (!session.sdk_session_id) {
      database.close();
      return;
    }

    // Acquire lock with retry
    let lockAcquired = false;
    for (let i = 0; i < 20; i++) {
      lockAcquired = db.acquireSessionLock(database, session.sdk_session_id, 'summary');
      if (lockAcquired) break;
      await new Promise(resolve => setTimeout(resolve, 500));
    }

    if (!lockAcquired) {
      throw new Error('Could not acquire session lock');
    }

    // Build end message
    const message = buildEndMessage({
      project,
      sessionId: session.claude_session_id,
      title: session.title,
      subtitle: session.subtitle
    });

    // Resume SDK session
    const response = query({
      prompt: message,
      options: {
        model: 'claude-sonnet-4-5-20250929',
        resume: session.sdk_session_id,
        allowedTools: ['Bash'],
        maxTokens: 4096,
        cwd: payload.cwd
      }
    });

    // Consume stream
    for await (const msg of response) {
      // SDK generates and stores overview
    }

    // Mark completed
    db.markStreamingSessionCompleted(database, session.id);

    // Delete SDK transcript
    const sanitizedCwd = payload.cwd.replace(/\//g, '-');
    const transcriptPath = path.join(
      os.homedir(),
      '.claude',
      'projects',
      sanitizedCwd,
      `${session.sdk_session_id}.jsonl`
    );

    if (fs.existsSync(transcriptPath)) {
      fs.unlinkSync(transcriptPath);
    }

    db.releaseSessionLock(database, session.sdk_session_id);
  } catch (error) {
    console.error('Error in summary:', error);
  } finally {
    database.close();
  }
}

src/cli.ts

import { Command } from 'commander';
import { readStdinJson } from './lib/stdin-reader';
import { handleContext, handleNew, handleSave, handleSummary } from './commands/hook-handlers';

const program = new Command();

program
  .name('claude-mem')
  .description('Memory management for Claude Code')
  .version('4.0.0');

program
  .command('context')
  .description('Load context from previous sessions')
  .action(async () => {
    const payload = await readStdinJson();
    await handleContext(payload);
  });

program
  .command('new')
  .description('Start new memory session')
  .action(async () => {
    const payload = await readStdinJson();
    await handleNew(payload);
  });

program
  .command('save')
  .description('Save tool observation to memory')
  .action(async () => {
    const payload = await readStdinJson();
    await handleSave(payload);
  });

program
  .command('summary')
  .description('Generate and store session summary')
  .action(async () => {
    const payload = await readStdinJson();
    await handleSummary(payload);
  });

program.parse();

Installation & Configuration

User Installation

npm install -g claude-mem

Claude Code Configuration

Add to .claude/settings.json:

{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "startup",
        "hooks": [
          {
            "type": "command",
            "command": "claude-mem context"
          }
        ]
      }
    ],
    "UserPromptSubmit": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "claude-mem new"
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "*",
        "hooks": [
          {
            "type": "command",
            "command": "claude-mem save"
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "claude-mem summary"
          }
        ]
      }
    ]
  }
}

That's it! No hook files needed.


Testing Strategy

Unit Tests

Test each command independently:

# Test context
echo '{"hook_event_name":"SessionStart","source":"startup","cwd":"'$(pwd)'"}' | claude-mem context

# Test new
echo '{"hook_event_name":"UserPromptSubmit","session_id":"test","prompt":"hello","cwd":"'$(pwd)'","timestamp":"'$(date -Iseconds)'"}' | claude-mem new

# Test save
echo '{"hook_event_name":"PostToolUse","session_id":"test","tool_name":"Read","tool_input":{},"tool_response":{"content":"test"},"cwd":"'$(pwd)'"}' | claude-mem save

# Test summary
echo '{"hook_event_name":"Stop","session_id":"test","cwd":"'$(pwd)'"}' | claude-mem summary

Integration Tests

Full lifecycle test:

  1. Create session
  2. Feed several tools
  3. Generate summary
  4. Verify database state

Database Verification

sqlite3 ~/.claude-mem/claude-mem.db "SELECT * FROM streaming_sessions;"

Success Criteria

All four commands work with stdin JSON All database operations are type-safe No hook files to distribute No CLI-to-CLI calls Clean separation of concerns Comprehensive test coverage Simple installation process


Timeline

  • Database layer: 2-3 hours
  • Command handlers: 4-6 hours
  • CLI integration: 1-2 hours
  • Testing: 2-3 hours
  • Documentation: 1-2 hours

Total: 10-16 hours of focused development