Hook scripts reference
Claude Mem registers hooks via plugin/hooks/hooks.json. At runtime, Claude Code fires lifecycle events and Claude Mem responds by running the appropriate script. This page documents every script, what it does, and the data contracts it expects.
Hooks.json — the complete configuration
The actual file shipped with the plugin:
{
"description": "Claude-mem memory system hooks",
"hooks": {
"Setup": [{
"matcher": "*",
"hooks": [{
"type": "command",
"command": "_R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; \"$_R/scripts/setup.sh\"",
"timeout": 300
}]
}],
"SessionStart": [{
"matcher": "startup|clear|compact",
"hooks": [
{
"type": "command",
"command": "_R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/smart-install.js\"",
"timeout": 300
},
{
"type": "command",
"command": "_R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" start",
"timeout": 60
},
{
"type": "command",
"command": "_R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code context",
"timeout": 60
}
]
}],
"UserPromptSubmit": [{
"hooks": [{
"type": "command",
"command": "_R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code session-init",
"timeout": 60
}]
}],
"PostToolUse": [{
"matcher": "*",
"hooks": [{
"type": "command",
"command": "_R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code observation",
"timeout": 120
}]
}],
"Stop": [{
"hooks": [{
"type": "command",
"command": "_R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code summarize",
"timeout": 120
}]
}],
"SessionEnd": [{
"hooks": [{
"type": "command",
"command": "node -e \"let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{const{sessionId:s}=JSON.parse(d);if(!s){process.exit(0)}const r=require('http').request({hostname:'127.0.0.1',port:37777,path:'/api/sessions/complete',method:'POST',headers:{'Content-Type':'application/json'}},()=>process.exit(0));r.on('error',()=>process.exit(0));r.end(JSON.stringify({contentSessionId:s}));setTimeout(()=>process.exit(0),3000)}catch{process.exit(0)}})\"",
"timeout": 5
}]
}]
}
}
${CLAUDE_PLUGIN_ROOT} is set by Claude Code to the installed plugin’s root directory. The fallback $HOME/.claude/plugins/marketplaces/thedotmack/plugin handles environments where the variable is not set.
The 5 lifecycle stages
| Stage | Script | Lifecycle event | Purpose |
|---|
| 0 (pre-hook) | smart-install.js | Before SessionStart context | Cached dependency check |
| 1 | worker-service.cjs start | SessionStart | Start Bun worker service |
| 2 | worker-service.cjs hook … context | SessionStart | Inject prior session context |
| 3 | worker-service.cjs hook … session-init | UserPromptSubmit | Create session, save prompt |
| 4 | worker-service.cjs hook … observation | PostToolUse | Queue tool observation |
| 5 | worker-service.cjs hook … summarize | Stop | Trigger session summary |
| 6 | cleanup-hook.js (inline) | SessionEnd | Mark session complete |
Script 1: smart-install.js
File: scripts/smart-install.js (pre-hook, not a lifecycle hook)
Lifecycle event: Runs via command chaining before context injection in SessionStart
Matcher: startup|clear|compact
Timeout: 300 seconds
What it does
Checks whether Node.js dependencies are up to date and installs them only when necessary. Without this, every session startup would run npm install (2–5 seconds).
Decision logic:
- Does
node_modules exist?
- Does
.install-version match the current package.json version?
- Is
better-sqlite3 present? (Legacy check — bun:sqlite requires no installation)
If all checks pass: skip install (~10ms). Otherwise: run npm install and write the new version to .install-version.
const currentVersion = getPackageVersion();
const installedVersion = readFileSync('.install-version', 'utf-8');
if (currentVersion !== installedVersion) {
await runNpmInstall();
writeFileSync('.install-version', currentVersion);
}
What it reads: package.json, .install-version marker file
What it writes: .install-version marker file
Exit codes:
0 — Check complete (installed or skipped)
1 — Installation error (shown to user on stderr)
Added in: v5.0.3
Script 2: worker-service.cjs start
File: plugin/scripts/worker-service.cjs
Command argument: start
Lifecycle event: SessionStart (second hook in sequence)
Matcher: startup|clear|compact
Timeout: 60 seconds
What it does
Ensures the Bun-managed worker process is running. Uses a three-layer health check:
- Does
~/.claude-mem/.worker.pid exist?
- Is the process with that PID alive? (
kill(pid, 0))
- Does
GET http://127.0.0.1:37777/health return OK?
If all three pass, the worker is already running and the hook returns immediately. Otherwise, it spawns a new Bun process.
What it reads: ~/.claude-mem/.worker.pid, ~/.claude-mem/.worker.port
What it writes: ~/.claude-mem/.worker.pid, ~/.claude-mem/.worker.port
Exit codes:
0 — Worker confirmed running
0 — Worker started successfully (or startup errors are swallowed to prevent session breakage)
Script 3: context-hook.js (worker-service hook … context)
File: plugin/scripts/context-hook.js / worker-service.cjs hook claude-code context
Lifecycle event: SessionStart (third hook in sequence)
Source: src/hooks/context-hook.ts
Matcher: startup|clear|compact
Timeout: 60 seconds
What it does
Fetches context from previous sessions and injects it silently into Claude’s initial system prompt.
Processing steps:
- Wait for worker to be healthy (health check with retry, max 10s)
- Extract project name from
cwd: path.basename(cwd)
- Call
GET http://127.0.0.1:37777/api/context/inject?project={project}
- Return the formatted markdown as
hookSpecificOutput.additionalContext
Stdin
{
"session_id": "claude-session-abc123",
"cwd": "/path/to/project",
"hook_event_name": "SessionStart",
"source": "startup"
}
Stdout
{
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": "# [claude-mem] recent context\n\n**Legend:** 🎯 session-request ...\n\n### Oct 26, 2025\n\n**General**\n| ID | Time | T | Title | Tokens |\n..."
}
}
Context format (progressive disclosure index):
# [claude-mem] recent context
**Legend:** 🎯 session-request | 🔴 gotcha | 🟡 problem-solution ...
### Oct 26, 2025
**General**
| ID | Time | T | Title | Tokens |
|----|------|---|-------|--------|
| #2586 | 12:58 AM | 🔵 | Context hook file empty | ~51 |
*Use MCP search tools to access full details*
What it reads: Worker HTTP API (/api/context/inject) → SQLite session_summaries and observations tables
What it writes: Nothing (read-only)
Configuration: CLAUDE_MEM_CONTEXT_OBSERVATIONS env var controls the number of observations shown (default: 50)
Exit codes:
0 — Context injected (or gracefully empty if worker unavailable)
As of Claude Code 2.1.0, context is injected silently via additionalContext. It is not shown to the user as a message.
Script 4: new-hook.js (worker-service hook … session-init)
File: plugin/scripts/new-hook.js / worker-service.cjs hook claude-code session-init
Lifecycle event: UserPromptSubmit
Source: src/hooks/new-hook.ts
Matcher: None (runs for every prompt)
Timeout: 60 seconds
What it does
Creates or retrieves the session record for this conversation and saves the user’s prompt for full-text search.
Processing steps:
- Extract project name from
cwd
- Strip privacy tags (
<private>...</private>, <claude-mem-context>...</claude-mem-context>) from the prompt
- If prompt is entirely private after stripping, skip saving
INSERT OR IGNORE INTO sdk_sessions — idempotent; returns existing row if session already exists (continuation prompts)
- Increment
prompt_counter on the session row
INSERT INTO user_prompts with the cleaned prompt text and prompt number
- POST
http://127.0.0.1:37777/sessions/{sessionDbId}/init (fire-and-forget, 2s timeout)
Idempotency: Using the same session_id always maps to the same database row. This handles continuation prompts correctly.
Stdin
{
"session_id": "claude-session-abc123",
"cwd": "/path/to/project",
"prompt": "Add login feature to the API"
}
Stdout
{ "continue": true, "suppressOutput": true }
What it reads: SQLite sdk_sessions table
What it writes: SQLite sdk_sessions, user_prompts tables; HTTP POST to worker
Exit codes:
0 — Session initialized (or skipped for private prompts)
Never generate your own session IDs. Always use the session_id provided by Claude Code via stdin — this is the source of truth for linking all observations and summaries together.
Script 5: save-hook.js (worker-service hook … observation)
File: plugin/scripts/save-hook.js / worker-service.cjs hook claude-code observation
Lifecycle event: PostToolUse
Source: src/hooks/save-hook.ts
Matcher: * (all tools)
Timeout: 120 seconds
What it does
Captures tool execution data and sends it to the worker for asynchronous AI compression. The hook itself returns in ~8ms; the AI compression runs in the background over 1–30 seconds.
Processing steps:
- Check the skip list — discard low-value tools immediately:
const SKIP_TOOLS = new Set([
'ListMcpResourcesTool', // MCP infrastructure noise
'SlashCommand', // Command invocation
'Skill', // Skill invocation
'TodoWrite', // Task management meta-tool
'AskUserQuestion' // User interaction
]);
- Ensure worker is running (health check)
- Strip privacy tags from
tool_input and tool_response
- HTTP POST to worker (fire-and-forget, 2s timeout):
POST http://127.0.0.1:37777/api/sessions/observations
Body: { claudeSessionId, tool_name, tool_input, tool_response, cwd }
Stdin
{
"session_id": "claude-session-abc123",
"cwd": "/path/to/project",
"tool_name": "Read",
"tool_input": { "file_path": "/src/index.ts" },
"tool_response": "file contents..."
}
Stdout
{ "continue": true, "suppressOutput": true }
What it reads: Stdin from Claude Code
What it writes: HTTP POST to worker (worker writes to SQLite and Chroma)
Async worker processing:
createSDKSession(claudeSessionId, '', '') → returns sessionDbId
- Checks privacy (skips observation if user prompt was entirely private)
- Strips memory tags from
tool_input and tool_response (defense-in-depth)
- Queues observation for SDK agent
- SDK agent calls Claude to compress into structured XML
- Stores compressed observation in database, syncs to Chroma vector DB
Exit codes:
0 — Observation queued (or skipped for low-value/private tools)
Script 6: summary-hook.js (worker-service hook … summarize)
File: plugin/scripts/summary-hook.js / worker-service.cjs hook claude-code summarize
Lifecycle event: Stop
Source: src/hooks/summary-hook.ts
Matcher: None (runs on every stop)
Timeout: 120 seconds
What it does
Triggers asynchronous session summary generation when the user stops asking questions. The hook returns immediately; the actual summarization runs in the worker.
Processing steps:
- Read the transcript JSONL file to extract:
- Last user message (type:
"user")
- Last assistant message (type:
"assistant", filtering out <system-reminder> tags)
- Ensure worker is running
- POST to worker (fire-and-forget, 2s timeout):
POST http://127.0.0.1:37777/api/sessions/summarize
Body: { claudeSessionId, last_user_message, last_assistant_message }
- Signal the worker to stop the processing spinner:
POST http://127.0.0.1:37777/api/processing
Body: { isProcessing: false }
Stdin
{
"session_id": "claude-session-abc123",
"cwd": "/path/to/project",
"transcript_path": "/path/to/transcript.jsonl"
}
Stdout
{ "continue": true, "suppressOutput": true }
What it reads: Transcript JSONL file from disk
What it writes: HTTP POST to worker (worker writes to SQLite session_summaries table)
Async worker processing:
- Looks up
sessionDbId from claudeSessionId
- Queues summarization for SDK agent
- SDK agent calls Claude with structured prompt
- Parses XML response with fields:
request, investigated, learned, completed, next_steps
- Stores in
session_summaries table, syncs to Chroma
Exit codes:
Script 7: cleanup-hook.js (inline SessionEnd)
File: Inline node -e "..." in hooks.json
Lifecycle event: SessionEnd
Source: src/hooks/cleanup-hook.ts
Matcher: None (runs on all session ends)
Timeout: 5 seconds
What it does
Marks the session as completed so the worker can update its state and broadcast to SSE clients (viewer UI). The hook is intentionally minimal — it is inlined directly in hooks.json to avoid a process startup penalty for a short-lived operation.
Processing steps:
- Parse
sessionId from stdin JSON
- POST to worker (fire-and-forget, 3s timeout):
POST http://127.0.0.1:37777/api/sessions/complete
Body: { contentSessionId: sessionId }
- Exit
0 regardless of outcome (HTTP errors are swallowed)
Stdin
{
"session_id": "claude-session-abc123",
"cwd": "/path/to/project",
"transcript_path": "/path/to/transcript.jsonl",
"reason": "exit"
}
Stdout
None (HTTP fire-and-forget).
What it reads: Stdin from Claude Code
What it writes: HTTP POST to worker (worker updates SQLite, broadcasts SSE event)
Async worker processing:
- Looks up
sessionDbId
UPDATE sdk_sessions SET status = 'completed', completed_at = NOW()
- Broadcasts session completion event to SSE clients (updates viewer UI)
Exit codes:
0 — Always (failure to mark complete is non-fatal)
Session state machine
All seven scripts operate on the same session state:
[*] → Initialized (SessionStart generates session_id)
→ Active (UserPromptSubmit fires, first prompt)
→ Active (UserPromptSubmit fires, continuation — promptNumber++)
→ ObservationQueued (PostToolUse fires — async, non-blocking)
→ Summarizing (Stop fires — summary generated async)
→ Active (user resumes, new prompt)
→ Completed (SessionEnd fires)
→ [*]
Key invariants:
session_id never changes within a conversation
sessionDbId is the integer primary key — all operations use it
promptNumber increments with each UserPromptSubmit
- State transitions are non-blocking — hooks fire and return
Database schema
The session-centric schema that all hook scripts write to:
-- One row per Claude Code session (conversation)
CREATE TABLE sdk_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
claude_session_id TEXT UNIQUE NOT NULL, -- From Claude Code hook input
project TEXT NOT NULL, -- path.basename(cwd)
first_user_prompt TEXT, -- Nullable (v4.2.8+)
prompt_counter INTEGER DEFAULT 0,
status TEXT DEFAULT 'initialized',
created_at DATETIME,
completed_at DATETIME
);
-- One row per user prompt within a session
CREATE TABLE user_prompts (
id INTEGER PRIMARY KEY,
session_id INTEGER REFERENCES sdk_sessions(id),
prompt_number INTEGER,
prompt TEXT,
created_at DATETIME
);
-- One row per tool observation (after AI compression)
CREATE TABLE observations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER REFERENCES sdk_sessions(id),
tool_name TEXT,
compressed_observation TEXT,
created_at DATETIME
);
-- One row per session summary (can have many per session)
CREATE TABLE session_summaries (
id INTEGER PRIMARY KEY,
session_id INTEGER REFERENCES sdk_sessions(id),
request TEXT,
investigated TEXT,
learned TEXT,
completed TEXT,
next_steps TEXT,
created_at DATETIME
);
-- FTS5 virtual table for full-text search
CREATE VIRTUAL TABLE observations_fts USING fts5(
title, subtitle, narrative, facts, concepts,
content=observations
);
Privacy and tag stripping
Both new-hook.js and save-hook.js strip special tags before data is saved:
// User-level privacy (manual)
<private>sensitive content</private> // removed before storing
// System-level recursion prevention (auto-injected by context hook)
<claude-mem-context>...</claude-mem-context> // removed before storing
Stripping happens at the hook layer (edge processing) before data is sent to the worker. The worker strips tags again as defense-in-depth.
Functions: stripMemoryTagsFromPrompt() and stripMemoryTagsFromJson() — source: src/utils/tag-stripping.ts.
Common pitfalls
| Problem | Root cause | Solution |
|---|
| Session ID mismatch | Different session_id used across hooks | Always use ID from hook stdin — never generate your own |
| Duplicate sessions | Creating new session instead of reusing | Use INSERT OR IGNORE with claude_session_id as unique key |
| Blocking IDE | Hook waits for full AI response | Use fire-and-forget with 2s HTTP timeout |
| Memory tags in DB | Tag stripping skipped | Strip at hook layer before HTTP send |
| Worker not found | Health check returns too fast | Retry loop with exponential backoff (max 10s) |
| Context not injecting | npm install logs in stdout | Fixed in v4.3.1 — use --loglevel=silent flag |