diff --git a/src/server/index.ts b/src/server/index.ts new file mode 100644 index 0000000..8c4183e --- /dev/null +++ b/src/server/index.ts @@ -0,0 +1,65 @@ +import express from "express"; +import path from "path"; +import { fileURLToPath } from "url"; +import { sessionsRouter } from "./routes/sessions.js"; +import { exportRouter } from "./routes/export.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export function createApp() { + const app = express(); + + app.use(express.json({ limit: "50mb" })); + + app.get("/api/health", (_req, res) => { + res.json({ status: "ok" }); + }); + + app.use("/api/sessions", sessionsRouter); + app.use("/api/export", exportRouter); + + // Serve static client files in production + const clientDist = path.resolve(__dirname, "../../dist/client"); + app.use(express.static(clientDist)); + app.get("*", (_req, res) => { + res.sendFile(path.join(clientDist, "index.html")); + }); + + return app; +} + +const TAILSCALE_IP = "100.84.4.113"; + +export function startServer() { + const PORT = parseInt(process.env.PORT || "3848", 10); + const app = createApp(); + + // Bind to both localhost and Tailscale — not the public interface + const localServer = app.listen(PORT, "127.0.0.1", () => { + console.log(`Session Viewer API running on http://localhost:${PORT}`); + }); + const tsServer = app.listen(PORT, TAILSCALE_IP, async () => { + console.log(`Session Viewer API running on http://${TAILSCALE_IP}:${PORT}`); + if (process.env.SESSION_VIEWER_OPEN_BROWSER === "1") { + const { default: open } = await import("open"); + open(`http://localhost:${PORT}`); + } + }); + + // Graceful shutdown so tsx watch can restart cleanly + function shutdown() { + localServer.close(); + tsServer.close(); + } + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + + return tsServer; +} + +// Only auto-start when run directly (not imported by tests) +const thisFile = fileURLToPath(import.meta.url); +const entryFile = process.argv[1]; +if (entryFile && path.resolve(entryFile) === path.resolve(thisFile)) { + startServer(); +} diff --git a/src/server/routes/export.ts b/src/server/routes/export.ts new file mode 100644 index 0000000..35c5379 --- /dev/null +++ b/src/server/routes/export.ts @@ -0,0 +1,34 @@ +import { Router } from "express"; +import { generateExportHtml } from "../services/html-exporter.js"; +import type { ExportRequest } from "../../shared/types.js"; + +export const exportRouter = Router(); + +exportRouter.post("/", async (req, res) => { + try { + const exportReq = req.body as ExportRequest; + if ( + !exportReq?.session?.messages || + !Array.isArray(exportReq.session.messages) || + !Array.isArray(exportReq.visibleMessageUuids) + ) { + res.status(400).json({ error: "Invalid export request: missing session, messages, or visibleMessageUuids" }); + return; + } + const html = await generateExportHtml(exportReq); + // Sanitize session ID for use in filename - strip anything not alphanumeric/dash/underscore + const safeId = (exportReq.session.id || "export").replace( + /[^a-zA-Z0-9_-]/g, + "_" + ); + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.setHeader( + "Content-Disposition", + `attachment; filename="session-${safeId}.html"` + ); + res.send(html); + } catch (err) { + console.error("Failed to generate export:", err); + res.status(500).json({ error: "Failed to generate export" }); + } +}); diff --git a/src/server/routes/sessions.ts b/src/server/routes/sessions.ts new file mode 100644 index 0000000..3a54c4a --- /dev/null +++ b/src/server/routes/sessions.ts @@ -0,0 +1,50 @@ +import { Router } from "express"; +import { discoverSessions } from "../services/session-discovery.js"; +import { parseSession } from "../services/session-parser.js"; +import type { SessionEntry } from "../../shared/types.js"; + +export const sessionsRouter = Router(); + +// Simple cache to avoid re-discovering sessions on every detail request +let cachedSessions: SessionEntry[] = []; +let cacheTimestamp = 0; +const CACHE_TTL_MS = 30_000; + +async function getCachedSessions(): Promise { + const now = Date.now(); + if (now - cacheTimestamp > CACHE_TTL_MS) { + cachedSessions = await discoverSessions(); + cacheTimestamp = now; + } + return cachedSessions; +} + +sessionsRouter.get("/", async (_req, res) => { + try { + const sessions = await getCachedSessions(); + res.json({ sessions }); + } catch (err) { + console.error("Failed to discover sessions:", err); + res.status(500).json({ error: "Failed to discover sessions" }); + } +}); + +sessionsRouter.get("/:id", async (req, res) => { + try { + const sessions = await getCachedSessions(); + const entry = sessions.find((s) => s.id === req.params.id); + if (!entry) { + res.status(404).json({ error: "Session not found" }); + return; + } + const messages = await parseSession(entry.path); + res.json({ + id: entry.id, + project: entry.project, + messages, + }); + } catch (err) { + console.error("Failed to load session:", err); + res.status(500).json({ error: "Failed to load session" }); + } +}); diff --git a/src/server/services/html-exporter.ts b/src/server/services/html-exporter.ts new file mode 100644 index 0000000..5246a38 --- /dev/null +++ b/src/server/services/html-exporter.ts @@ -0,0 +1,202 @@ +import { marked } from "marked"; +import hljs from "highlight.js"; +import { markedHighlight } from "marked-highlight"; +import type { ExportRequest, ParsedMessage } from "../../shared/types.js"; +import { CATEGORY_LABELS } from "../../shared/types.js"; +import { redactMessage } from "../../shared/sensitive-redactor.js"; + +marked.use( + markedHighlight({ + highlight(code: string, lang: string) { + if (lang && hljs.getLanguage(lang)) { + return hljs.highlight(code, { language: lang }).value; + } + return hljs.highlightAuto(code).value; + }, + }) +); + +export async function generateExportHtml( + req: ExportRequest +): Promise { + const { session, visibleMessageUuids, redactedMessageUuids, autoRedactEnabled } = req; + + const visibleSet = new Set(visibleMessageUuids); + const redactedSet = new Set(redactedMessageUuids); + + const exportMessages = session.messages.filter( + (m) => visibleSet.has(m.uuid) && !redactedSet.has(m.uuid) + ); + + const messageHtmlParts: string[] = []; + const allVisibleOrdered = session.messages.filter((m) => + visibleSet.has(m.uuid) + ); + + let lastWasRedacted = false; + for (const msg of allVisibleOrdered) { + if (redactedSet.has(msg.uuid)) { + lastWasRedacted = true; + continue; + } + if (lastWasRedacted) { + messageHtmlParts.push( + '
··· content redacted ···
' + ); + lastWasRedacted = false; + } + const msgToRender = autoRedactEnabled ? redactMessage(msg) : msg; + messageHtmlParts.push(renderMessage(msgToRender)); + } + + const hljsCss = getHighlightCss(); + const messageCount = exportMessages.length; + const dateStr = session.messages[0]?.timestamp + ? new Date(session.messages[0].timestamp).toLocaleDateString() + : "Unknown date"; + + return ` + + + + +Session Export - ${escapeHtml(session.project)} + + + +
+
+

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(""); +}