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