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:
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user