From 090d69a97ac96e04127b71b42666e9b68db67589 Mon Sep 17 00:00:00 2001 From: teernisse Date: Thu, 29 Jan 2026 22:56:10 -0500 Subject: [PATCH] Add Express server: session discovery, JSONL parser, HTML exporter, API routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 + + +
+
+

Session Export

+
+ Project: ${escapeHtml(session.project)} + Date: ${escapeHtml(dateStr)} + ${messageCount} messages +
+
+
+ ${messageHtmlParts.join("\n ")} +
+
+ +`; +} + +function renderMessage(msg: ParsedMessage): string { + const categoryClass = msg.category.replace(/_/g, "-"); + const label = CATEGORY_LABELS[msg.category]; + let bodyHtml: string; + + if (msg.category === "tool_call") { + const inputHtml = msg.toolInput + ? `
${escapeHtml(msg.toolInput)}
` + : ""; + bodyHtml = `
${escapeHtml(msg.toolName || "Unknown Tool")}
${inputHtml}`; + } else { + bodyHtml = renderMarkdown(msg.content); + } + + return `
+
${escapeHtml(label)}
+
${bodyHtml}
+
`; +} + +function renderMarkdown(text: string): string { + try { + return marked.parse(text) as string; + } catch { + return `

${escapeHtml(text)}

`; + } +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function getHighlightCss(): string { + return ` +.hljs{color:#e6edf3;background:#161b22} +.hljs-comment,.hljs-quote{color:#8b949e;font-style:italic} +.hljs-keyword,.hljs-selector-tag,.hljs-subst{color:#ff7b72;font-weight:bold} +.hljs-number,.hljs-literal,.hljs-variable,.hljs-template-variable,.hljs-tag .hljs-attr{color:#79c0ff} +.hljs-string,.hljs-doctag{color:#a5d6ff} +.hljs-title,.hljs-section,.hljs-selector-id{color:#d2a8ff;font-weight:bold} +.hljs-type,.hljs-class .hljs-title{color:#d2a8ff} +.hljs-tag,.hljs-name,.hljs-attribute{color:#7ee787} +.hljs-regexp,.hljs-link{color:#a5d6ff} +.hljs-symbol,.hljs-bullet{color:#ffa657} +.hljs-built_in,.hljs-builtin-name{color:#79c0ff} +.hljs-meta{color:#d29922;font-weight:bold} +.hljs-deletion{background:#3d1f28;color:#ffa198} +.hljs-addition{background:#1a3a2a;color:#7ee787} +.hljs-emphasis{font-style:italic} +.hljs-strong{font-weight:bold} +`; +} + +function getExportCss(): string { + return ` +* { margin: 0; padding: 0; box-sizing: border-box; } +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: #0d1117; color: #e6edf3; line-height: 1.6; +} +.session-export { max-width: 900px; margin: 0 auto; padding: 2rem 1rem; } +.session-header { + background: #161b22; color: #e6edf3; padding: 1.5rem 2rem; + border-radius: 12px; margin-bottom: 2rem; border: 1px solid #30363d; +} +.session-header h1 { font-size: 1.5rem; margin-bottom: 0.5rem; } +.session-header .meta { display: flex; gap: 1.5rem; font-size: 0.875rem; color: #8b949e; flex-wrap: wrap; } +.messages { display: flex; flex-direction: column; gap: 1rem; } +.message { + padding: 1rem 1.25rem; border-radius: 10px; + border-left: 4px solid #30363d; background: #161b22; + box-shadow: 0 1px 3px rgba(0,0,0,0.3); +} +.message-label { + font-size: 0.75rem; font-weight: 600; text-transform: uppercase; + letter-spacing: 0.05em; margin-bottom: 0.5rem; color: #8b949e; +} +.message-body { overflow-wrap: break-word; } +.message-body pre { + background: #0d1117; padding: 1rem; border-radius: 6px; + overflow-x: auto; font-size: 0.875rem; margin: 0.5rem 0; + border: 1px solid #30363d; +} +.message-body code { font-family: 'SF Mono', 'Fira Code', monospace; font-size: 0.875em; } +.message-body p { margin: 0.5em 0; } +.message-body ul, .message-body ol { padding-left: 1.5em; margin: 0.5em 0; } +.message-body h1,.message-body h2,.message-body h3 { margin: 0.75em 0 0.25em; color: #f0f6fc; } +.message-body a { color: #58a6ff; } +.message-body table { border-collapse: collapse; margin: 0.5em 0; width: 100%; } +.message-body th, .message-body td { border: 1px solid #30363d; padding: 0.4em 0.75em; text-align: left; } +.message-body th { background: #1c2128; } +.message-body blockquote { border-left: 3px solid #30363d; padding-left: 1em; color: #8b949e; margin: 0.5em 0; } +.message-body hr { border: none; border-top: 1px solid #30363d; margin: 1em 0; } +.user-message { border-left-color: #58a6ff; background: #121d2f; } +.assistant-text { border-left-color: #3fb950; background: #161b22; } +.thinking { border-left-color: #bc8cff; background: #1c1631; } +.tool-call { border-left-color: #d29922; background: #1c1a10; } +.tool-result { border-left-color: #8b8cf8; background: #181830; } +.system-message { border-left-color: #8b949e; background: #1c2128; font-size: 0.875rem; } +.hook-progress { border-left-color: #484f58; background: #131820; font-size: 0.875rem; } +.file-snapshot { border-left-color: #f778ba; background: #241525; } +.summary { border-left-color: #2dd4bf; background: #122125; } +.tool-name { font-weight: 600; color: #d29922; margin-bottom: 0.5rem; } +.tool-input { font-size: 0.8rem; } +.redacted-divider { + text-align: center; color: #484f58; font-size: 0.875rem; + padding: 0.75rem 0; border-top: 1px dashed #30363d; border-bottom: 1px dashed #30363d; + margin: 0.5rem 0; +} +`; +} diff --git a/src/server/services/session-discovery.ts b/src/server/services/session-discovery.ts new file mode 100644 index 0000000..39517ed --- /dev/null +++ b/src/server/services/session-discovery.ts @@ -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 { + 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; +} diff --git a/src/server/services/session-parser.ts b/src/server/services/session-parser.ts new file mode 100644 index 0000000..3a1dccd --- /dev/null +++ b/src/server/services/session-parser.ts @@ -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; + tool_use_id?: string; + content?: string | ContentBlock[]; +} + +interface RawLine { + type?: string; + uuid?: string; + timestamp?: string; + message?: { + role?: string; + content?: string | ContentBlock[]; + }; + data?: Record; + summary?: string; + snapshot?: Record; + subtype?: string; +} + +export async function parseSession( + filePath: string +): Promise { + 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 { + 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("") || text.includes(""); +}