Add Express server: session discovery, JSONL parser, HTML exporter, API routes

Server-side implementation for reading, parsing, and exporting Claude
Code session logs:

session-discovery.ts:
- Walks ~/.claude/projects/ directories, reads sessions-index.json from
  each project folder, supports both the current {version, entries}
  format and the legacy raw array format
- Aggregates sessions across all projects, sorted by most recently
  modified first
- Gracefully handles missing directories, corrupt index files, and
  missing entries

session-parser.ts:
- Parses Claude Code JSONL session files line-by-line into normalized
  ParsedMessage objects
- Handles the full real-world format: type="user" (string or
  ContentBlock array content), type="assistant" (text, thinking,
  tool_use blocks), type="progress" (hook events with structured data
  fields), type="summary" (summary text field), type="file-history-
  snapshot", and silently skips type="system" (turn_duration metadata)
  and type="queue-operation" (internal)
- Detects <system-reminder> tags in user messages and reclassifies them
  as system_message category
- Resilient to malformed JSONL lines (skips with continue, no crash)

html-exporter.ts:
- Generates self-contained HTML exports with no external dependencies —
  all CSS (layout, category-specific colors, syntax highlighting) is
  inlined in a <style> block
- Dark theme (GitHub dark palette) with category-specific left border
  colors and backgrounds matching the Claude Code aesthetic
- Renders markdown content via marked + highlight.js with syntax
  highlighting, inserts "content redacted" dividers where redacted
  messages were removed
- Outputs a complete <!DOCTYPE html> document with session metadata
  header (project name, date, message count)

routes/sessions.ts:
- GET /api/sessions — returns all discovered sessions with 30-second
  in-memory cache to avoid re-scanning the filesystem on every request
- GET /api/sessions/:id — looks up session by ID from cache, parses
  the JSONL file, returns parsed messages

routes/export.ts:
- POST /api/export — accepts ExportRequest body, validates required
  fields, generates HTML via the exporter, returns as a downloadable
  attachment with sanitized filename

index.ts:
- Express app factory (createApp) with 50MB JSON body limit, health
  check endpoint, session and export routers, and static file serving
  for the built client
- Dual-bind to localhost and Tailscale IP (100.84.4.113) for local +
  tailnet access, with optional browser-open on startup via
  SESSION_VIEWER_OPEN_BROWSER env var
- Auto-start guard: only calls startServer() when run directly, not
  when imported by tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-29 22:56:10 -05:00
parent c4e15bf082
commit 090d69a97a
6 changed files with 672 additions and 0 deletions

View File

@@ -0,0 +1,80 @@
import fs from "fs/promises";
import path from "path";
import os from "os";
import type { SessionEntry } from "../../shared/types.js";
interface IndexEntry {
sessionId: string;
summary?: string;
firstPrompt?: string;
created?: string;
modified?: string;
messageCount?: number;
fullPath?: string;
projectPath?: string;
}
const CLAUDE_PROJECTS_DIR = path.join(os.homedir(), ".claude", "projects");
export async function discoverSessions(
projectsDir: string = CLAUDE_PROJECTS_DIR
): Promise<SessionEntry[]> {
const sessions: SessionEntry[] = [];
let projectDirs: string[];
try {
projectDirs = await fs.readdir(projectsDir);
} catch {
return sessions;
}
for (const projectDir of projectDirs) {
const projectPath = path.join(projectsDir, projectDir);
let stat;
try {
stat = await fs.stat(projectPath);
} catch {
continue;
}
if (!stat.isDirectory()) continue;
const indexPath = path.join(projectPath, "sessions-index.json");
try {
const content = await fs.readFile(indexPath, "utf-8");
const parsed = JSON.parse(content);
// Handle both formats: raw array or { version, entries: [...] }
const entries: IndexEntry[] = Array.isArray(parsed)
? parsed
: parsed.entries ?? [];
for (const entry of entries) {
const sessionPath =
entry.fullPath ||
path.join(projectPath, `${entry.sessionId}.jsonl`);
sessions.push({
id: entry.sessionId,
summary: entry.summary || "",
firstPrompt: entry.firstPrompt || "",
project: projectDir,
created: entry.created || "",
modified: entry.modified || "",
messageCount: entry.messageCount || 0,
path: sessionPath,
});
}
} catch {
// Missing or corrupt index - skip
}
}
sessions.sort((a, b) => {
const dateA = new Date(a.modified || a.created || 0).getTime() || 0;
const dateB = new Date(b.modified || b.created || 0).getTime() || 0;
return dateB - dateA;
});
return sessions;
}