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,241 @@
import fs from "fs/promises";
import type { ParsedMessage } from "../../shared/types.js";
/**
* Real Claude Code JSONL format (verified from actual session files):
*
* Every line has: { type, uuid?, timestamp?, ... }
*
* type="user" → { message: { role: "user", content: string | ContentBlock[] }, uuid, timestamp }
* type="assistant" → { message: { role: "assistant", content: ContentBlock[] }, uuid, timestamp }
* type="progress" → { data: { type, hookEvent?, status?, ... }, uuid, timestamp }
* type="summary" → { summary: string, leafUuid }
* type="file-history-snapshot" → { snapshot: { ... }, messageId }
* type="system" → { subtype: "turn_duration", durationMs, ... } (metadata, not display)
* type="queue-operation" → internal (not display)
*
* ContentBlock: { type: "text", text } | { type: "thinking", thinking } | { type: "tool_use", name, input } | { type: "tool_result", content, tool_use_id }
*/
interface ContentBlock {
type: string;
text?: string;
thinking?: string;
name?: string;
input?: Record<string, unknown>;
tool_use_id?: string;
content?: string | ContentBlock[];
}
interface RawLine {
type?: string;
uuid?: string;
timestamp?: string;
message?: {
role?: string;
content?: string | ContentBlock[];
};
data?: Record<string, unknown>;
summary?: string;
snapshot?: Record<string, unknown>;
subtype?: string;
}
export async function parseSession(
filePath: string
): Promise<ParsedMessage[]> {
let content: string;
try {
content = await fs.readFile(filePath, "utf-8");
} catch {
return [];
}
return parseSessionContent(content);
}
export function parseSessionContent(content: string): ParsedMessage[] {
const messages: ParsedMessage[] = [];
const lines = content.split("\n").filter((l) => l.trim());
for (let i = 0; i < lines.length; i++) {
let parsed: RawLine;
try {
parsed = JSON.parse(lines[i]);
} catch {
continue; // Skip malformed lines
}
const extracted = extractMessages(parsed, i);
messages.push(...extracted);
}
return messages;
}
function extractMessages(raw: RawLine, rawIndex: number): ParsedMessage[] {
const messages: ParsedMessage[] = [];
const type = raw.type;
const uuid = raw.uuid || `generated-${rawIndex}`;
const timestamp = raw.timestamp;
// Progress/hook messages - content is in `data`, not `content`
if (type === "progress") {
const data = raw.data;
const progressText = data
? formatProgressData(data)
: "Progress event";
messages.push({
uuid,
category: "hook_progress",
content: progressText,
timestamp,
rawIndex,
});
return messages;
}
// File history snapshot
if (type === "file-history-snapshot") {
messages.push({
uuid,
category: "file_snapshot",
content: JSON.stringify(raw.snapshot || raw, null, 2),
timestamp,
rawIndex,
});
return messages;
}
// Summary message - text is in `summary` field, not `content`
if (type === "summary") {
messages.push({
uuid,
category: "summary",
content: raw.summary || "",
timestamp,
rawIndex,
});
return messages;
}
// System metadata (turn_duration etc.) - skip, not user-facing
if (type === "system" || type === "queue-operation") {
return messages;
}
// User and assistant messages - content is in `message.content`
const role = raw.message?.role;
const content = raw.message?.content;
if ((type === "user" || role === "user") && content !== undefined) {
if (typeof content === "string") {
const category = detectSystemReminder(content)
? "system_message"
: "user_message";
messages.push({
uuid,
category,
content,
timestamp,
rawIndex,
});
} else if (Array.isArray(content)) {
for (const block of content) {
if (block.type === "tool_result") {
const resultText =
typeof block.content === "string"
? block.content
: Array.isArray(block.content)
? block.content
.map((b: ContentBlock) => b.text || "")
.join("\n")
: JSON.stringify(block.content);
messages.push({
uuid: `${uuid}-tr-${block.tool_use_id || rawIndex}`,
category: "tool_result",
content: resultText,
toolName: block.tool_use_id,
timestamp,
rawIndex,
});
} else if (block.type === "text") {
const text = block.text || "";
const category = detectSystemReminder(text)
? "system_message"
: "user_message";
messages.push({
uuid: `${uuid}-text-${rawIndex}`,
category,
content: text,
timestamp,
rawIndex,
});
}
}
}
return messages;
}
if ((type === "assistant" || role === "assistant") && content !== undefined) {
if (typeof content === "string") {
messages.push({
uuid,
category: "assistant_text",
content,
timestamp,
rawIndex,
});
} else if (Array.isArray(content)) {
for (let j = 0; j < content.length; j++) {
const block = content[j];
const blockUuid = `${uuid}-${j}`;
if (block.type === "thinking") {
messages.push({
uuid: blockUuid,
category: "thinking",
content: block.thinking || block.text || "",
timestamp,
rawIndex,
});
} else if (block.type === "text") {
messages.push({
uuid: blockUuid,
category: "assistant_text",
content: block.text || "",
timestamp,
rawIndex,
});
} else if (block.type === "tool_use") {
messages.push({
uuid: blockUuid,
category: "tool_call",
content: `Tool: ${block.name}`,
toolName: block.name,
toolInput: JSON.stringify(block.input, null, 2),
timestamp,
rawIndex,
});
}
}
}
return messages;
}
return messages;
}
function formatProgressData(data: Record<string, unknown>): string {
const parts: string[] = [];
if (data.hookEvent) parts.push(`Hook: ${data.hookEvent}`);
if (data.hookName) parts.push(`${data.hookName}`);
if (data.toolName) parts.push(`Tool: ${data.toolName}`);
if (data.status) parts.push(`Status: ${data.status}`);
if (data.serverName) parts.push(`Server: ${data.serverName}`);
if (parts.length > 0) return parts.join(" | ");
return JSON.stringify(data);
}
function detectSystemReminder(text: string): boolean {
return text.includes("<system-reminder>") || text.includes("</system-reminder>");
}