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:
241
src/server/services/session-parser.ts
Normal file
241
src/server/services/session-parser.ts
Normal 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>");
|
||||
}
|
||||
Reference in New Issue
Block a user