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:
65
src/server/index.ts
Normal file
65
src/server/index.ts
Normal file
@@ -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();
|
||||||
|
}
|
||||||
34
src/server/routes/export.ts
Normal file
34
src/server/routes/export.ts
Normal file
@@ -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" });
|
||||||
|
}
|
||||||
|
});
|
||||||
50
src/server/routes/sessions.ts
Normal file
50
src/server/routes/sessions.ts
Normal file
@@ -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<SessionEntry[]> {
|
||||||
|
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" });
|
||||||
|
}
|
||||||
|
});
|
||||||
202
src/server/services/html-exporter.ts
Normal file
202
src/server/services/html-exporter.ts
Normal file
@@ -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<string> {
|
||||||
|
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(
|
||||||
|
'<div class="redacted-divider">··· content redacted ···</div>'
|
||||||
|
);
|
||||||
|
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 `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Session Export - ${escapeHtml(session.project)}</title>
|
||||||
|
<style>
|
||||||
|
${getExportCss()}
|
||||||
|
${hljsCss}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="session-export">
|
||||||
|
<header class="session-header">
|
||||||
|
<h1>Session Export</h1>
|
||||||
|
<div class="meta">
|
||||||
|
<span class="project">Project: ${escapeHtml(session.project)}</span>
|
||||||
|
<span class="date">Date: ${escapeHtml(dateStr)}</span>
|
||||||
|
<span class="count">${messageCount} messages</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class="messages">
|
||||||
|
${messageHtmlParts.join("\n ")}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
? `<pre class="tool-input"><code>${escapeHtml(msg.toolInput)}</code></pre>`
|
||||||
|
: "";
|
||||||
|
bodyHtml = `<div class="tool-name">${escapeHtml(msg.toolName || "Unknown Tool")}</div>${inputHtml}`;
|
||||||
|
} else {
|
||||||
|
bodyHtml = renderMarkdown(msg.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<div class="message ${categoryClass}">
|
||||||
|
<div class="message-label">${escapeHtml(label)}</div>
|
||||||
|
<div class="message-body">${bodyHtml}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMarkdown(text: string): string {
|
||||||
|
try {
|
||||||
|
return marked.parse(text) as string;
|
||||||
|
} catch {
|
||||||
|
return `<p>${escapeHtml(text)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
80
src/server/services/session-discovery.ts
Normal file
80
src/server/services/session-discovery.ts
Normal 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;
|
||||||
|
}
|
||||||
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