refactor: Convert all hooks to HTTP clients (remove all SQL)
Architecture transformation: Hooks → HTTP → Worker → Database **context-hook.ts** (843 → 104 lines, 88% reduction) - Remove all database imports and raw SQL queries - HTTP GET to /api/context/inject - Returns both formatted (stderr) and unformatted (stdout) context - Dual output: colored display for users, plain text for model **user-message-hook.ts** (updated, 113 lines) - HTTP GET to /api/context/inject with colors=true - Displays formatted context to users via stderr - No database dependencies **save-hook.ts** (418 → 99 lines, 76% reduction) - Remove all SessionStore database methods - HTTP POST to /api/sessions/observations - Worker handles privacy checks and observation creation - Fire-and-forget pattern with 2s timeout **summary-hook.ts** (435 → 200 lines, 54% reduction) - Remove all SessionStore database methods - Keep local transcript parsing (hook has file access) - HTTP POST to /api/sessions/summarize - Worker handles privacy checks and summary generation **cleanup-hook.ts** (414 → 90 lines, 78% reduction) - Remove all SessionStore database methods - HTTP POST to /api/sessions/complete - Worker handles session completion and DB cleanup - Non-fatal if worker unavailable **Benefits:** - Zero native module dependencies in hooks (Node.js or Bun compatible) - Hooks can run in any runtime without recompilation - All database operations centralized in worker service - Simpler, more maintainable hook code - Complete separation of concerns: I/O vs business logic
This commit is contained in:
+32
-44
@@ -1,11 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* Cleanup Hook - SessionEnd
|
* Cleanup Hook - SessionEnd
|
||||||
* Consolidated entry point + logic
|
*
|
||||||
|
* Pure HTTP client - sends data to worker, worker handles all database operations.
|
||||||
|
* This allows the hook to run under any runtime (Node.js or Bun) since it has no
|
||||||
|
* native module dependencies.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { stdin } from 'process';
|
import { stdin } from 'process';
|
||||||
import { SessionStore } from '../services/sqlite/SessionStore.js';
|
|
||||||
import { getWorkerPort } from '../shared/worker-utils.js';
|
import { getWorkerPort } from '../shared/worker-utils.js';
|
||||||
|
import { silentDebug } from '../utils/silent-debug.js';
|
||||||
|
|
||||||
export interface SessionEndInput {
|
export interface SessionEndInput {
|
||||||
session_id: string;
|
session_id: string;
|
||||||
@@ -16,16 +19,13 @@ export interface SessionEndInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cleanup Hook Main Logic
|
* Cleanup Hook Main Logic - Fire-and-forget HTTP client
|
||||||
*/
|
*/
|
||||||
async function cleanupHook(input?: SessionEndInput): Promise<void> {
|
async function cleanupHook(input?: SessionEndInput): Promise<void> {
|
||||||
// Log hook entry point
|
silentDebug('[cleanup-hook] Hook fired', {
|
||||||
console.error('[claude-mem cleanup] Hook fired', {
|
session_id: input?.session_id,
|
||||||
input: input ? {
|
cwd: input?.cwd,
|
||||||
session_id: input.session_id,
|
reason: input?.reason
|
||||||
cwd: input.cwd,
|
|
||||||
reason: input.reason
|
|
||||||
} : null
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle standalone execution (no input provided)
|
// Handle standalone execution (no input provided)
|
||||||
@@ -43,47 +43,35 @@ async function cleanupHook(input?: SessionEndInput): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { session_id, reason } = input;
|
const { session_id, reason } = input;
|
||||||
console.error('[claude-mem cleanup] Searching for active SDK session', { session_id, reason });
|
|
||||||
|
|
||||||
// Find active SDK session
|
const port = getWorkerPort();
|
||||||
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();
|
|
||||||
|
|
||||||
// Tell worker to stop spinner
|
|
||||||
try {
|
try {
|
||||||
const workerPort = session.worker_port || getWorkerPort();
|
// Send to worker - worker handles finding session, marking complete, and stopping spinner
|
||||||
await fetch(`http://127.0.0.1:${workerPort}/sessions/${session.id}/complete`, {
|
const response = await fetch(`http://127.0.0.1:${port}/api/sessions/complete`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
signal: AbortSignal.timeout(1000)
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
claudeSessionId: session_id,
|
||||||
|
reason
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(2000)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
silentDebug('[cleanup-hook] Session cleanup completed', result);
|
||||||
|
} else {
|
||||||
|
// Non-fatal - session might not exist
|
||||||
|
silentDebug('[cleanup-hook] Session not found or already cleaned up');
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
// Worker might not be running - that's okay
|
||||||
|
silentDebug('[cleanup-hook] Worker not reachable (non-critical)', {
|
||||||
|
error: error.message
|
||||||
});
|
});
|
||||||
console.error('[claude-mem cleanup] Worker notified to stop processing indicator');
|
|
||||||
} catch (err) {
|
|
||||||
// Non-critical - worker might be down
|
|
||||||
console.error('[claude-mem cleanup] Failed to notify worker (non-critical):', err);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error('[claude-mem cleanup] Cleanup completed successfully');
|
|
||||||
console.log('{"continue": true, "suppressOutput": true}');
|
console.log('{"continue": true, "suppressOutput": true}');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|||||||
+54
-787
@@ -1,111 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* Context Hook - SessionStart
|
* Context Hook - SessionStart
|
||||||
* Consolidated entry point + logic
|
*
|
||||||
|
* Pure HTTP client - calls worker to generate context.
|
||||||
|
* This allows the hook to run under any runtime (Node.js or Bun) since it has no
|
||||||
|
* native module dependencies.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { homedir } from 'os';
|
|
||||||
import { existsSync, readFileSync, unlinkSync } from 'fs';
|
|
||||||
import { stdin } from 'process';
|
import { stdin } from 'process';
|
||||||
import { fileURLToPath } from 'url';
|
import { getWorkerPort } from '../shared/worker-utils.js';
|
||||||
import { dirname } from 'path';
|
import { silentDebug } from '../utils/silent-debug.js';
|
||||||
import { SessionStore } from '../services/sqlite/SessionStore.js';
|
|
||||||
import {
|
|
||||||
OBSERVATION_TYPES,
|
|
||||||
OBSERVATION_CONCEPTS,
|
|
||||||
TYPE_ICON_MAP,
|
|
||||||
TYPE_WORK_EMOJI_MAP,
|
|
||||||
DEFAULT_OBSERVATION_TYPES_STRING,
|
|
||||||
DEFAULT_OBSERVATION_CONCEPTS_STRING
|
|
||||||
} from '../constants/observation-metadata.js';
|
|
||||||
import { logger } from '../utils/logger.js';
|
|
||||||
|
|
||||||
// Get __dirname equivalent in ESM
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = dirname(__filename);
|
|
||||||
|
|
||||||
// Version marker path (same as smart-install.js)
|
|
||||||
// From src/hooks/ we need to go up to plugin root: ../../
|
|
||||||
const VERSION_MARKER_PATH = path.join(__dirname, '../../.install-version');
|
|
||||||
|
|
||||||
interface ContextConfig {
|
|
||||||
// Display counts
|
|
||||||
totalObservationCount: number;
|
|
||||||
fullObservationCount: number;
|
|
||||||
sessionCount: number;
|
|
||||||
|
|
||||||
// Token display toggles
|
|
||||||
showReadTokens: boolean;
|
|
||||||
showWorkTokens: boolean;
|
|
||||||
showSavingsAmount: boolean;
|
|
||||||
showSavingsPercent: boolean;
|
|
||||||
|
|
||||||
// Filters
|
|
||||||
observationTypes: Set<string>;
|
|
||||||
observationConcepts: Set<string>;
|
|
||||||
|
|
||||||
// Display options
|
|
||||||
fullObservationField: 'narrative' | 'facts';
|
|
||||||
showLastSummary: boolean;
|
|
||||||
showLastMessage: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load all context configuration settings
|
|
||||||
* Priority: ~/.claude-mem/settings.json > env var > defaults
|
|
||||||
*/
|
|
||||||
function loadContextConfig(): ContextConfig {
|
|
||||||
const defaults = {
|
|
||||||
totalObservationCount: parseInt(process.env.CLAUDE_MEM_CONTEXT_OBSERVATIONS || '50', 10),
|
|
||||||
fullObservationCount: 5,
|
|
||||||
sessionCount: 10,
|
|
||||||
showReadTokens: true,
|
|
||||||
showWorkTokens: true,
|
|
||||||
showSavingsAmount: true,
|
|
||||||
showSavingsPercent: true,
|
|
||||||
observationTypes: new Set(OBSERVATION_TYPES),
|
|
||||||
observationConcepts: new Set(OBSERVATION_CONCEPTS),
|
|
||||||
fullObservationField: 'narrative' as const,
|
|
||||||
showLastSummary: true,
|
|
||||||
showLastMessage: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const settingsPath = path.join(homedir(), '.claude-mem', 'settings.json');
|
|
||||||
if (!existsSync(settingsPath)) return defaults;
|
|
||||||
|
|
||||||
const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
|
||||||
const env = settings.env || {};
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalObservationCount: parseInt(env.CLAUDE_MEM_CONTEXT_OBSERVATIONS || '50', 10),
|
|
||||||
fullObservationCount: parseInt(env.CLAUDE_MEM_CONTEXT_FULL_COUNT || '5', 10),
|
|
||||||
sessionCount: parseInt(env.CLAUDE_MEM_CONTEXT_SESSION_COUNT || '10', 10),
|
|
||||||
showReadTokens: env.CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS !== 'false',
|
|
||||||
showWorkTokens: env.CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS !== 'false',
|
|
||||||
showSavingsAmount: env.CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT !== 'false',
|
|
||||||
showSavingsPercent: env.CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_PERCENT !== 'false',
|
|
||||||
observationTypes: new Set(
|
|
||||||
(env.CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES || DEFAULT_OBSERVATION_TYPES_STRING)
|
|
||||||
.split(',').map((t: string) => t.trim()).filter(Boolean)
|
|
||||||
),
|
|
||||||
observationConcepts: new Set(
|
|
||||||
(env.CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS || DEFAULT_OBSERVATION_CONCEPTS_STRING)
|
|
||||||
.split(',').map((c: string) => c.trim()).filter(Boolean)
|
|
||||||
),
|
|
||||||
fullObservationField: (env.CLAUDE_MEM_CONTEXT_FULL_FIELD || 'narrative') as 'narrative' | 'facts',
|
|
||||||
showLastSummary: env.CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY !== 'false',
|
|
||||||
showLastMessage: env.CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE === 'true',
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn('HOOK', 'Failed to load context settings, using defaults', {}, error as Error);
|
|
||||||
return defaults;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configuration constants
|
|
||||||
const CHARS_PER_TOKEN_ESTIMATE = 4; // Rough estimate for token counting
|
|
||||||
const SUMMARY_LOOKAHEAD = 1; // Fetch one extra summary for offset calculation
|
|
||||||
|
|
||||||
export interface SessionStartInput {
|
export interface SessionStartInput {
|
||||||
session_id?: string;
|
session_id?: string;
|
||||||
@@ -116,719 +20,82 @@ export interface SessionStartInput {
|
|||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ANSI color codes for terminal output
|
/**
|
||||||
const colors = {
|
* Fetch context from worker
|
||||||
reset: '\x1b[0m',
|
*/
|
||||||
bright: '\x1b[1m',
|
async function fetchContext(project: string, port: number, useFormatting: boolean): Promise<string> {
|
||||||
dim: '\x1b[2m',
|
const formatParam = useFormatting ? '&colors=true' : '';
|
||||||
cyan: '\x1b[36m',
|
const response = await fetch(
|
||||||
green: '\x1b[32m',
|
`http://127.0.0.1:${port}/api/context/inject?project=${encodeURIComponent(project)}${formatParam}`,
|
||||||
yellow: '\x1b[33m',
|
{ method: 'GET', signal: AbortSignal.timeout(5000) }
|
||||||
blue: '\x1b[34m',
|
);
|
||||||
magenta: '\x1b[35m',
|
|
||||||
gray: '\x1b[90m',
|
|
||||||
red: '\x1b[31m',
|
|
||||||
};
|
|
||||||
|
|
||||||
interface Observation {
|
if (!response.ok) {
|
||||||
id: number;
|
const errorText = await response.text();
|
||||||
sdk_session_id: string;
|
throw new Error(`Worker error ${response.status}: ${errorText}`);
|
||||||
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;
|
|
||||||
discovery_tokens: number | null;
|
|
||||||
created_at: string;
|
|
||||||
created_at_epoch: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SessionSummary {
|
|
||||||
id: number;
|
|
||||||
sdk_session_id: string;
|
|
||||||
request: string | null;
|
|
||||||
investigated: string | null;
|
|
||||||
learned: string | null;
|
|
||||||
completed: string | null;
|
|
||||||
next_steps: 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 (err) {
|
|
||||||
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: 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: Render a summary field (investigated, learned, etc.)
|
|
||||||
function renderSummaryField(label: string, value: string | null, color: string, useColors: boolean): string[] {
|
|
||||||
if (!value) return [];
|
|
||||||
|
|
||||||
if (useColors) {
|
|
||||||
return [`${color}${label}:${colors.reset} ${value}`, ''];
|
|
||||||
}
|
|
||||||
return [`**${label}**: ${value}`, ''];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper: Convert cwd path to dashed format for transcript directory name
|
|
||||||
function cwdToDashed(cwd: string): string {
|
|
||||||
// Convert all slashes to dashes (including leading slash)
|
|
||||||
return cwd.replace(/\//g, '-');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper: Extract last assistant message from transcript file
|
|
||||||
function extractPriorMessages(transcriptPath: string): { userMessage: string; assistantMessage: string } {
|
|
||||||
try {
|
|
||||||
if (!existsSync(transcriptPath)) {
|
|
||||||
return { userMessage: '', assistantMessage: '' };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = readFileSync(transcriptPath, 'utf-8').trim();
|
return response.text();
|
||||||
if (!content) {
|
|
||||||
return { userMessage: '', assistantMessage: '' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = content.split('\n').filter(line => line.trim());
|
|
||||||
|
|
||||||
// Find the last assistant message by filtering for assistant type and taking the last one
|
|
||||||
let lastAssistantMessage = '';
|
|
||||||
|
|
||||||
// Iterate backwards to find the most recent assistant message with text content
|
|
||||||
for (let i = lines.length - 1; i >= 0; i--) {
|
|
||||||
try {
|
|
||||||
const line = lines[i];
|
|
||||||
|
|
||||||
// Quick check if this line is an assistant message
|
|
||||||
if (!line.includes('"type":"assistant"')) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const entry = JSON.parse(line);
|
|
||||||
|
|
||||||
if (entry.type === 'assistant' && entry.message?.content && Array.isArray(entry.message.content)) {
|
|
||||||
let text = '';
|
|
||||||
for (const block of entry.message.content) {
|
|
||||||
if (block.type === 'text') {
|
|
||||||
text += block.text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Remove system-reminder tags
|
|
||||||
text = text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim();
|
|
||||||
if (text) {
|
|
||||||
lastAssistantMessage = text;
|
|
||||||
break; // Found it, stop searching
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (parseError) {
|
|
||||||
// Skip malformed lines
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { userMessage: '', assistantMessage: lastAssistantMessage };
|
|
||||||
} catch (error) {
|
|
||||||
logger.failure('HOOK', `Failed to extract prior messages from transcript`, { transcriptPath }, error as Error);
|
|
||||||
return { userMessage: '', assistantMessage: '' };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Context Hook Main Logic
|
* Context Hook Main Logic - Fire-and-forget HTTP client
|
||||||
|
* Returns { unformatted, formatted } for dual output (stderr for user, stdout for model)
|
||||||
*/
|
*/
|
||||||
async function contextHook(input?: SessionStartInput, useColors: boolean = false): Promise<string> {
|
async function contextHook(input?: SessionStartInput): Promise<{ unformatted: string; formatted: string }> {
|
||||||
const config = loadContextConfig();
|
|
||||||
const cwd = input?.cwd ?? process.cwd();
|
const cwd = input?.cwd ?? process.cwd();
|
||||||
const project = cwd ? path.basename(cwd) : 'unknown-project';
|
const project = cwd ? path.basename(cwd) : 'unknown-project';
|
||||||
|
|
||||||
let db: SessionStore | null = null;
|
const port = getWorkerPort();
|
||||||
|
|
||||||
|
silentDebug('[context-hook] Requesting context from worker', {
|
||||||
|
project,
|
||||||
|
workerPort: port
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
db = new SessionStore();
|
// Fetch both versions in parallel
|
||||||
|
const [unformatted, formatted] = await Promise.all([
|
||||||
|
fetchContext(project, port, false),
|
||||||
|
fetchContext(project, port, true)
|
||||||
|
]);
|
||||||
|
|
||||||
|
silentDebug('[context-hook] Context received', { unformattedLength: unformatted.length, formattedLength: formatted.length });
|
||||||
|
return { unformatted, formatted };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.code === 'ERR_DLOPEN_FAILED') {
|
// Worker might not be running
|
||||||
// Native module ABI mismatch - delete version marker to trigger reinstall
|
silentDebug('[context-hook] Worker not reachable', { error: error.message });
|
||||||
try {
|
const fallback = `# [${project}] recent context\n\nWorker not available. Start with: pm2 start claude-mem-worker`;
|
||||||
unlinkSync(VERSION_MARKER_PATH);
|
return { unformatted: fallback, formatted: fallback };
|
||||||
} catch (unlinkError) {
|
|
||||||
// Marker might not exist, that's okay
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log once (not error spam) and exit cleanly
|
|
||||||
console.error('⚠️ Native module rebuild needed - restart Claude Code to auto-fix');
|
|
||||||
console.error(' (This happens after Node.js version upgrades)');
|
|
||||||
process.exit(0); // Exit cleanly to avoid error spam
|
|
||||||
}
|
|
||||||
|
|
||||||
// Other errors should still throw
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build SQL WHERE clause for observation types
|
|
||||||
const typeArray = Array.from(config.observationTypes);
|
|
||||||
const typePlaceholders = typeArray.map(() => '?').join(',');
|
|
||||||
|
|
||||||
// Build SQL WHERE clause for concepts
|
|
||||||
const conceptArray = Array.from(config.observationConcepts);
|
|
||||||
const conceptPlaceholders = conceptArray.map(() => '?').join(',');
|
|
||||||
|
|
||||||
// Get recent observations filtered by type and concepts at SQL level
|
|
||||||
// This ensures we show observations even when summaries haven't been generated
|
|
||||||
// Configurable via settings (default: 50)
|
|
||||||
const observations = db.db.prepare(`
|
|
||||||
SELECT
|
|
||||||
id, sdk_session_id, type, title, subtitle, narrative,
|
|
||||||
facts, concepts, files_read, files_modified, discovery_tokens,
|
|
||||||
created_at, created_at_epoch
|
|
||||||
FROM observations
|
|
||||||
WHERE project = ?
|
|
||||||
AND type IN (${typePlaceholders})
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT 1 FROM json_each(concepts)
|
|
||||||
WHERE value IN (${conceptPlaceholders})
|
|
||||||
)
|
|
||||||
ORDER BY created_at_epoch DESC
|
|
||||||
LIMIT ?
|
|
||||||
`).all(project, ...typeArray, ...conceptArray, config.totalObservationCount) as Observation[];
|
|
||||||
|
|
||||||
// Get recent summaries (optional - may not exist for recent sessions)
|
|
||||||
// Fetch one extra for offset calculation
|
|
||||||
const recentSummaries = db.db.prepare(`
|
|
||||||
SELECT id, sdk_session_id, request, investigated, learned, completed, next_steps, created_at, created_at_epoch
|
|
||||||
FROM session_summaries
|
|
||||||
WHERE project = ?
|
|
||||||
ORDER BY created_at_epoch DESC
|
|
||||||
LIMIT ?
|
|
||||||
`).all(project, config.sessionCount + SUMMARY_LOOKAHEAD) as SessionSummary[];
|
|
||||||
|
|
||||||
// Retrieve prior session messages if enabled
|
|
||||||
let priorUserMessage = '';
|
|
||||||
let priorAssistantMessage = '';
|
|
||||||
// let debugInfo: string[] = [];
|
|
||||||
|
|
||||||
if (config.showLastMessage && observations.length > 0) {
|
|
||||||
try {
|
|
||||||
const currentSessionId = input?.session_id;
|
|
||||||
|
|
||||||
// Find the first observation from a different session (the prior session)
|
|
||||||
const priorSessionObs = observations.find(obs => obs.sdk_session_id !== currentSessionId);
|
|
||||||
|
|
||||||
if (priorSessionObs) {
|
|
||||||
const priorSessionId = priorSessionObs.sdk_session_id;
|
|
||||||
|
|
||||||
// Construct transcript path: ~/.claude/projects/{dashed-cwd}/{session_id}.jsonl
|
|
||||||
const dashedCwd = cwdToDashed(cwd);
|
|
||||||
const transcriptPath = path.join(homedir(), '.claude', 'projects', dashedCwd, `${priorSessionId}.jsonl`);
|
|
||||||
|
|
||||||
// debugInfo.push(`📋 Prior Message Retrieval:`);
|
|
||||||
// debugInfo.push(` Session ID: ${priorSessionId}`);
|
|
||||||
// debugInfo.push(` Transcript: ${transcriptPath}`);
|
|
||||||
// debugInfo.push(` Exists: ${existsSync(transcriptPath)}`);
|
|
||||||
|
|
||||||
// Extract messages from transcript
|
|
||||||
const messages = extractPriorMessages(transcriptPath);
|
|
||||||
priorUserMessage = messages.userMessage;
|
|
||||||
priorAssistantMessage = messages.assistantMessage;
|
|
||||||
|
|
||||||
// if (!priorUserMessage && !priorAssistantMessage) {
|
|
||||||
// debugInfo.push(` ⚠️ No messages extracted from transcript`);
|
|
||||||
// } else {
|
|
||||||
// debugInfo.push(` ✅ Found user message: ${!!priorUserMessage}`);
|
|
||||||
// debugInfo.push(` ✅ Found assistant message: ${!!priorAssistantMessage}`);
|
|
||||||
// }
|
|
||||||
} // else {
|
|
||||||
// debugInfo.push(`📋 Prior Message Retrieval: No prior session found (all observations from current session)`);
|
|
||||||
// }
|
|
||||||
} catch (error) {
|
|
||||||
// debugInfo.push(`📋 Prior Message Retrieval Error: ${(error as Error).message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we have neither observations nor summaries, show empty state
|
|
||||||
if (observations.length === 0 && 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.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const displaySummaries = recentSummaries.slice(0, config.sessionCount);
|
|
||||||
|
|
||||||
// All filtered observations are shown in timeline
|
|
||||||
const timelineObs = observations;
|
|
||||||
|
|
||||||
// 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 | 🔴 bugfix | 🟣 feature | 🔄 refactor | ✅ change | 🔵 discovery | ⚖️ decision${colors.reset}`);
|
|
||||||
} else {
|
|
||||||
output.push(`**Legend:** 🎯 session-request | 🔴 bugfix | 🟣 feature | 🔄 refactor | ✅ change | 🔵 discovery | ⚖️ decision`);
|
|
||||||
}
|
|
||||||
output.push('');
|
|
||||||
|
|
||||||
// Column Key
|
|
||||||
if (useColors) {
|
|
||||||
output.push(`${colors.bright}💡 Column Key${colors.reset}`);
|
|
||||||
output.push(`${colors.dim} Read: Tokens to read this observation (cost to learn it now)${colors.reset}`);
|
|
||||||
output.push(`${colors.dim} Work: Tokens spent on work that produced this record (🔍 research, 🛠️ building, ⚖️ deciding)${colors.reset}`);
|
|
||||||
} else {
|
|
||||||
output.push(`💡 **Column Key**:`);
|
|
||||||
output.push(`- **Read**: Tokens to read this observation (cost to learn it now)`);
|
|
||||||
output.push(`- **Work**: Tokens spent on work that produced this record (🔍 research, 🛠️ building, ⚖️ deciding)`);
|
|
||||||
}
|
|
||||||
output.push('');
|
|
||||||
|
|
||||||
// Context Index Usage Instructions
|
|
||||||
if (useColors) {
|
|
||||||
output.push(`${colors.dim}💡 Context Index: This semantic index (titles, types, files, tokens) is usually sufficient to understand past work.${colors.reset}`);
|
|
||||||
output.push('');
|
|
||||||
output.push(`${colors.dim}When you need implementation details, rationale, or debugging context:${colors.reset}`);
|
|
||||||
output.push(`${colors.dim} - Use the mem-search skill to fetch full observations on-demand${colors.reset}`);
|
|
||||||
output.push(`${colors.dim} - Critical types (🔴 bugfix, ⚖️ decision) often need detailed fetching${colors.reset}`);
|
|
||||||
output.push(`${colors.dim} - Trust this index over re-reading code for past decisions and learnings${colors.reset}`);
|
|
||||||
} else {
|
|
||||||
output.push(`💡 **Context Index:** This semantic index (titles, types, files, tokens) is usually sufficient to understand past work.`);
|
|
||||||
output.push('');
|
|
||||||
output.push(`When you need implementation details, rationale, or debugging context:`);
|
|
||||||
output.push(`- Use the mem-search skill to fetch full observations on-demand`);
|
|
||||||
output.push(`- Critical types (🔴 bugfix, ⚖️ decision) often need detailed fetching`);
|
|
||||||
output.push(`- Trust this index over re-reading code for past decisions and learnings`);
|
|
||||||
}
|
|
||||||
output.push('');
|
|
||||||
|
|
||||||
// Section 1: Aggregate ROI Metrics
|
|
||||||
const totalObservations = observations.length;
|
|
||||||
const totalReadTokens = observations.reduce((sum, obs) => {
|
|
||||||
// Estimate read tokens from observation size
|
|
||||||
const obsSize = (obs.title?.length || 0) +
|
|
||||||
(obs.subtitle?.length || 0) +
|
|
||||||
(obs.narrative?.length || 0) +
|
|
||||||
JSON.stringify(obs.facts || []).length;
|
|
||||||
return sum + Math.ceil(obsSize / CHARS_PER_TOKEN_ESTIMATE);
|
|
||||||
}, 0);
|
|
||||||
const totalDiscoveryTokens = observations.reduce((sum, obs) => sum + (obs.discovery_tokens || 0), 0);
|
|
||||||
const savings = totalDiscoveryTokens - totalReadTokens;
|
|
||||||
const savingsPercent = totalDiscoveryTokens > 0
|
|
||||||
? Math.round((savings / totalDiscoveryTokens) * 100)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
// Display Context Economics section only if at least one token setting is enabled
|
|
||||||
const showContextEconomics = config.showReadTokens || config.showWorkTokens ||
|
|
||||||
config.showSavingsAmount || config.showSavingsPercent;
|
|
||||||
|
|
||||||
if (showContextEconomics) {
|
|
||||||
if (useColors) {
|
|
||||||
output.push(`${colors.bright}${colors.cyan}📊 Context Economics${colors.reset}`);
|
|
||||||
output.push(`${colors.dim} Loading: ${totalObservations} observations (${totalReadTokens.toLocaleString()} tokens to read)${colors.reset}`);
|
|
||||||
output.push(`${colors.dim} Work investment: ${totalDiscoveryTokens.toLocaleString()} tokens spent on research, building, and decisions${colors.reset}`);
|
|
||||||
if (totalDiscoveryTokens > 0 && (config.showSavingsAmount || config.showSavingsPercent)) {
|
|
||||||
let savingsLine = ' Your savings: ';
|
|
||||||
if (config.showSavingsAmount && config.showSavingsPercent) {
|
|
||||||
savingsLine += `${savings.toLocaleString()} tokens (${savingsPercent}% reduction from reuse)`;
|
|
||||||
} else if (config.showSavingsAmount) {
|
|
||||||
savingsLine += `${savings.toLocaleString()} tokens`;
|
|
||||||
} else {
|
|
||||||
savingsLine += `${savingsPercent}% reduction from reuse`;
|
|
||||||
}
|
|
||||||
output.push(`${colors.green}${savingsLine}${colors.reset}`);
|
|
||||||
}
|
|
||||||
output.push('');
|
|
||||||
} else {
|
|
||||||
output.push(`📊 **Context Economics**:`);
|
|
||||||
output.push(`- Loading: ${totalObservations} observations (${totalReadTokens.toLocaleString()} tokens to read)`);
|
|
||||||
output.push(`- Work investment: ${totalDiscoveryTokens.toLocaleString()} tokens spent on research, building, and decisions`);
|
|
||||||
if (totalDiscoveryTokens > 0 && (config.showSavingsAmount || config.showSavingsPercent)) {
|
|
||||||
let savingsLine = '- Your savings: ';
|
|
||||||
if (config.showSavingsAmount && config.showSavingsPercent) {
|
|
||||||
savingsLine += `${savings.toLocaleString()} tokens (${savingsPercent}% reduction from reuse)`;
|
|
||||||
} else if (config.showSavingsAmount) {
|
|
||||||
savingsLine += `${savings.toLocaleString()} tokens`;
|
|
||||||
} else {
|
|
||||||
savingsLine += `${savingsPercent}% reduction from reuse`;
|
|
||||||
}
|
|
||||||
output.push(savingsLine);
|
|
||||||
}
|
|
||||||
output.push('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare summaries for timeline display
|
|
||||||
// The most recent summary shows full details (investigated, learned, etc.)
|
|
||||||
// Older summaries only show as timeline markers (no link needed)
|
|
||||||
const mostRecentSummaryId = recentSummaries[0]?.id;
|
|
||||||
|
|
||||||
interface SummaryTimelineItem extends SessionSummary {
|
|
||||||
displayEpoch: number;
|
|
||||||
displayTime: string;
|
|
||||||
shouldShowLink: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const summariesForTimeline: SummaryTimelineItem[] = displaySummaries.map((summary, i) => {
|
|
||||||
// For visual grouping, display each summary at the time range it covers
|
|
||||||
// Most recent: shows at its own time (current session)
|
|
||||||
// Older: shows at the previous (older) summary's time to mark the session range
|
|
||||||
const olderSummary = i === 0 ? null : recentSummaries[i + 1];
|
|
||||||
return {
|
|
||||||
...summary,
|
|
||||||
displayEpoch: olderSummary ? olderSummary.created_at_epoch : summary.created_at_epoch,
|
|
||||||
displayTime: olderSummary ? olderSummary.created_at : summary.created_at,
|
|
||||||
shouldShowLink: summary.id !== mostRecentSummaryId
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Identify which observations should show full details (most recent N)
|
|
||||||
const fullObservationIds = new Set(
|
|
||||||
observations
|
|
||||||
.slice(0, config.fullObservationCount)
|
|
||||||
.map(obs => obs.id)
|
|
||||||
);
|
|
||||||
|
|
||||||
type TimelineItem =
|
|
||||||
| { type: 'observation'; data: Observation }
|
|
||||||
| { type: 'summary'; data: SummaryTimelineItem };
|
|
||||||
|
|
||||||
const timeline: TimelineItem[] = [
|
|
||||||
...timelineObs.map(obs => ({ type: 'observation' as const, data: obs })),
|
|
||||||
...summariesForTimeline.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 itemsByDay = new Map<string, TimelineItem[]>();
|
|
||||||
for (const item of timeline) {
|
|
||||||
const itemDate = item.type === 'observation' ? item.data.created_at : item.data.displayTime;
|
|
||||||
const day = formatDate(itemDate);
|
|
||||||
if (!itemsByDay.has(day)) {
|
|
||||||
itemsByDay.set(day, []);
|
|
||||||
}
|
|
||||||
itemsByDay.get(day)!.push(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort days chronologically
|
|
||||||
const sortedDays = Array.from(itemsByDay.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.shouldShowLink ? `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 && files[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 | Read | Work |`);
|
|
||||||
output.push(`|----|------|---|-------|------|------|`);
|
|
||||||
}
|
|
||||||
|
|
||||||
currentFile = file;
|
|
||||||
tableOpen = true;
|
|
||||||
lastTime = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const time = formatTime(obs.created_at);
|
|
||||||
const title = obs.title || 'Untitled';
|
|
||||||
|
|
||||||
// Map observation type to emoji icon
|
|
||||||
const icon = TYPE_ICON_MAP[obs.type as keyof typeof TYPE_ICON_MAP] || '•';
|
|
||||||
|
|
||||||
// Section 2: Calculate read tokens (estimate from observation size)
|
|
||||||
const obsSize = (obs.title?.length || 0) +
|
|
||||||
(obs.subtitle?.length || 0) +
|
|
||||||
(obs.narrative?.length || 0) +
|
|
||||||
JSON.stringify(obs.facts || []).length;
|
|
||||||
const readTokens = Math.ceil(obsSize / CHARS_PER_TOKEN_ESTIMATE);
|
|
||||||
|
|
||||||
// Get discovery tokens (handle old observations without this field)
|
|
||||||
const discoveryTokens = obs.discovery_tokens || 0;
|
|
||||||
|
|
||||||
// Map observation type to work emoji
|
|
||||||
const workEmoji = TYPE_WORK_EMOJI_MAP[obs.type as keyof typeof TYPE_WORK_EMOJI_MAP] || '🔍';
|
|
||||||
|
|
||||||
const discoveryDisplay = discoveryTokens > 0 ? `${workEmoji} ${discoveryTokens.toLocaleString()}` : '-';
|
|
||||||
|
|
||||||
const showTime = time !== lastTime;
|
|
||||||
const timeDisplay = showTime ? time : '';
|
|
||||||
lastTime = time;
|
|
||||||
|
|
||||||
// Check if this observation should show full details
|
|
||||||
const shouldShowFull = fullObservationIds.has(obs.id);
|
|
||||||
|
|
||||||
if (shouldShowFull) {
|
|
||||||
// Render with full details (narrative or facts)
|
|
||||||
const detailField = config.fullObservationField === 'narrative'
|
|
||||||
? obs.narrative
|
|
||||||
: (obs.facts ? parseJsonArray(obs.facts).join('\n') : null);
|
|
||||||
|
|
||||||
if (useColors) {
|
|
||||||
const timePart = showTime ? `${colors.dim}${time}${colors.reset}` : ' '.repeat(time.length);
|
|
||||||
const readPart = (config.showReadTokens && readTokens > 0) ? `${colors.dim}(~${readTokens}t)${colors.reset}` : '';
|
|
||||||
const discoveryPart = (config.showWorkTokens && discoveryTokens > 0) ? `${colors.dim}(${workEmoji} ${discoveryTokens.toLocaleString()}t)${colors.reset}` : '';
|
|
||||||
|
|
||||||
output.push(` ${colors.dim}#${obs.id}${colors.reset} ${timePart} ${icon} ${colors.bright}${title}${colors.reset}`);
|
|
||||||
if (detailField) {
|
|
||||||
output.push(` ${colors.dim}${detailField}${colors.reset}`);
|
|
||||||
}
|
|
||||||
if (readPart || discoveryPart) {
|
|
||||||
output.push(` ${readPart} ${discoveryPart}`);
|
|
||||||
}
|
|
||||||
output.push('');
|
|
||||||
} else {
|
|
||||||
// Close table for full observation
|
|
||||||
if (tableOpen) {
|
|
||||||
output.push('');
|
|
||||||
tableOpen = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
output.push(`**#${obs.id}** ${timeDisplay || '″'} ${icon} **${title}**`);
|
|
||||||
if (detailField) {
|
|
||||||
output.push('');
|
|
||||||
output.push(detailField);
|
|
||||||
output.push('');
|
|
||||||
}
|
|
||||||
const tokenParts: string[] = [];
|
|
||||||
if (config.showReadTokens) {
|
|
||||||
tokenParts.push(`Read: ~${readTokens}`);
|
|
||||||
}
|
|
||||||
if (config.showWorkTokens) {
|
|
||||||
tokenParts.push(`Work: ${discoveryDisplay}`);
|
|
||||||
}
|
|
||||||
if (tokenParts.length > 0) {
|
|
||||||
output.push(tokenParts.join(', '));
|
|
||||||
}
|
|
||||||
output.push('');
|
|
||||||
|
|
||||||
// Reopen table for next items if in same file
|
|
||||||
currentFile = null;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Compact index rendering (existing code)
|
|
||||||
if (useColors) {
|
|
||||||
const timePart = showTime ? `${colors.dim}${time}${colors.reset}` : ' '.repeat(time.length);
|
|
||||||
const readPart = (config.showReadTokens && readTokens > 0) ? `${colors.dim}(~${readTokens}t)${colors.reset}` : '';
|
|
||||||
const discoveryPart = (config.showWorkTokens && discoveryTokens > 0) ? `${colors.dim}(${workEmoji} ${discoveryTokens.toLocaleString()}t)${colors.reset}` : '';
|
|
||||||
output.push(` ${colors.dim}#${obs.id}${colors.reset} ${timePart} ${icon} ${title} ${readPart} ${discoveryPart}`);
|
|
||||||
} else {
|
|
||||||
const readCol = config.showReadTokens ? `~${readTokens}` : '';
|
|
||||||
const workCol = config.showWorkTokens ? discoveryDisplay : '';
|
|
||||||
output.push(`| #${obs.id} | ${timeDisplay || '″'} | ${icon} | ${title} | ${readCol} | ${workCol} |`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close final table if open
|
|
||||||
if (tableOpen) {
|
|
||||||
output.push('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add full summary details for most recent session
|
|
||||||
// Only show if summary was generated AFTER the last observation
|
|
||||||
const mostRecentSummary = recentSummaries[0];
|
|
||||||
const mostRecentObservation = observations[0]; // observations are DESC by created_at_epoch
|
|
||||||
|
|
||||||
const shouldShowSummary = config.showLastSummary &&
|
|
||||||
mostRecentSummary &&
|
|
||||||
(mostRecentSummary.investigated || mostRecentSummary.learned || mostRecentSummary.completed || mostRecentSummary.next_steps) &&
|
|
||||||
(!mostRecentObservation || mostRecentSummary.created_at_epoch > mostRecentObservation.created_at_epoch);
|
|
||||||
|
|
||||||
if (shouldShowSummary) {
|
|
||||||
output.push(...renderSummaryField('Investigated', mostRecentSummary.investigated, colors.blue, useColors));
|
|
||||||
output.push(...renderSummaryField('Learned', mostRecentSummary.learned, colors.yellow, useColors));
|
|
||||||
output.push(...renderSummaryField('Completed', mostRecentSummary.completed, colors.green, useColors));
|
|
||||||
output.push(...renderSummaryField('Next Steps', mostRecentSummary.next_steps, colors.magenta, useColors));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Previously section (last assistant message from prior session) - positioned at bottom for chronological sense
|
|
||||||
if (priorAssistantMessage) {
|
|
||||||
output.push('');
|
|
||||||
output.push('---');
|
|
||||||
output.push('');
|
|
||||||
if (useColors) {
|
|
||||||
output.push(`${colors.bright}${colors.magenta}📋 Previously${colors.reset}`);
|
|
||||||
output.push('');
|
|
||||||
output.push(`${colors.dim}A: ${priorAssistantMessage}${colors.reset}`);
|
|
||||||
} else {
|
|
||||||
output.push(`**📋 Previously**`);
|
|
||||||
output.push('');
|
|
||||||
output.push(`A: ${priorAssistantMessage}`);
|
|
||||||
}
|
|
||||||
output.push('');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Footer with token savings message (only show if token economics is visible)
|
|
||||||
if (showContextEconomics && totalDiscoveryTokens > 0 && savings > 0) {
|
|
||||||
const workTokensK = Math.round(totalDiscoveryTokens / 1000);
|
|
||||||
output.push('');
|
|
||||||
if (useColors) {
|
|
||||||
output.push(`${colors.dim}💰 Access ${workTokensK}k tokens of past research & decisions for just ${totalReadTokens.toLocaleString()}t. Use the mem-search skill to access memories by ID instead of re-reading files.${colors.reset}`);
|
|
||||||
} else {
|
|
||||||
output.push(`💰 Access ${workTokensK}k tokens of past research & decisions for just ${totalReadTokens.toLocaleString()}t. Use the mem-search skill to access memories by ID instead of re-reading files.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
db?.close();
|
|
||||||
|
|
||||||
// Add debug info directly to output
|
|
||||||
// if (debugInfo.length > 0) {
|
|
||||||
// output.push('');
|
|
||||||
// output.push('---');
|
|
||||||
// output.push('');
|
|
||||||
// output.push(...debugInfo);
|
|
||||||
// }
|
|
||||||
|
|
||||||
return output.join('\n').trimEnd();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export for use by worker service
|
// Export for use by worker service (compatibility)
|
||||||
export { contextHook };
|
export { contextHook };
|
||||||
|
|
||||||
// Entry Point - handle stdin/stdout
|
// Entry Point - handle stdin/stdout
|
||||||
const forceColors = process.argv.includes('--colors');
|
if (stdin.isTTY) {
|
||||||
|
// Running manually from terminal - show formatted output
|
||||||
if (stdin.isTTY || forceColors) {
|
contextHook(undefined).then(({ formatted }) => {
|
||||||
// Running manually from terminal - print formatted output with colors
|
console.log(formatted);
|
||||||
contextHook(undefined, true).then(contextOutput => {
|
|
||||||
console.log(contextOutput);
|
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Running from hook - wrap in hookSpecificOutput JSON format
|
// Running from hook - formatted to stderr (user display), unformatted to stdout (model context)
|
||||||
let input = '';
|
let input = '';
|
||||||
stdin.on('data', (chunk) => input += chunk);
|
stdin.on('data', (chunk) => input += chunk);
|
||||||
stdin.on('end', async () => {
|
stdin.on('end', async () => {
|
||||||
const parsed = input.trim() ? JSON.parse(input) : undefined;
|
const parsed = input.trim() ? JSON.parse(input) : undefined;
|
||||||
const contextOutput = await contextHook(parsed, false);
|
const { unformatted, formatted } = await contextHook(parsed);
|
||||||
|
|
||||||
|
// Write formatted version to stderr for user display
|
||||||
|
process.stderr.write(formatted + '\n');
|
||||||
|
|
||||||
|
// Write unformatted version to stdout as JSON for model context
|
||||||
const result = {
|
const result = {
|
||||||
hookSpecificOutput: {
|
hookSpecificOutput: {
|
||||||
hookEventName: "SessionStart",
|
hookEventName: "SessionStart",
|
||||||
additionalContext: contextOutput
|
additionalContext: unformatted
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
console.log(JSON.stringify(result));
|
console.log(JSON.stringify(result));
|
||||||
|
|||||||
+12
-63
@@ -1,15 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* Save Hook - PostToolUse
|
* Save Hook - PostToolUse
|
||||||
* Consolidated entry point + logic
|
*
|
||||||
|
* Pure HTTP client - sends data to worker, worker handles all database operations
|
||||||
|
* including privacy checks. This allows the hook to run under any runtime
|
||||||
|
* (Node.js or Bun) since it has no native module dependencies.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { stdin } from 'process';
|
import { stdin } from 'process';
|
||||||
import { SessionStore } from '../services/sqlite/SessionStore.js';
|
|
||||||
import { createHookResponse } from './hook-response.js';
|
import { createHookResponse } from './hook-response.js';
|
||||||
import { logger } from '../utils/logger.js';
|
import { logger } from '../utils/logger.js';
|
||||||
import { ensureWorkerRunning, getWorkerPort } from '../shared/worker-utils.js';
|
import { ensureWorkerRunning, getWorkerPort } from '../shared/worker-utils.js';
|
||||||
import { silentDebug } from '../utils/silent-debug.js';
|
|
||||||
import { stripMemoryTagsFromJson } from '../utils/tag-stripping.js';
|
|
||||||
|
|
||||||
export interface PostToolUseInput {
|
export interface PostToolUseInput {
|
||||||
session_id: string;
|
session_id: string;
|
||||||
@@ -29,9 +29,8 @@ const SKIP_TOOLS = new Set([
|
|||||||
'AskUserQuestion' // User interaction, not substantive work
|
'AskUserQuestion' // User interaction, not substantive work
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save Hook Main Logic
|
* Save Hook Main Logic - Fire-and-forget HTTP client
|
||||||
*/
|
*/
|
||||||
async function saveHook(input?: PostToolUseInput): Promise<void> {
|
async function saveHook(input?: PostToolUseInput): Promise<void> {
|
||||||
if (!input) {
|
if (!input) {
|
||||||
@@ -48,72 +47,24 @@ async function saveHook(input?: PostToolUseInput): Promise<void> {
|
|||||||
// Ensure worker is running
|
// Ensure worker is running
|
||||||
await ensureWorkerRunning();
|
await ensureWorkerRunning();
|
||||||
|
|
||||||
const db = new SessionStore();
|
const port = getWorkerPort();
|
||||||
|
|
||||||
// Get or create session
|
|
||||||
const sessionDbId = db.createSDKSession(session_id, '', '');
|
|
||||||
const promptNumber = db.getPromptCounter(sessionDbId);
|
|
||||||
|
|
||||||
// Skip observation if user prompt was entirely private
|
|
||||||
// This respects the user's intent: if they marked the entire prompt as <private>,
|
|
||||||
// they don't want ANY observations from that interaction
|
|
||||||
const userPrompt = db.getUserPrompt(session_id, promptNumber);
|
|
||||||
if (!userPrompt || userPrompt.trim() === '') {
|
|
||||||
silentDebug('[save-hook] Skipping observation - user prompt was entirely private', {
|
|
||||||
session_id,
|
|
||||||
promptNumber,
|
|
||||||
tool_name
|
|
||||||
});
|
|
||||||
db.close();
|
|
||||||
console.log(createHookResponse('PostToolUse', true));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
db.close();
|
|
||||||
|
|
||||||
const toolStr = logger.formatTool(tool_name, tool_input);
|
const toolStr = logger.formatTool(tool_name, tool_input);
|
||||||
|
|
||||||
const port = getWorkerPort();
|
|
||||||
|
|
||||||
logger.dataIn('HOOK', `PostToolUse: ${toolStr}`, {
|
logger.dataIn('HOOK', `PostToolUse: ${toolStr}`, {
|
||||||
sessionId: sessionDbId,
|
|
||||||
workerPort: port
|
workerPort: port
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Serialize and strip memory tags from tool_input and tool_response
|
// Send to worker - worker handles privacy check and database operations
|
||||||
// This prevents recursive storage of context and respects <private> tags
|
const response = await fetch(`http://127.0.0.1:${port}/api/sessions/observations`, {
|
||||||
let cleanedToolInput = '{}';
|
|
||||||
let cleanedToolResponse = '{}';
|
|
||||||
|
|
||||||
try {
|
|
||||||
cleanedToolInput = tool_input !== undefined
|
|
||||||
? stripMemoryTagsFromJson(JSON.stringify(tool_input))
|
|
||||||
: '{}';
|
|
||||||
} catch (error) {
|
|
||||||
// Handle circular references or other JSON.stringify errors
|
|
||||||
silentDebug('[save-hook] Failed to stringify tool_input:', { error, tool_name });
|
|
||||||
cleanedToolInput = '{"error": "Failed to serialize tool_input"}';
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
cleanedToolResponse = tool_response !== undefined
|
|
||||||
? stripMemoryTagsFromJson(JSON.stringify(tool_response))
|
|
||||||
: '{}';
|
|
||||||
} catch (error) {
|
|
||||||
// Handle circular references or other JSON.stringify errors
|
|
||||||
silentDebug('[save-hook] Failed to stringify tool_response:', { error, tool_name });
|
|
||||||
cleanedToolResponse = '{"error": "Failed to serialize tool_response"}';
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`http://127.0.0.1:${port}/sessions/${sessionDbId}/observations`, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
claudeSessionId: session_id,
|
||||||
tool_name,
|
tool_name,
|
||||||
tool_input: cleanedToolInput,
|
tool_input,
|
||||||
tool_response: cleanedToolResponse,
|
tool_response,
|
||||||
prompt_number: promptNumber,
|
|
||||||
cwd: cwd || ''
|
cwd: cwd || ''
|
||||||
}),
|
}),
|
||||||
signal: AbortSignal.timeout(2000)
|
signal: AbortSignal.timeout(2000)
|
||||||
@@ -122,19 +73,17 @@ async function saveHook(input?: PostToolUseInput): Promise<void> {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
logger.failure('HOOK', 'Failed to send observation', {
|
logger.failure('HOOK', 'Failed to send observation', {
|
||||||
sessionId: sessionDbId,
|
|
||||||
status: response.status
|
status: response.status
|
||||||
}, errorText);
|
}, errorText);
|
||||||
throw new Error(`Failed to send observation to worker: ${response.status} ${errorText}`);
|
throw new Error(`Failed to send observation to worker: ${response.status} ${errorText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug('HOOK', 'Observation sent successfully', { sessionId: sessionDbId, toolName: tool_name });
|
logger.debug('HOOK', 'Observation sent successfully', { toolName: tool_name });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// Only show restart message for connection errors, not HTTP errors
|
// Only show restart message for connection errors, not HTTP errors
|
||||||
if (error.cause?.code === 'ECONNREFUSED' || error.name === 'TimeoutError' || error.message.includes('fetch failed')) {
|
if (error.cause?.code === 'ECONNREFUSED' || error.name === 'TimeoutError' || error.message.includes('fetch failed')) {
|
||||||
throw new Error("There's a problem with the worker. If you just updated, type `pm2 restart claude-mem-worker` in your terminal to continue");
|
throw new Error("There's a problem with the worker. If you just updated, type `pm2 restart claude-mem-worker` in your terminal to continue");
|
||||||
}
|
}
|
||||||
// Re-throw HTTP errors and other errors as-is
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+15
-64
@@ -1,15 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* Summary Hook - Stop
|
* Summary Hook - Stop
|
||||||
* Consolidated entry point + logic
|
*
|
||||||
|
* Pure HTTP client - sends data to worker, worker handles all database operations
|
||||||
|
* including privacy checks. This allows the hook to run under any runtime
|
||||||
|
* (Node.js or Bun) since it has no native module dependencies.
|
||||||
|
*
|
||||||
|
* Transcript parsing stays in the hook because only the hook has access to
|
||||||
|
* the transcript file path.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { stdin } from 'process';
|
import { stdin } from 'process';
|
||||||
import { readFileSync, existsSync } from 'fs';
|
import { readFileSync, existsSync } from 'fs';
|
||||||
import { SessionStore } from '../services/sqlite/SessionStore.js';
|
|
||||||
import { createHookResponse } from './hook-response.js';
|
import { createHookResponse } from './hook-response.js';
|
||||||
import { logger } from '../utils/logger.js';
|
import { logger } from '../utils/logger.js';
|
||||||
import { ensureWorkerRunning, getWorkerPort } from '../shared/worker-utils.js';
|
import { ensureWorkerRunning, getWorkerPort } from '../shared/worker-utils.js';
|
||||||
import { silentDebug } from '../utils/silent-debug.js';
|
|
||||||
|
|
||||||
export interface StopInput {
|
export interface StopInput {
|
||||||
session_id: string;
|
session_id: string;
|
||||||
@@ -123,7 +127,7 @@ function extractLastAssistantMessage(transcriptPath: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Summary Hook Main Logic
|
* Summary Hook Main Logic - Fire-and-forget HTTP client
|
||||||
*/
|
*/
|
||||||
async function summaryHook(input?: StopInput): Promise<void> {
|
async function summaryHook(input?: StopInput): Promise<void> {
|
||||||
if (!input) {
|
if (!input) {
|
||||||
@@ -135,77 +139,25 @@ async function summaryHook(input?: StopInput): Promise<void> {
|
|||||||
// Ensure worker is running
|
// Ensure worker is running
|
||||||
await ensureWorkerRunning();
|
await ensureWorkerRunning();
|
||||||
|
|
||||||
const db = new SessionStore();
|
|
||||||
|
|
||||||
// Get or create session
|
|
||||||
const sessionDbId = db.createSDKSession(session_id, '', '');
|
|
||||||
const promptNumber = db.getPromptCounter(sessionDbId);
|
|
||||||
|
|
||||||
// Skip summary if user prompt was entirely private
|
|
||||||
// This respects the user's intent: if they marked the entire prompt as <private>,
|
|
||||||
// they don't want ANY memory operations including summaries
|
|
||||||
const userPrompt = db.getUserPrompt(session_id, promptNumber);
|
|
||||||
if (!userPrompt || userPrompt.trim() === '') {
|
|
||||||
silentDebug('[summary-hook] Skipping summary - user prompt was entirely private', {
|
|
||||||
session_id,
|
|
||||||
promptNumber
|
|
||||||
});
|
|
||||||
db.close();
|
|
||||||
console.log(createHookResponse('Stop', true));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// DIAGNOSTIC: Check session and observations
|
|
||||||
const sessionInfo = db.db.prepare(`
|
|
||||||
SELECT id, claude_session_id, sdk_session_id, project
|
|
||||||
FROM sdk_sessions WHERE id = ?
|
|
||||||
`).get(sessionDbId) as any;
|
|
||||||
|
|
||||||
const obsCount = db.db.prepare(`
|
|
||||||
SELECT COUNT(*) as count
|
|
||||||
FROM observations
|
|
||||||
WHERE sdk_session_id = ?
|
|
||||||
`).get(sessionInfo?.sdk_session_id) as { count: number };
|
|
||||||
|
|
||||||
silentDebug('[summary-hook] Session diagnostics', {
|
|
||||||
claudeSessionId: session_id,
|
|
||||||
sessionDbId,
|
|
||||||
sdkSessionId: sessionInfo?.sdk_session_id,
|
|
||||||
project: sessionInfo?.project,
|
|
||||||
promptNumber,
|
|
||||||
observationCount: obsCount?.count || 0,
|
|
||||||
transcriptPath: input.transcript_path
|
|
||||||
});
|
|
||||||
|
|
||||||
db.close();
|
|
||||||
|
|
||||||
const port = getWorkerPort();
|
const port = getWorkerPort();
|
||||||
|
|
||||||
// Extract last user AND assistant messages from transcript
|
// Extract last user AND assistant messages from transcript
|
||||||
const lastUserMessage = extractLastUserMessage(input.transcript_path || '');
|
const lastUserMessage = extractLastUserMessage(input.transcript_path || '');
|
||||||
const lastAssistantMessage = extractLastAssistantMessage(input.transcript_path || '');
|
const lastAssistantMessage = extractLastAssistantMessage(input.transcript_path || '');
|
||||||
|
|
||||||
silentDebug('[summary-hook] Extracted messages', {
|
|
||||||
hasLastUserMessage: !!lastUserMessage,
|
|
||||||
hasLastAssistantMessage: !!lastAssistantMessage,
|
|
||||||
lastAssistantPreview: lastAssistantMessage.substring(0, 200),
|
|
||||||
lastAssistantLength: lastAssistantMessage.length
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.dataIn('HOOK', 'Stop: Requesting summary', {
|
logger.dataIn('HOOK', 'Stop: Requesting summary', {
|
||||||
sessionId: sessionDbId,
|
|
||||||
workerPort: port,
|
workerPort: port,
|
||||||
promptNumber,
|
|
||||||
hasLastUserMessage: !!lastUserMessage,
|
hasLastUserMessage: !!lastUserMessage,
|
||||||
hasLastAssistantMessage: !!lastAssistantMessage
|
hasLastAssistantMessage: !!lastAssistantMessage
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`http://127.0.0.1:${port}/sessions/${sessionDbId}/summarize`, {
|
// Send to worker - worker handles privacy check and database operations
|
||||||
|
const response = await fetch(`http://127.0.0.1:${port}/api/sessions/summarize`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
prompt_number: promptNumber,
|
claudeSessionId: session_id,
|
||||||
last_user_message: lastUserMessage,
|
last_user_message: lastUserMessage,
|
||||||
last_assistant_message: lastAssistantMessage
|
last_assistant_message: lastAssistantMessage
|
||||||
}),
|
}),
|
||||||
@@ -215,26 +167,25 @@ async function summaryHook(input?: StopInput): Promise<void> {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
logger.failure('HOOK', 'Failed to generate summary', {
|
logger.failure('HOOK', 'Failed to generate summary', {
|
||||||
sessionId: sessionDbId,
|
|
||||||
status: response.status
|
status: response.status
|
||||||
}, errorText);
|
}, errorText);
|
||||||
throw new Error(`Failed to request summary from worker: ${response.status} ${errorText}`);
|
throw new Error(`Failed to request summary from worker: ${response.status} ${errorText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug('HOOK', 'Summary request sent successfully', { sessionId: sessionDbId });
|
logger.debug('HOOK', 'Summary request sent successfully');
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// Only show restart message for connection errors, not HTTP errors
|
// Only show restart message for connection errors, not HTTP errors
|
||||||
if (error.cause?.code === 'ECONNREFUSED' || error.name === 'TimeoutError' || error.message.includes('fetch failed')) {
|
if (error.cause?.code === 'ECONNREFUSED' || error.name === 'TimeoutError' || error.message.includes('fetch failed')) {
|
||||||
throw new Error("There's a problem with the worker. If you just updated, type `pm2 restart claude-mem-worker` in your terminal to continue");
|
throw new Error("There's a problem with the worker. If you just updated, type `pm2 restart claude-mem-worker` in your terminal to continue");
|
||||||
}
|
}
|
||||||
// Re-throw HTTP errors and other errors as-is
|
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
await fetch(`http://127.0.0.1:${port}/api/processing`, {
|
// Notify worker to stop spinner (fire-and-forget)
|
||||||
|
fetch(`http://127.0.0.1:${port}/api/processing`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ isProcessing: false })
|
body: JSON.stringify({ isProcessing: false })
|
||||||
});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(createHookResponse('Stop', true));
|
console.log(createHookResponse('Stop', true));
|
||||||
|
|||||||
@@ -6,8 +6,7 @@
|
|||||||
* has been loaded into their session. Uses stderr as the communication channel
|
* has been loaded into their session. Uses stderr as the communication channel
|
||||||
* since it's currently the only way to display messages in Claude Code UI.
|
* since it's currently the only way to display messages in Claude Code UI.
|
||||||
*/
|
*/
|
||||||
import { execSync } from "child_process";
|
import { join, basename } from "path";
|
||||||
import { join } from "path";
|
|
||||||
import { homedir } from "os";
|
import { homedir } from "os";
|
||||||
import { existsSync } from "fs";
|
import { existsSync } from "fs";
|
||||||
import { getWorkerPort } from "../shared/worker-utils.js";
|
import { getWorkerPort } from "../shared/worker-utils.js";
|
||||||
@@ -41,14 +40,20 @@ This message was not added to your startup context, so you can continue working
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Cross-platform path to context-hook.js in the installed plugin
|
|
||||||
const contextHookPath = join(homedir(), '.claude', 'plugins', 'marketplaces', 'thedotmack', 'plugin', 'scripts', 'context-hook.js');
|
|
||||||
const output = execSync(`node "${contextHookPath}" --colors`, {
|
|
||||||
encoding: 'utf8',
|
|
||||||
windowsHide: true
|
|
||||||
});
|
|
||||||
|
|
||||||
const port = getWorkerPort();
|
const port = getWorkerPort();
|
||||||
|
const project = basename(process.cwd());
|
||||||
|
|
||||||
|
// Fetch formatted context directly from worker API
|
||||||
|
const response = await fetch(
|
||||||
|
`http://127.0.0.1:${port}/api/context/inject?project=${encodeURIComponent(project)}&colors=true`,
|
||||||
|
{ method: 'GET', signal: AbortSignal.timeout(5000) }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Worker error ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = await response.text();
|
||||||
|
|
||||||
// If it's after Dec 5, 2025 7pm EST, patch this out
|
// If it's after Dec 5, 2025 7pm EST, patch this out
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|||||||
Reference in New Issue
Block a user