feat: Add web-based viewer UI for real-time memory stream (#58)

* Add viewer HTML for claude-mem with live stream and settings interface

- Implemented a responsive layout with left and right columns for observations and settings.
- Added status indicators for connection state.
- Integrated server-sent events (SSE) for real-time updates on observations and summaries.
- Created dynamic project filter dropdown based on available observations.
- Developed settings section for environment variables and worker stats.
- Included functionality to save settings and load current stats from the server.
- Enhanced UI with custom styles for better user experience.

* Remove draft implementation plan for v5.1 web UI

* feat: Implement viewer UI with sidebar, feed, and settings management

- Add main viewer template (HTML) with styling for dark mode.
- Create App component to manage state and render Header, Feed, and Sidebar.
- Implement Feed component to display observations and summaries with filtering.
- Develop Header component for project selection and connection status.
- Create ObservationCard and SummaryCard components for displaying individual items.
- Implement Sidebar for settings management and displaying worker/database stats.
- Add hooks for managing SSE connections, settings, and stats fetching.
- Define types for observations, summaries, settings, and stats.

* Enhance UI components and improve layout

- Updated padding and layout for the feed and card components in viewer.html, viewer-template.html, and viewer.html to improve visual spacing and alignment.
- Increased card margins and padding for better readability and aesthetics.
- Adjusted font sizes, weights, and line heights for card titles and subtitles to enhance text clarity and hierarchy.
- Added a new feed-content class to center the feed items and limit their maximum width.
- Modified the Header component to improve the settings icon's SVG structure for better rendering.
- Enhanced the Sidebar component by adding a close button with an SVG icon, improving user experience for closing settings.
- Updated the Sidebar component's props to include an onClose function for handling sidebar closure.

* feat: Add user prompts feature with UI integration

- Implemented a new method in SessionStore to retrieve recent user prompts.
- Updated WorkerService to fetch and broadcast user prompts to clients.
- Enhanced the Feed component to display user prompts alongside observations and summaries.
- Created a new PromptCard component for rendering individual user prompts.
- Modified useSSE hook to handle new prompt events and processing status.
- Updated viewer templates and styles to accommodate the new prompts feature.

* feat: Add project filtering and pagination for observations

- Implemented `getAllProjects` method in `SessionStore` to retrieve unique projects from the database.
- Added `/api/observations` endpoint in `WorkerService` for paginated observations fetching.
- Enhanced `App` component to manage paginated observations and integrate with the new API.
- Updated `Feed` component to support infinite scrolling and loading more observations.
- Modified `Header` to display processing status.
- Refactored `PromptCard` to remove unnecessary processing indicator.
- Introduced `usePagination` hook to handle pagination logic for observations.
- Updated `useSSE` hook to include projects in the state.
- Adjusted types to accommodate new project data.

* Refactor viewer build process and remove deprecated HTML template

- Updated build-viewer.js to copy HTML template to build output with improved logging.
- Removed src/ui/viewer.html as it is no longer needed.
- Enhanced App component to merge observations while removing duplicates using useMemo.
- Improved Feed component to utilize a ref for onLoadMore callback and adjusted infinite scroll logic.
- Updated Sidebar component to use default settings from constants and removed redundant formatting functions.
- Refactored usePagination hook to streamline loading logic and prevent concurrent requests.
- Updated useSSE hook to use centralized API endpoints and improved reconnection logic.
- Refactored useSettings and useStats hooks to utilize constants for API endpoints and timing.
- Introduced ErrorBoundary component for better error handling in the viewer.
- Centralized API endpoint paths, default settings, timing constants, and UI-related constants into dedicated files.
- Added utility functions for formatting uptime and bytes for consistent display across components.

* feat: Enhance session management and pagination for user prompts, summaries, and observations

- Added project field to user prompts in the database and API responses.
- Implemented new API endpoints for fetching summaries and prompts with pagination.
- Updated WorkerService to handle new endpoints and filter results by project.
- Modified App component to manage paginated data for prompts and summaries.
- Refactored Feed component to remove unnecessary filtering and handle combined data.
- Improved usePagination hook to support multiple data types and project filtering.
- Adjusted useSSE hook to only load projects initially, with data fetched via pagination.
- Updated types to include project information for user prompts.

* feat: add SummarySkeleton component and data utility for merging items

- Introduced SummarySkeleton component for displaying loading state in the UI.
- Implemented mergeAndDeduplicateByProject utility function to merge real-time and paginated data while removing duplicates based on project filtering.

* Enhance UI and functionality of the viewer component

- Updated sidebar transition effects to use translate3d for improved performance.
- Added a sidebar header with title and connection status indicators.
- Modified the PromptCard to display project name instead of prompt number.
- Introduced a GitHub and X (Twitter) link in the header for easy access.
- Improved styling for setting descriptions and card hover effects.
- Enhanced Sidebar component to include connection status and updated layout.

* fix: reduce timeout for worker health checks and ensure proper responsiveness
This commit is contained in:
Alex Newman
2025-11-05 22:54:38 -05:00
committed by GitHub
parent ff28db9d76
commit 79ff1849f0
49 changed files with 3622 additions and 309 deletions
+98
View File
@@ -565,6 +565,104 @@ export class SessionStore {
return stmt.all(project, limit) as any[];
}
/**
* Get recent observations across all projects (for web UI)
*/
getAllRecentObservations(limit: number = 100): Array<{
id: number;
type: string;
title: string | null;
subtitle: string | null;
text: string;
project: string;
prompt_number: number | null;
created_at: string;
created_at_epoch: number;
}> {
const stmt = this.db.prepare(`
SELECT id, type, title, subtitle, text, project, prompt_number, created_at, created_at_epoch
FROM observations
ORDER BY created_at_epoch DESC
LIMIT ?
`);
return stmt.all(limit) as any[];
}
/**
* Get recent summaries across all projects (for web UI)
*/
getAllRecentSummaries(limit: number = 50): Array<{
id: number;
request: string | null;
investigated: string | null;
learned: string | null;
completed: string | null;
next_steps: string | null;
files_read: string | null;
files_edited: string | null;
notes: string | null;
project: string;
prompt_number: number | null;
created_at: string;
created_at_epoch: number;
}> {
const stmt = this.db.prepare(`
SELECT id, request, investigated, learned, completed, next_steps,
files_read, files_edited, notes, project, prompt_number,
created_at, created_at_epoch
FROM session_summaries
ORDER BY created_at_epoch DESC
LIMIT ?
`);
return stmt.all(limit) as any[];
}
/**
* Get recent user prompts across all sessions (for web UI)
*/
getAllRecentUserPrompts(limit: number = 100): Array<{
id: number;
claude_session_id: string;
project: string;
prompt_number: number;
prompt_text: string;
created_at: string;
created_at_epoch: number;
}> {
const stmt = this.db.prepare(`
SELECT
up.id,
up.claude_session_id,
s.project,
up.prompt_number,
up.prompt_text,
up.created_at,
up.created_at_epoch
FROM user_prompts up
LEFT JOIN sdk_sessions s ON up.claude_session_id = s.claude_session_id
ORDER BY up.created_at_epoch DESC
LIMIT ?
`);
return stmt.all(limit) as any[];
}
/**
* Get all unique projects from the database (for web UI project filter)
*/
getAllProjects(): string[] {
const stmt = this.db.prepare(`
SELECT DISTINCT project
FROM sdk_sessions
ORDER BY project ASC
`);
const rows = stmt.all() as Array<{ project: string }>;
return rows.map(row => row.project);
}
/**
* Get recent sessions with their status and summary info
*/
+504
View File
@@ -14,6 +14,10 @@ import type { SDKSession } from '../sdk/prompts.js';
import { logger } from '../utils/logger.js';
import { ensureAllDataDirs } from '../shared/paths.js';
import { execSync } from 'child_process';
import { readFileSync, writeFileSync, existsSync, statSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { homedir } from 'os';
const MODEL = process.env.CLAUDE_MEM_MODEL || 'claude-sonnet-4-5';
const DISALLOWED_TOOLS = ['Glob', 'Grep', 'ListMcpResourcesTool', 'WebSearch'];
@@ -96,14 +100,33 @@ class WorkerService {
private port: number = FIXED_PORT;
private sessions: Map<number, ActiveSession> = new Map();
private chromaSync!: ChromaSync;
private sseClients: Set<Response> = new Set();
constructor() {
this.app = express();
this.app.use(express.json({ limit: '50mb' }));
// Serve static files for web UI (viewer-bundle.js, logos, etc.)
const uiDir = this.getUIDirectory();
this.app.use(express.static(uiDir));
// Health check
this.app.get('/health', this.handleHealth.bind(this));
// Web UI viewer
this.app.get('/', this.handleViewerHTML.bind(this));
// SSE stream for web UI
this.app.get('/stream', this.handleSSEStream.bind(this));
// API endpoints for web UI
this.app.get('/api/stats', this.handleStats.bind(this));
this.app.get('/api/settings', this.handleGetSettings.bind(this));
this.app.post('/api/settings', this.handlePostSettings.bind(this));
this.app.get('/api/observations', this.handleGetObservations.bind(this));
this.app.get('/api/summaries', this.handleGetSummaries.bind(this));
this.app.get('/api/prompts', this.handleGetPrompts.bind(this));
// Session endpoints
this.app.post('/sessions/:sessionDbId/init', this.handleInit.bind(this));
this.app.post('/sessions/:sessionDbId/observations', this.handleObservation.bind(this));
@@ -146,6 +169,22 @@ class WorkerService {
});
}
/**
* Get UI directory path (works in both dev ESM and production CJS)
*/
private getUIDirectory(): string {
let scriptDir: string;
if (typeof __dirname !== 'undefined') {
// CJS context (production build)
scriptDir = __dirname;
} else {
// ESM context (development)
const __filename = fileURLToPath(import.meta.url);
scriptDir = dirname(__filename);
}
return join(scriptDir, '..', 'ui');
}
/**
* GET /health
*/
@@ -153,6 +192,411 @@ class WorkerService {
res.json({ status: 'ok' });
}
/**
* GET / - Serve viewer HTML
*/
private handleViewerHTML(_req: Request, res: Response): void {
try {
const uiPath = join(this.getUIDirectory(), 'viewer.html');
const html = readFileSync(uiPath, 'utf-8');
res.setHeader('Content-Type', 'text/html');
res.send(html);
} catch (error: any) {
logger.error('WORKER', 'Failed to serve viewer HTML', {}, error);
res.status(500).send('Failed to load viewer');
}
}
/**
* GET /stream - SSE endpoint for web UI
*/
private handleSSEStream(req: Request, res: Response): void {
// Set SSE headers
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('Access-Control-Allow-Origin', '*');
// Add client to set
this.sseClients.add(res);
logger.info('WORKER', `SSE client connected`, { totalClients: this.sseClients.size });
// Send only projects list - all data will be loaded via pagination
const db = new SessionStore();
const allProjects = db.getAllProjects();
db.close();
const initialData = {
type: 'initial_load',
projects: allProjects,
timestamp: Date.now()
};
res.write(`data: ${JSON.stringify(initialData)}\n\n`);
// Handle client disconnect
req.on('close', () => {
this.sseClients.delete(res);
logger.info('WORKER', `SSE client disconnected`, { remainingClients: this.sseClients.size });
});
}
/**
* Broadcast SSE event to all connected clients
*/
private broadcastSSE(event: any): void {
if (this.sseClients.size === 0) {
return; // No clients connected, skip broadcast
}
const data = `data: ${JSON.stringify(event)}\n\n`;
const clientsToRemove: Response[] = [];
for (const client of this.sseClients) {
try {
client.write(data);
} catch (error) {
// Client disconnected, mark for removal
clientsToRemove.push(client);
}
}
// Clean up disconnected clients
for (const client of clientsToRemove) {
this.sseClients.delete(client);
}
if (clientsToRemove.length > 0) {
logger.info('WORKER', `SSE cleaned up disconnected clients`, { count: clientsToRemove.length });
}
}
/**
* Broadcast processing status to SSE clients
*/
private broadcastProcessingStatus(claudeSessionId: string, isProcessing: boolean): void {
this.broadcastSSE({
type: 'processing_status',
processing: {
session_id: claudeSessionId,
is_processing: isProcessing
}
});
}
/**
* GET /api/stats - Return worker and database stats
*/
private handleStats(_req: Request, res: Response): void {
try {
const db = new SessionStore();
// Get database stats
const obsCount = db.db.prepare('SELECT COUNT(*) as count FROM observations').get() as { count: number };
const sessionCount = db.db.prepare('SELECT COUNT(*) as count FROM sdk_sessions').get() as { count: number };
const summaryCount = db.db.prepare('SELECT COUNT(*) as count FROM session_summaries').get() as { count: number };
// Get database file size
const dbPath = join(homedir(), '.claude-mem', 'claude-mem.db');
let dbSize = 0;
if (existsSync(dbPath)) {
dbSize = statSync(dbPath).size;
}
db.close();
// Get worker stats
const uptime = process.uptime();
const version = process.env.npm_package_version || '5.0.3'; // fallback to current version
res.json({
worker: {
version,
uptime: Math.floor(uptime),
activeSessions: this.sessions.size,
sseClients: this.sseClients.size,
port: this.port
},
database: {
path: dbPath,
size: dbSize,
observations: obsCount.count,
sessions: sessionCount.count,
summaries: summaryCount.count
}
});
} catch (error: any) {
logger.error('WORKER', 'Failed to get stats', {}, error);
res.status(500).json({ error: 'Failed to get stats' });
}
}
/**
* GET /api/settings - Read settings from ~/.claude/settings.json
*/
private handleGetSettings(_req: Request, res: Response): void {
try {
const settingsPath = join(homedir(), '.claude', 'settings.json');
if (!existsSync(settingsPath)) {
// Return defaults if file doesn't exist
res.json({
CLAUDE_MEM_MODEL: 'claude-haiku-4-5',
CLAUDE_MEM_CONTEXT_OBSERVATIONS: '50',
CLAUDE_MEM_WORKER_PORT: '37777'
});
return;
}
const settingsData = readFileSync(settingsPath, 'utf-8');
const settings = JSON.parse(settingsData);
const env = settings.env || {};
res.json({
CLAUDE_MEM_MODEL: env.CLAUDE_MEM_MODEL || 'claude-haiku-4-5',
CLAUDE_MEM_CONTEXT_OBSERVATIONS: env.CLAUDE_MEM_CONTEXT_OBSERVATIONS || '50',
CLAUDE_MEM_WORKER_PORT: env.CLAUDE_MEM_WORKER_PORT || '37777'
});
} catch (error: any) {
logger.error('WORKER', 'Failed to read settings', {}, error);
res.status(500).json({ error: 'Failed to read settings' });
}
}
/**
* POST /api/settings - Update settings in ~/.claude/settings.json
*/
private handlePostSettings(req: Request, res: Response): void {
try {
const { CLAUDE_MEM_MODEL, CLAUDE_MEM_CONTEXT_OBSERVATIONS, CLAUDE_MEM_WORKER_PORT } = req.body;
// Validate inputs
const validModels = ['claude-haiku-4-5', 'claude-sonnet-4-5', 'claude-opus-4'];
if (CLAUDE_MEM_MODEL && !validModels.includes(CLAUDE_MEM_MODEL)) {
res.status(400).json({ success: false, error: `Invalid model name: ${CLAUDE_MEM_MODEL}` });
return;
}
if (CLAUDE_MEM_CONTEXT_OBSERVATIONS) {
const obsCount = parseInt(CLAUDE_MEM_CONTEXT_OBSERVATIONS, 10);
if (isNaN(obsCount) || obsCount < 1 || obsCount > 200) {
res.status(400).json({ success: false, error: 'CLAUDE_MEM_CONTEXT_OBSERVATIONS must be between 1 and 200' });
return;
}
}
if (CLAUDE_MEM_WORKER_PORT) {
const port = parseInt(CLAUDE_MEM_WORKER_PORT, 10);
if (isNaN(port) || port < 1024 || port > 65535) {
res.status(400).json({ success: false, error: 'CLAUDE_MEM_WORKER_PORT must be between 1024 and 65535' });
return;
}
}
// Read existing settings
const settingsPath = join(homedir(), '.claude', 'settings.json');
let settings: any = { env: {} };
if (existsSync(settingsPath)) {
const settingsData = readFileSync(settingsPath, 'utf-8');
settings = JSON.parse(settingsData);
if (!settings.env) {
settings.env = {};
}
}
// Update settings
if (CLAUDE_MEM_MODEL) {
settings.env.CLAUDE_MEM_MODEL = CLAUDE_MEM_MODEL;
}
if (CLAUDE_MEM_CONTEXT_OBSERVATIONS) {
settings.env.CLAUDE_MEM_CONTEXT_OBSERVATIONS = CLAUDE_MEM_CONTEXT_OBSERVATIONS;
}
if (CLAUDE_MEM_WORKER_PORT) {
settings.env.CLAUDE_MEM_WORKER_PORT = CLAUDE_MEM_WORKER_PORT;
}
// Write back
writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
logger.info('WORKER', 'Settings updated', {});
res.json({ success: true, message: 'Settings updated successfully' });
} catch (error: any) {
logger.error('WORKER', 'Failed to update settings', {}, error);
res.status(500).json({ success: false, error: 'Failed to update settings' });
}
}
/**
* GET /api/observations - Paginated observations fetch
* Query params: offset (default 0), limit (default 50), project (optional)
*/
private handleGetObservations(req: Request, res: Response): void {
try {
const offset = parseInt(req.query.offset as string || '0', 10);
const limit = Math.min(parseInt(req.query.limit as string || '50', 10), 100); // Cap at 100
const project = req.query.project as string | undefined;
const db = new SessionStore();
// Build query with optional project filter
let query = `
SELECT id, type, title, subtitle, text, project, prompt_number, created_at, created_at_epoch
FROM observations
`;
let countQuery = 'SELECT COUNT(*) as total FROM observations';
const params: any[] = [];
const countParams: any[] = [];
if (project) {
query += ' WHERE project = ?';
countQuery += ' WHERE project = ?';
params.push(project);
countParams.push(project);
}
query += ' ORDER BY created_at_epoch DESC LIMIT ? OFFSET ?';
params.push(limit, offset);
const stmt = db.db.prepare(query);
const observations = stmt.all(...params);
// Check if there are more results
const countStmt = db.db.prepare(countQuery);
const { total } = countStmt.get(...countParams) as { total: number };
const hasMore = (offset + limit) < total;
db.close();
res.json({
observations,
hasMore,
total,
offset,
limit
});
} catch (error: any) {
logger.error('WORKER', 'Failed to get observations', {}, error);
res.status(500).json({ error: 'Failed to get observations' });
}
}
private handleGetSummaries(req: Request, res: Response): void {
try {
const offset = parseInt(req.query.offset as string || '0', 10);
const limit = Math.min(parseInt(req.query.limit as string || '50', 10), 100); // Cap at 100
const project = req.query.project as string | undefined;
const db = new SessionStore();
// Build query with optional project filter
// JOIN with sdk_sessions to get claude_session_id (needed for UI matching with processingSessions)
let query = `
SELECT
ss.id,
s.claude_session_id as session_id,
ss.request,
ss.learned,
ss.completed,
ss.next_steps,
ss.project,
ss.created_at,
ss.created_at_epoch
FROM session_summaries ss
JOIN sdk_sessions s ON ss.sdk_session_id = s.sdk_session_id
`;
let countQuery = 'SELECT COUNT(*) as total FROM session_summaries';
const params: any[] = [];
const countParams: any[] = [];
if (project) {
query += ' WHERE ss.project = ?';
countQuery += ' WHERE project = ?';
params.push(project);
countParams.push(project);
}
query += ' ORDER BY ss.created_at_epoch DESC LIMIT ? OFFSET ?';
params.push(limit, offset);
const stmt = db.db.prepare(query);
const summaries = stmt.all(...params);
// Check if there are more results
const countStmt = db.db.prepare(countQuery);
const { total } = countStmt.get(...countParams) as { total: number };
const hasMore = (offset + limit) < total;
db.close();
res.json({
summaries,
hasMore,
total,
offset,
limit
});
} catch (error: any) {
logger.error('WORKER', 'Failed to get summaries', {}, error);
res.status(500).json({ error: 'Failed to get summaries' });
}
}
private handleGetPrompts(req: Request, res: Response): void {
try {
const offset = parseInt(req.query.offset as string || '0', 10);
const limit = Math.min(parseInt(req.query.limit as string || '50', 10), 100); // Cap at 100
const project = req.query.project as string | undefined;
const db = new SessionStore();
// Build query with optional project filter - JOIN with sdk_sessions to get project
let query = `
SELECT up.id, up.claude_session_id, s.project, up.prompt_number, up.prompt_text, up.created_at, up.created_at_epoch
FROM user_prompts up
JOIN sdk_sessions s ON up.claude_session_id = s.claude_session_id
`;
let countQuery = `
SELECT COUNT(*) as total
FROM user_prompts up
JOIN sdk_sessions s ON up.claude_session_id = s.claude_session_id
`;
const params: any[] = [];
const countParams: any[] = [];
if (project) {
query += ' WHERE s.project = ?';
countQuery += ' WHERE s.project = ?';
params.push(project);
countParams.push(project);
}
query += ' ORDER BY created_at_epoch DESC LIMIT ? OFFSET ?';
params.push(limit, offset);
const stmt = db.db.prepare(query);
const prompts = stmt.all(...params);
// Check if there are more results
const countStmt = db.db.prepare(countQuery);
const { total } = countStmt.get(...countParams) as { total: number };
const hasMore = (offset + limit) < total;
db.close();
res.json({
prompts,
hasMore,
total,
offset,
limit
});
} catch (error: any) {
logger.error('WORKER', 'Failed to get prompts', {}, error);
res.status(500).json({ error: 'Failed to get prompts' });
}
}
/**
* POST /sessions/:sessionDbId/init
* Body: { project, userPrompt }
@@ -208,6 +652,21 @@ class WorkerService {
db.close();
// Broadcast new prompt to SSE clients (for web UI)
if (latestPrompt) {
this.broadcastSSE({
type: 'new_prompt',
prompt: {
id: latestPrompt.id,
claude_session_id: latestPrompt.claude_session_id,
project: latestPrompt.project,
prompt_number: latestPrompt.prompt_number,
prompt_text: latestPrompt.prompt_text,
created_at_epoch: latestPrompt.created_at_epoch
}
});
}
// Sync user prompt to Chroma (fire-and-forget, but crash on failure)
if (latestPrompt) {
this.chromaSync.syncUserPrompt(
@@ -296,6 +755,9 @@ class WorkerService {
prompt_number
});
// Don't broadcast processing status for observations - only for summaries
// Observations are processed continuously, skeleton should only show during summary generation
res.json({ status: 'queued', queueLength: session.pendingMessages.length });
}
@@ -351,6 +813,9 @@ class WorkerService {
prompt_number
});
// Notify UI that processing is active
this.broadcastProcessingStatus(session.claudeSessionId, true);
res.json({ status: 'queued', queueLength: session.pendingMessages.length });
}
@@ -612,6 +1077,21 @@ class WorkerService {
id
});
// Broadcast to SSE clients (for web UI)
this.broadcastSSE({
type: 'new_observation',
observation: {
id,
session_id: session.claudeSessionId,
type: obs.type,
title: obs.title,
subtitle: obs.subtitle,
project: session.project,
prompt_number: promptNumber,
created_at_epoch: createdAtEpoch
}
});
// Sync to Chroma (non-blocking fire-and-forget, but crash on failure)
this.chromaSync.syncObservation(
id,
@@ -651,6 +1131,27 @@ class WorkerService {
const { id, createdAtEpoch } = db.storeSummary(session.claudeSessionId, session.project, summary, promptNumber);
logger.success('DB', '📝 SUMMARY STORED IN DATABASE', { sessionId: session.sessionDbId, promptNumber, id });
// Broadcast to SSE clients (for web UI)
this.broadcastSSE({
type: 'new_summary',
summary: {
id,
session_id: session.claudeSessionId,
request: summary.request,
investigated: summary.investigated,
learned: summary.learned,
completed: summary.completed,
next_steps: summary.next_steps,
notes: summary.notes,
project: session.project,
prompt_number: promptNumber,
created_at_epoch: createdAtEpoch
}
});
// Notify UI that processing is complete (summary is the final step)
this.broadcastProcessingStatus(session.claudeSessionId, false);
// Sync to Chroma (non-blocking fire-and-forget, but crash on failure)
this.chromaSync.syncSummary(
id,
@@ -677,6 +1178,9 @@ class WorkerService {
promptNumber,
contentSample: content.substring(0, 500)
});
// Still mark processing as complete even if no summary was generated
this.broadcastProcessingStatus(session.claudeSessionId, false);
}
db.close();