Consolidate hooks: merge bin/hooks and hooks into single hooks/ directory

- Removed bin/hooks/ wrapper layer
- Moved all hook logic into consolidated hooks/*-hook.ts files
- Each hook now handles its own stdin/stdout/JSON wrapping
- Removed ALL try-catch blocks from context-hook (let errors surface)
- Updated build script to reference new src/hooks/ paths
- Reduced from 12+ files to 6 hook files

This simplifies the architecture and makes debugging actually possible.
This commit is contained in:
Alex Newman
2025-10-25 23:59:43 -04:00
parent d363dfd668
commit e9c0ec45db
20 changed files with 718 additions and 825 deletions
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env node
/**
* Cleanup Hook - SessionEnd
* Consolidated entry point + logic
*/
import { stdin } from 'process';
import { SessionStore } from '../services/sqlite/SessionStore.js';
import { ensureWorkerRunning } from '../shared/worker-utils.js';
export interface SessionEndInput {
session_id: string;
cwd: string;
transcript_path?: string;
hook_event_name: string;
reason: 'exit' | 'clear' | 'logout' | 'prompt_input_exit' | 'other';
}
/**
* Cleanup Hook Main Logic
*/
async function cleanupHook(input?: SessionEndInput): Promise<void> {
// Log hook entry point
console.error('[claude-mem cleanup] Hook fired', {
input: input ? {
session_id: input.session_id,
cwd: input.cwd,
reason: input.reason
} : null
});
// Handle standalone execution (no input provided)
if (!input) {
console.log('No input provided - this script is designed to run as a Claude Code SessionEnd hook');
console.log('\nExpected input format:');
console.log(JSON.stringify({
session_id: "string",
cwd: "string",
transcript_path: "string",
hook_event_name: "SessionEnd",
reason: "exit"
}, null, 2));
process.exit(0);
}
const { session_id, reason } = input;
console.error('[claude-mem cleanup] Searching for active SDK session', { session_id, reason });
// Ensure worker is running first
const workerReady = await ensureWorkerRunning();
if (!workerReady) {
console.error('[claude-mem cleanup] Worker not available - skipping HTTP cleanup');
}
// Find active SDK session
const db = new SessionStore();
const session = db.findActiveSDKSession(session_id);
if (!session) {
// No active session - nothing to clean up
console.error('[claude-mem cleanup] No active SDK session found', { session_id });
db.close();
console.log('{"continue": true, "suppressOutput": true}');
process.exit(0);
}
console.error('[claude-mem cleanup] Active SDK session found', {
session_id: session.id,
sdk_session_id: session.sdk_session_id,
project: session.project,
worker_port: session.worker_port
});
// Mark session as completed in DB
db.markSessionCompleted(session.id);
console.error('[claude-mem cleanup] Session marked as completed in database');
db.close();
console.error('[claude-mem cleanup] Cleanup completed successfully');
console.log('{"continue": true, "suppressOutput": true}');
process.exit(0);
}
// Entry Point
if (stdin.isTTY) {
// Running manually
cleanupHook(undefined);
} else {
let input = '';
stdin.on('data', (chunk) => input += chunk);
stdin.on('end', async () => {
const parsed = input ? JSON.parse(input) : undefined;
await cleanupHook(parsed);
});
}
-98
View File
@@ -1,98 +0,0 @@
import { SessionStore } from '../services/sqlite/SessionStore.js';
import { ensureWorkerRunning } from '../shared/worker-utils.js';
export interface SessionEndInput {
session_id: string;
cwd: string;
transcript_path?: string;
hook_event_name: string;
reason: 'exit' | 'clear' | 'logout' | 'prompt_input_exit' | 'other';
}
/**
* Cleanup Hook - SessionEnd
* Marks session as completed when Claude Code session ends
*
* This hook runs when a Claude Code session ends. It:
* 1. Finds active SDK session for this Claude session
* 2. Marks session as completed in database
* 3. Allows worker to finish pending operations naturally
*/
export async function cleanupHook(input?: SessionEndInput): Promise<void> {
try {
// Log hook entry point
console.error('[claude-mem cleanup] Hook fired', {
input: input ? {
session_id: input.session_id,
cwd: input.cwd,
reason: input.reason
} : null
});
// Handle standalone execution (no input provided)
if (!input) {
console.log('No input provided - this script is designed to run as a Claude Code SessionEnd hook');
console.log('\nExpected input format:');
console.log(JSON.stringify({
session_id: "string",
cwd: "string",
transcript_path: "string",
hook_event_name: "SessionEnd",
reason: "exit"
}, null, 2));
process.exit(0);
}
const { session_id, reason } = input;
console.error('[claude-mem cleanup] Searching for active SDK session', { session_id, reason });
// Ensure worker is running first (runs cleanup if restarting)
const workerReady = await ensureWorkerRunning();
if (!workerReady) {
console.error('[claude-mem cleanup] Worker not available - skipping HTTP cleanup');
}
// Find active SDK session
const db = new SessionStore();
const session = db.findActiveSDKSession(session_id);
if (!session) {
// No active session - nothing to clean up
console.error('[claude-mem cleanup] No active SDK session found', { session_id });
db.close();
console.log('{"continue": true, "suppressOutput": true}');
process.exit(0);
}
console.error('[claude-mem cleanup] Active SDK session found', {
session_id: session.id,
sdk_session_id: session.sdk_session_id,
project: session.project,
worker_port: session.worker_port
});
// 1. Mark session as completed in DB (if not already completed)
try {
db.markSessionCompleted(session.id);
console.error('[claude-mem cleanup] Session marked as completed in database');
} catch (markErr: any) {
console.error('[claude-mem cleanup] Failed to mark session as completed:', markErr);
}
db.close();
console.error('[claude-mem cleanup] Cleanup completed successfully');
console.log('{"continue": true, "suppressOutput": true}');
process.exit(0);
} catch (error: any) {
// On error, don't block Claude Code exit
console.error('[claude-mem cleanup] Unexpected error in hook', {
error: error.message,
stack: error.stack,
name: error.name
});
console.log('{"continue": true, "suppressOutput": true}');
process.exit(0);
}
}
+440
View File
@@ -0,0 +1,440 @@
#!/usr/bin/env node
/**
* Context Hook - SessionStart
* Consolidated entry point + logic (no try-catch bullshit)
*/
import path from 'path';
import { stdin } from 'process';
import { SessionStore } from '../services/sqlite/SessionStore.js';
import { ensureWorkerRunning } from '../shared/worker-utils.js';
export interface SessionStartInput {
session_id?: string;
transcript_path?: string;
cwd?: string;
hook_event_name?: string;
source?: "startup" | "resume" | "clear" | "compact";
[key: string]: any;
}
// ANSI color codes for terminal output
const colors = {
reset: '\x1b[0m',
bright: '\x1b[1m',
dim: '\x1b[2m',
cyan: '\x1b[36m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
magenta: '\x1b[35m',
gray: '\x1b[90m',
red: '\x1b[31m',
};
interface Observation {
id: number;
sdk_session_id: string;
type: string;
title: string | null;
subtitle: string | null;
narrative: string | null;
facts: string | null;
concepts: string | null;
files_read: string | null;
files_modified: string | null;
created_at: string;
created_at_epoch: number;
}
// Helper: Parse JSON array safely
function parseJsonArray(json: string | null): string[] {
if (!json) return [];
const parsed = JSON.parse(json);
return Array.isArray(parsed) ? parsed : [];
}
// Helper: Format date with time
function formatDateTime(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true
});
}
// Helper: Format just time (no date)
function formatTime(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
});
}
// Helper: Format just date
function formatDate(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
}
// Helper: Estimate token count for text
function estimateTokens(text: string | null): number {
if (!text) return 0;
// Rough estimate: ~4 characters per token
return Math.ceil(text.length / 4);
}
// Helper: Convert absolute paths to relative paths
function toRelativePath(filePath: string, cwd: string): string {
if (path.isAbsolute(filePath)) {
return path.relative(cwd, filePath);
}
return filePath;
}
// Helper: Get all observations for given sessions
function getObservations(db: SessionStore, sessionIds: string[]): Observation[] {
if (sessionIds.length === 0) return [];
const placeholders = sessionIds.map(() => '?').join(',');
const observations = db.db.prepare(`
SELECT
id, sdk_session_id, type, title, subtitle, narrative,
facts, concepts, files_read, files_modified,
created_at, created_at_epoch
FROM observations
WHERE sdk_session_id IN (${placeholders})
ORDER BY created_at_epoch DESC
`).all(...sessionIds) as Observation[];
return observations;
}
/**
* Context Hook Main Logic
*/
function contextHook(input?: SessionStartInput, useColors: boolean = false, useIndexView: boolean = false): string {
ensureWorkerRunning();
const cwd = input?.cwd ?? process.cwd();
const project = cwd ? path.basename(cwd) : 'unknown-project';
const db = new SessionStore();
// Get last 4 summaries (use 4th for offset calculation)
const recentSummaries = db.db.prepare(`
SELECT id, sdk_session_id, request, completed, next_steps, created_at, created_at_epoch
FROM session_summaries
WHERE project = ?
ORDER BY created_at_epoch DESC
LIMIT 4
`).all(project) as Array<{ id: number; sdk_session_id: string; request: string | null; completed: string | null; next_steps: string | null; created_at: string; created_at_epoch: number }>;
if (recentSummaries.length === 0) {
db.close();
if (useColors) {
return `\n${colors.bright}${colors.cyan}📝 [${project}] recent context${colors.reset}\n${colors.gray}${'─'.repeat(60)}${colors.reset}\n\n${colors.dim}No previous sessions found for this project yet.${colors.reset}\n`;
}
return `# [${project}] recent context\n\nNo previous sessions found for this project yet.`;
}
// Extract unique session IDs from first 3 summaries
const displaySummaries = recentSummaries.slice(0, 3);
const sessionIds = [...new Set(displaySummaries.map(s => s.sdk_session_id))];
// Get all observations from these sessions
const observations = getObservations(db, sessionIds);
// Filter observations by key concepts for timeline
const timelineObs = observations.filter(obs => {
const concepts = parseJsonArray(obs.concepts);
return concepts.includes('what-changed') ||
concepts.includes('how-it-works') ||
concepts.includes('problem-solution') ||
concepts.includes('gotcha') ||
concepts.includes('discovery') ||
concepts.includes('why-it-exists') ||
concepts.includes('decision') ||
concepts.includes('trade-off');
});
// Build output
const output: string[] = [];
// Header
if (useColors) {
output.push('');
output.push(`${colors.bright}${colors.cyan}📝 [${project}] recent context${colors.reset}`);
output.push(`${colors.gray}${'─'.repeat(60)}${colors.reset}`);
output.push('');
} else {
output.push(`# [${project}] recent context`);
output.push('');
}
// Chronological Timeline
if (timelineObs.length > 0) {
// Legend/Key
if (useColors) {
output.push(`${colors.dim}Legend: 🎯 session-request | 🔴 gotcha | 🟡 problem-solution | 🔵 how-it-works | 🟢 what-changed | 🟣 discovery | 🟠 why-it-exists | 🟤 decision | ⚖️ trade-off${colors.reset}`);
output.push('');
} else {
output.push(`**Legend:** 🎯 session-request | 🔴 gotcha | 🟡 problem-solution | 🔵 how-it-works | 🟢 what-changed | 🟣 discovery | 🟠 why-it-exists | 🟤 decision | ⚖️ trade-off`);
output.push('');
}
// Progressive Disclosure Usage Instructions
if (useColors) {
output.push(`${colors.dim}💡 Progressive Disclosure: This index shows WHAT exists (titles) and retrieval COST (token counts).${colors.reset}`);
output.push(`${colors.dim} → Use MCP search tools to fetch full observation details on-demand (Layer 2)${colors.reset}`);
output.push(`${colors.dim} → Prefer searching observations over re-reading code for past decisions and learnings${colors.reset}`);
output.push(`${colors.dim} → Critical types (🔴 gotcha, 🟤 decision, ⚖️ trade-off) often worth fetching immediately${colors.reset}`);
output.push('');
} else {
output.push(`💡 **Progressive Disclosure:** This index shows WHAT exists (titles) and retrieval COST (token counts).`);
output.push(`- Use MCP search tools to fetch full observation details on-demand (Layer 2)`);
output.push(`- Prefer searching observations over re-reading code for past decisions and learnings`);
output.push(`- Critical types (🔴 gotcha, 🟤 decision, ⚖️ trade-off) often worth fetching immediately`);
output.push('');
}
// Create unified timeline with both observations and summaries
const mostRecentSummaryId = recentSummaries[0]?.id;
// Create offset summaries
const summariesWithOffset = displaySummaries.map((summary, i) => {
// Most recent keeps its own time, others offset to next summary's time
const nextSummary = i === 0 ? null : recentSummaries[i + 1];
return {
...summary,
displayEpoch: nextSummary ? nextSummary.created_at_epoch : summary.created_at_epoch,
displayTime: nextSummary ? nextSummary.created_at : summary.created_at,
isMostRecent: summary.id === mostRecentSummaryId
};
});
type TimelineItem =
| { type: 'observation'; data: Observation }
| { type: 'summary'; data: typeof summariesWithOffset[0] };
const timeline: TimelineItem[] = [
...timelineObs.map(obs => ({ type: 'observation' as const, data: obs })),
...summariesWithOffset.map(summary => ({ type: 'summary' as const, data: summary }))
];
// Sort chronologically
timeline.sort((a, b) => {
const aEpoch = a.type === 'observation' ? a.data.created_at_epoch : a.data.displayEpoch;
const bEpoch = b.type === 'observation' ? b.data.created_at_epoch : b.data.displayEpoch;
return aEpoch - bEpoch;
});
// Group by day for rendering
const dayTimelines = new Map<string, typeof timeline>();
for (const item of timeline) {
const itemDate = item.type === 'observation' ? item.data.created_at : item.data.displayTime;
const day = formatDate(itemDate);
if (!dayTimelines.has(day)) {
dayTimelines.set(day, []);
}
dayTimelines.get(day)!.push(item);
}
// Sort days chronologically
const sortedDays = Array.from(dayTimelines.entries()).sort((a, b) => {
const aDate = new Date(a[0]).getTime();
const bDate = new Date(b[0]).getTime();
return aDate - bDate;
});
// Render each day's timeline
for (const [day, dayItems] of sortedDays) {
// Day header
if (useColors) {
output.push(`${colors.bright}${colors.cyan}${day}${colors.reset}`);
output.push('');
} else {
output.push(`### ${day}`);
output.push('');
}
// Render items chronologically with visual file grouping
let currentFile: string | null = null;
let lastTime = '';
let tableOpen = false;
for (const item of dayItems) {
if (item.type === 'summary') {
// Close any open table
if (tableOpen) {
output.push('');
tableOpen = false;
currentFile = null;
lastTime = '';
}
// Render summary
const summary = item.data;
const summaryTitle = `${summary.request || 'Session started'} (${formatDateTime(summary.displayTime)})`;
const link = summary.isMostRecent ? '' : `claude-mem://session-summary/${summary.id}`;
if (useColors) {
const linkPart = link ? `${colors.dim}[${link}]${colors.reset}` : '';
output.push(`🎯 ${colors.yellow}#S${summary.id}${colors.reset} ${summaryTitle} ${linkPart}`);
} else {
const linkPart = link ? ` [→](${link})` : '';
output.push(`**🎯 #S${summary.id}** ${summaryTitle}${linkPart}`);
}
output.push('');
} else {
// Render observation
const obs = item.data;
const files = parseJsonArray(obs.files_modified);
const file = files.length > 0 ? toRelativePath(files[0], cwd) : 'General';
// Check if we need a new file section
if (file !== currentFile) {
// Close previous table
if (tableOpen) {
output.push('');
}
// File header
if (useColors) {
output.push(`${colors.dim}${file}${colors.reset}`);
} else {
output.push(`**${file}**`);
}
// Table header (markdown only)
if (!useColors) {
output.push(`| ID | Time | T | Title | Tokens |`);
output.push(`|----|------|---|-------|--------|`);
}
currentFile = file;
tableOpen = true;
lastTime = '';
}
// Render observation row
const concepts = parseJsonArray(obs.concepts);
let icon = '•';
// Priority order: gotcha > decision > trade-off > problem-solution > discovery > why-it-exists > how-it-works > what-changed
if (concepts.includes('gotcha')) {
icon = '🔴';
} else if (concepts.includes('decision')) {
icon = '🟤';
} else if (concepts.includes('trade-off')) {
icon = '⚖️';
} else if (concepts.includes('problem-solution')) {
icon = '🟡';
} else if (concepts.includes('discovery')) {
icon = '🟣';
} else if (concepts.includes('why-it-exists')) {
icon = '🟠';
} else if (concepts.includes('how-it-works')) {
icon = '🔵';
} else if (concepts.includes('what-changed')) {
icon = '🟢';
}
const time = formatTime(obs.created_at);
const title = obs.title || 'Untitled';
const tokens = estimateTokens(obs.narrative);
const showTime = time !== lastTime;
const timeDisplay = showTime ? time : '';
lastTime = time;
if (useColors) {
const timePart = showTime ? `${colors.dim}${time}${colors.reset}` : ' '.repeat(time.length);
const tokensPart = tokens > 0 ? `${colors.dim}(~${tokens}t)${colors.reset}` : '';
output.push(` ${colors.dim}#${obs.id}${colors.reset} ${timePart} ${icon} ${title} ${tokensPart}`);
} else {
output.push(`| #${obs.id} | ${timeDisplay || '″'} | ${icon} | ${title} | ~${tokens} |`);
}
}
}
// Close final table if open
if (tableOpen) {
output.push('');
}
}
// Add full summary details for most recent session
const mostRecentSummary = recentSummaries[0];
if (mostRecentSummary && (mostRecentSummary.completed || mostRecentSummary.next_steps)) {
if (mostRecentSummary.completed) {
if (useColors) {
output.push(`${colors.green}Completed:${colors.reset} ${mostRecentSummary.completed}`);
} else {
output.push(`**Completed**: ${mostRecentSummary.completed}`);
}
output.push('');
}
if (mostRecentSummary.next_steps) {
if (useColors) {
output.push(`${colors.magenta}Next Steps:${colors.reset} ${mostRecentSummary.next_steps}`);
} else {
output.push(`**Next Steps**: ${mostRecentSummary.next_steps}`);
}
output.push('');
}
}
// Footer with MCP search instructions
if (useColors) {
output.push(`${colors.dim}Use claude-mem MCP search to access records with the given ID${colors.reset}`);
} else {
output.push(`*Use claude-mem MCP search to access records with the given ID*`);
}
output.push('');
}
// Footer
if (useColors) {
output.push(`${colors.gray}${'─'.repeat(60)}${colors.reset}`);
output.push('');
}
db.close();
return output.join('\n');
}
// Entry Point - handle stdin/stdout
const useIndexView = process.argv.includes('--index');
if (stdin.isTTY) {
// Running manually from terminal - print formatted output with colors
const contextOutput = contextHook(undefined, true, useIndexView);
console.log(contextOutput);
process.exit(0);
} else {
// Running from hook - wrap in hookSpecificOutput JSON format
let input = '';
stdin.on('data', (chunk) => input += chunk);
stdin.on('end', () => {
const parsed = input.trim() ? JSON.parse(input) : undefined;
const contextOutput = contextHook(parsed, false, useIndexView);
const result = {
hookSpecificOutput: {
hookEventName: "SessionStart",
additionalContext: contextOutput
}
};
console.log(JSON.stringify(result));
process.exit(0);
});
}
-449
View File
@@ -1,449 +0,0 @@
import path from 'path';
import { SessionStore } from '../services/sqlite/SessionStore.js';
import { ensureWorkerRunning } from '../shared/worker-utils.js';
export interface SessionStartInput {
session_id?: string;
transcript_path?: string;
cwd?: string;
hook_event_name?: string;
source?: "startup" | "resume" | "clear" | "compact";
[key: string]: any;
}
// ANSI color codes for terminal output
const colors = {
reset: '\x1b[0m',
bright: '\x1b[1m',
dim: '\x1b[2m',
cyan: '\x1b[36m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
magenta: '\x1b[35m',
gray: '\x1b[90m',
red: '\x1b[31m',
};
interface Observation {
id: number;
sdk_session_id: string;
type: string;
title: string | null;
subtitle: string | null;
narrative: string | null;
facts: string | null;
concepts: string | null;
files_read: string | null;
files_modified: string | null;
created_at: string;
created_at_epoch: number;
}
/**
* Helper: Parse JSON array safely
*/
function parseJsonArray(json: string | null): string[] {
if (!json) return [];
try {
const parsed = JSON.parse(json);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
/**
* Helper: Format date with time
*/
function formatDateTime(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true
});
}
/**
* Helper: Format just time (no date)
*/
function formatTime(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
});
}
/**
* Helper: Format just date
*/
function formatDate(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
}
/**
* Helper: Estimate token count for text
*/
function estimateTokens(text: string | null): number {
if (!text) return 0;
// Rough estimate: ~4 characters per token
return Math.ceil(text.length / 4);
}
/**
* Helper: Convert absolute paths to relative paths
*/
function toRelativePath(filePath: string, cwd: string): string {
try {
if (path.isAbsolute(filePath)) {
return path.relative(cwd, filePath);
}
return filePath;
} catch {
return filePath;
}
}
/**
* Helper: Get recent session IDs for a project
*/
function getRecentSessionIds(db: SessionStore, project: string, limit: number = 3): string[] {
const sessions = db.db.prepare(`
SELECT sdk_session_id
FROM sdk_sessions
WHERE project = ? AND sdk_session_id IS NOT NULL
ORDER BY started_at_epoch DESC
LIMIT ?
`).all(project, limit) as Array<{ sdk_session_id: string }>;
return sessions.map(s => s.sdk_session_id);
}
/**
* Helper: Get all observations for given sessions
*/
function getObservations(db: SessionStore, sessionIds: string[]): Observation[] {
if (sessionIds.length === 0) return [];
const placeholders = sessionIds.map(() => '?').join(',');
const observations = db.db.prepare(`
SELECT
id, sdk_session_id, type, title, subtitle, narrative,
facts, concepts, files_read, files_modified,
created_at, created_at_epoch
FROM observations
WHERE sdk_session_id IN (${placeholders})
ORDER BY created_at_epoch DESC
`).all(...sessionIds) as Observation[];
return observations;
}
/**
* Context Hook - SessionStart
* Shows user what happened in recent sessions
*/
export function contextHook(input?: SessionStartInput, useColors: boolean = false, useIndexView: boolean = false): string {
ensureWorkerRunning();
const cwd = input?.cwd ?? process.cwd();
const project = cwd ? path.basename(cwd) : 'unknown-project';
const db = new SessionStore();
try {
// Get last 4 summaries (use 4th for offset calculation)
const recentSummaries = db.db.prepare(`
SELECT id, sdk_session_id, request, completed, next_steps, created_at, created_at_epoch
FROM session_summaries
WHERE project = ?
ORDER BY created_at_epoch DESC
LIMIT 4
`).all(project) as Array<{ id: number; sdk_session_id: string; request: string | null; completed: string | null; next_steps: string | null; created_at: string; created_at_epoch: number }>;
if (recentSummaries.length === 0) {
if (useColors) {
return `\n${colors.bright}${colors.cyan}📝 [${project}] recent context${colors.reset}\n${colors.gray}${'─'.repeat(60)}${colors.reset}\n\n${colors.dim}No previous sessions found for this project yet.${colors.reset}\n`;
}
return `# [${project}] recent context\n\nNo previous sessions found for this project yet.`;
}
// Extract unique session IDs from first 3 summaries
const displaySummaries = recentSummaries.slice(0, 3);
const sessionIds = [...new Set(displaySummaries.map(s => s.sdk_session_id))];
// Get all observations from these sessions
const observations = getObservations(db, sessionIds);
// Filter observations by key concepts for timeline
const timelineObs = observations.filter(obs => {
const concepts = parseJsonArray(obs.concepts);
return concepts.includes('what-changed') ||
concepts.includes('how-it-works') ||
concepts.includes('problem-solution') ||
concepts.includes('gotcha') ||
concepts.includes('discovery') ||
concepts.includes('why-it-exists') ||
concepts.includes('decision') ||
concepts.includes('trade-off');
});
// Build output
const output: string[] = [];
// Header
if (useColors) {
output.push('');
output.push(`${colors.bright}${colors.cyan}📝 [${project}] recent context${colors.reset}`);
output.push(`${colors.gray}${'─'.repeat(60)}${colors.reset}`);
output.push('');
} else {
output.push(`# [${project}] recent context`);
output.push('');
}
// Chronological Timeline
if (timelineObs.length > 0) {
// Legend/Key
if (useColors) {
output.push(`${colors.dim}Legend: 🎯 session-request | 🔴 gotcha | 🟡 problem-solution | 🔵 how-it-works | 🟢 what-changed | 🟣 discovery | 🟠 why-it-exists | 🟤 decision | ⚖️ trade-off${colors.reset}`);
output.push('');
} else {
output.push(`**Legend:** 🎯 session-request | 🔴 gotcha | 🟡 problem-solution | 🔵 how-it-works | 🟢 what-changed | 🟣 discovery | 🟠 why-it-exists | 🟤 decision | ⚖️ trade-off`);
output.push('');
}
// Progressive Disclosure Usage Instructions
if (useColors) {
output.push(`${colors.dim}💡 Progressive Disclosure: This index shows WHAT exists (titles) and retrieval COST (token counts).${colors.reset}`);
output.push(`${colors.dim} → Use MCP search tools to fetch full observation details on-demand (Layer 2)${colors.reset}`);
output.push(`${colors.dim} → Prefer searching observations over re-reading code for past decisions and learnings${colors.reset}`);
output.push(`${colors.dim} → Critical types (🔴 gotcha, 🟤 decision, ⚖️ trade-off) often worth fetching immediately${colors.reset}`);
output.push('');
} else {
output.push(`💡 **Progressive Disclosure:** This index shows WHAT exists (titles) and retrieval COST (token counts).`);
output.push(`- Use MCP search tools to fetch full observation details on-demand (Layer 2)`);
output.push(`- Prefer searching observations over re-reading code for past decisions and learnings`);
output.push(`- Critical types (🔴 gotcha, 🟤 decision, ⚖️ trade-off) often worth fetching immediately`);
output.push('');
}
// Create unified timeline with both observations and summaries
const mostRecentSummaryId = recentSummaries[0]?.id;
// Create offset summaries (displaySummaries already defined at top)
const summariesWithOffset = displaySummaries.map((summary, i) => {
// Most recent keeps its own time, others offset to next summary's time
const nextSummary = i === 0 ? null : recentSummaries[i + 1];
return {
...summary,
displayEpoch: nextSummary ? nextSummary.created_at_epoch : summary.created_at_epoch,
displayTime: nextSummary ? nextSummary.created_at : summary.created_at,
isMostRecent: summary.id === mostRecentSummaryId
};
});
type TimelineItem =
| { type: 'observation'; data: Observation }
| { type: 'summary'; data: typeof summariesWithOffset[0] };
const timeline: TimelineItem[] = [
...timelineObs.map(obs => ({ type: 'observation' as const, data: obs })),
...summariesWithOffset.map(summary => ({ type: 'summary' as const, data: summary }))
];
// Sort chronologically
timeline.sort((a, b) => {
const aEpoch = a.type === 'observation' ? a.data.created_at_epoch : a.data.displayEpoch;
const bEpoch = b.type === 'observation' ? b.data.created_at_epoch : b.data.displayEpoch;
return aEpoch - bEpoch;
});
// Group by day for rendering
const dayTimelines = new Map<string, typeof timeline>();
for (const item of timeline) {
const itemDate = item.type === 'observation' ? item.data.created_at : item.data.displayTime;
const day = formatDate(itemDate);
if (!dayTimelines.has(day)) {
dayTimelines.set(day, []);
}
dayTimelines.get(day)!.push(item);
}
// Sort days chronologically
const sortedDays = Array.from(dayTimelines.entries()).sort((a, b) => {
const aDate = new Date(a[0]).getTime();
const bDate = new Date(b[0]).getTime();
return aDate - bDate;
});
// Render each day's timeline
for (const [day, dayItems] of sortedDays) {
// Day header
if (useColors) {
output.push(`${colors.bright}${colors.cyan}${day}${colors.reset}`);
output.push('');
} else {
output.push(`### ${day}`);
output.push('');
}
// Render items chronologically with visual file grouping
let currentFile: string | null = null;
let lastTime = '';
let tableOpen = false;
for (const item of dayItems) {
if (item.type === 'summary') {
// Close any open table
if (tableOpen) {
output.push('');
tableOpen = false;
currentFile = null;
lastTime = '';
}
// Render summary
const summary = item.data;
const summaryTitle = `${summary.request || 'Session started'} (${formatDateTime(summary.displayTime)})`;
const link = summary.isMostRecent ? '' : `claude-mem://session-summary/${summary.id}`;
if (useColors) {
const linkPart = link ? `${colors.dim}[${link}]${colors.reset}` : '';
output.push(`🎯 ${colors.yellow}#S${summary.id}${colors.reset} ${summaryTitle} ${linkPart}`);
} else {
const linkPart = link ? ` [→](${link})` : '';
output.push(`**🎯 #S${summary.id}** ${summaryTitle}${linkPart}`);
}
output.push('');
} else {
// Render observation
const obs = item.data;
const files = parseJsonArray(obs.files_modified);
const file = files.length > 0 ? toRelativePath(files[0], cwd) : 'General';
// Check if we need a new file section
if (file !== currentFile) {
// Close previous table
if (tableOpen) {
output.push('');
}
// File header
if (useColors) {
output.push(`${colors.dim}${file}${colors.reset}`);
} else {
output.push(`**${file}**`);
}
// Table header (markdown only)
if (!useColors) {
output.push(`| ID | Time | T | Title | Tokens |`);
output.push(`|----|------|---|-------|--------|`);
}
currentFile = file;
tableOpen = true;
lastTime = '';
}
// Render observation row
const concepts = parseJsonArray(obs.concepts);
let icon = '•';
// Priority order: gotcha > decision > trade-off > problem-solution > discovery > why-it-exists > how-it-works > what-changed
if (concepts.includes('gotcha')) {
icon = '🔴';
} else if (concepts.includes('decision')) {
icon = '🟤';
} else if (concepts.includes('trade-off')) {
icon = '⚖️';
} else if (concepts.includes('problem-solution')) {
icon = '🟡';
} else if (concepts.includes('discovery')) {
icon = '🟣';
} else if (concepts.includes('why-it-exists')) {
icon = '🟠';
} else if (concepts.includes('how-it-works')) {
icon = '🔵';
} else if (concepts.includes('what-changed')) {
icon = '🟢';
}
const time = formatTime(obs.created_at);
const title = obs.title || 'Untitled';
const tokens = estimateTokens(obs.narrative);
const showTime = time !== lastTime;
const timeDisplay = showTime ? time : '';
lastTime = time;
if (useColors) {
const timePart = showTime ? `${colors.dim}${time}${colors.reset}` : ' '.repeat(time.length);
const tokensPart = tokens > 0 ? `${colors.dim}(~${tokens}t)${colors.reset}` : '';
output.push(` ${colors.dim}#${obs.id}${colors.reset} ${timePart} ${icon} ${title} ${tokensPart}`);
} else {
output.push(`| #${obs.id} | ${timeDisplay || '″'} | ${icon} | ${title} | ~${tokens} |`);
}
}
}
// Close final table if open
if (tableOpen) {
output.push('');
}
}
// Add full summary details for most recent session
const mostRecentSummary = recentSummaries[0];
if (mostRecentSummary && (mostRecentSummary.completed || mostRecentSummary.next_steps)) {
if (mostRecentSummary.completed) {
if (useColors) {
output.push(`${colors.green}Completed:${colors.reset} ${mostRecentSummary.completed}`);
} else {
output.push(`**Completed**: ${mostRecentSummary.completed}`);
}
output.push('');
}
if (mostRecentSummary.next_steps) {
if (useColors) {
output.push(`${colors.magenta}Next Steps:${colors.reset} ${mostRecentSummary.next_steps}`);
} else {
output.push(`**Next Steps**: ${mostRecentSummary.next_steps}`);
}
output.push('');
}
}
// Footer with MCP search instructions
if (useColors) {
output.push(`${colors.dim}Use claude-mem MCP search to access records with the given ID${colors.reset}`);
} else {
output.push(`*Use claude-mem MCP search to access records with the given ID*`);
}
output.push('');
}
// Footer
if (useColors) {
output.push(`${colors.gray}${'─'.repeat(60)}${colors.reset}`);
output.push('');
}
return output.join('\n');
} finally {
db.close();
}
}
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env node
/**
* New Hook - UserPromptSubmit
* Consolidated entry point + logic
*/
import path from 'path';
import { stdin } from 'process';
import { SessionStore } from '../services/sqlite/SessionStore.js';
import { createHookResponse } from './hook-response.js';
import { ensureWorkerRunning, getWorkerPort } from '../shared/worker-utils.js';
export interface UserPromptSubmitInput {
session_id: string;
cwd: string;
prompt: string;
[key: string]: any;
}
/**
* New Hook Main Logic
*/
async function newHook(input?: UserPromptSubmitInput): Promise<void> {
if (!input) {
throw new Error('newHook requires input');
}
const { session_id, cwd, prompt } = input;
const project = path.basename(cwd);
// Ensure worker is running first
const workerReady = await ensureWorkerRunning();
if (!workerReady) {
throw new Error('Worker service failed to start or become healthy');
}
const db = new SessionStore();
// Save session_id for indexing
const sessionDbId = db.createSDKSession(session_id, project, prompt);
const promptNumber = db.incrementPromptCounter(sessionDbId);
// Save raw user prompt for full-text search
db.saveUserPrompt(session_id, promptNumber, prompt);
console.error(`[new-hook] Session ${sessionDbId}, prompt #${promptNumber}`);
db.close();
// Get fixed port
const port = getWorkerPort();
// Initialize session via HTTP
const response = await fetch(`http://127.0.0.1:${port}/sessions/${sessionDbId}/init`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ project, userPrompt: prompt }),
signal: AbortSignal.timeout(5000)
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to initialize session: ${response.status} ${errorText}`);
}
console.log(createHookResponse('UserPromptSubmit', true));
}
// Entry Point
let input = '';
stdin.on('data', (chunk) => input += chunk);
stdin.on('end', async () => {
const parsed = input ? JSON.parse(input) : undefined;
await newHook(parsed);
});
-63
View File
@@ -1,63 +0,0 @@
import path from 'path';
import { SessionStore } from '../services/sqlite/SessionStore.js';
import { createHookResponse } from './hook-response.js';
import { ensureWorkerRunning, getWorkerPort } from '../shared/worker-utils.js';
export interface UserPromptSubmitInput {
session_id: string;
cwd: string;
prompt: string;
[key: string]: any;
}
/**
* New Hook - UserPromptSubmit
* Initializes SDK memory session via HTTP POST to worker service
*/
export async function newHook(input?: UserPromptSubmitInput): Promise<void> {
if (!input) {
throw new Error('newHook requires input');
}
const { session_id, cwd, prompt } = input;
const project = path.basename(cwd);
// Ensure worker is running first (runs cleanup if restarting)
const workerReady = await ensureWorkerRunning();
if (!workerReady) {
throw new Error('Worker service failed to start or become healthy');
}
const db = new SessionStore();
try {
// Just save session_id for indexing - no validation, no state management
const sessionDbId = db.createSDKSession(session_id, project, prompt);
const promptNumber = db.incrementPromptCounter(sessionDbId);
// Save raw user prompt for full-text search
db.saveUserPrompt(session_id, promptNumber, prompt);
console.error(`[new-hook] Session ${sessionDbId}, prompt #${promptNumber}`);
// Get fixed port
const port = getWorkerPort();
// Initialize session via HTTP
const response = await fetch(`http://127.0.0.1:${port}/sessions/${sessionDbId}/init`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ project, userPrompt: prompt }),
signal: AbortSignal.timeout(5000)
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to initialize session: ${response.status} ${errorText}`);
}
console.log(createHookResponse('UserPromptSubmit', true));
} finally {
db.close();
}
}
+21 -7
View File
@@ -1,3 +1,10 @@
#!/usr/bin/env node
/**
* Save Hook - PostToolUse
* Consolidated entry point + logic
*/
import { stdin } from 'process';
import { SessionStore } from '../services/sqlite/SessionStore.js';
import { createHookResponse } from './hook-response.js';
import { logger } from '../utils/logger.js';
@@ -18,10 +25,9 @@ const SKIP_TOOLS = new Set([
]);
/**
* Save Hook - PostToolUse
* Sends tool observations to worker via HTTP POST
* Save Hook Main Logic
*/
export async function saveHook(input?: PostToolUseInput): Promise<void> {
async function saveHook(input?: PostToolUseInput): Promise<void> {
if (!input) {
throw new Error('saveHook requires input');
}
@@ -33,7 +39,7 @@ export async function saveHook(input?: PostToolUseInput): Promise<void> {
return;
}
// Ensure worker is running first (runs cleanup if restarting)
// Ensure worker is running first
const workerReady = await ensureWorkerRunning();
if (!workerReady) {
throw new Error('Worker service failed to start or become healthy');
@@ -41,14 +47,14 @@ export async function saveHook(input?: PostToolUseInput): Promise<void> {
const db = new SessionStore();
// Get or create session - no validation, just use the session_id from hook
const sessionDbId = db.createSDKSession(session_id, '', ''); // project and prompt not needed for observations
// Get or create session
const sessionDbId = db.createSDKSession(session_id, '', '');
const promptNumber = db.getPromptCounter(sessionDbId);
db.close();
const toolStr = logger.formatTool(tool_name, tool_input);
// Use fixed worker port - no session.worker_port validation needed
// Use fixed worker port
const FIXED_PORT = parseInt(process.env.CLAUDE_MEM_WORKER_PORT || '37777', 10);
logger.dataIn('HOOK', `PostToolUse: ${toolStr}`, {
@@ -80,3 +86,11 @@ export async function saveHook(input?: PostToolUseInput): Promise<void> {
logger.debug('HOOK', 'Observation sent successfully', { sessionId: sessionDbId, toolName: tool_name });
console.log(createHookResponse('PostToolUse', true));
}
// Entry Point
let input = '';
stdin.on('data', (chunk) => input += chunk);
stdin.on('end', async () => {
const parsed = input ? JSON.parse(input) : undefined;
await saveHook(parsed);
});
@@ -1,3 +1,10 @@
#!/usr/bin/env node
/**
* Summary Hook - Stop
* Consolidated entry point + logic
*/
import { stdin } from 'process';
import { SessionStore } from '../services/sqlite/SessionStore.js';
import { createHookResponse } from './hook-response.js';
import { logger } from '../utils/logger.js';
@@ -10,17 +17,16 @@ export interface StopInput {
}
/**
* Summary Hook - Stop
* Sends SUMMARIZE message to worker via HTTP POST (not finalize - keeps SDK agent running)
* Summary Hook Main Logic
*/
export async function summaryHook(input?: StopInput): Promise<void> {
async function summaryHook(input?: StopInput): Promise<void> {
if (!input) {
throw new Error('summaryHook requires input');
}
const { session_id } = input;
// Ensure worker is running first (runs cleanup if restarting)
// Ensure worker is running first
const workerReady = await ensureWorkerRunning();
if (!workerReady) {
throw new Error('Worker service failed to start or become healthy');
@@ -28,12 +34,12 @@ export async function summaryHook(input?: StopInput): Promise<void> {
const db = new SessionStore();
// Get or create session - no validation, just use the session_id from hook
// Get or create session
const sessionDbId = db.createSDKSession(session_id, '', '');
const promptNumber = db.getPromptCounter(sessionDbId);
db.close();
// Use fixed worker port - no session.worker_port validation needed
// Use fixed worker port
const FIXED_PORT = parseInt(process.env.CLAUDE_MEM_WORKER_PORT || '37777', 10);
logger.dataIn('HOOK', 'Stop: Requesting summary', {
@@ -61,3 +67,11 @@ export async function summaryHook(input?: StopInput): Promise<void> {
logger.debug('HOOK', 'Summary request sent successfully', { sessionId: sessionDbId });
console.log(createHookResponse('Stop', true));
}
// Entry Point
let input = '';
stdin.on('data', (chunk) => input += chunk);
stdin.on('end', async () => {
const parsed = input ? JSON.parse(input) : undefined;
await summaryHook(parsed);
});