Introduce ProgressSubtype union ("hook" | "bash" | "mcp" | "agent") and
three new fields on ParsedMessage: toolUseId, parentToolUseId, and
progressSubtype. These enable linking hook_progress events to the
tool_call that spawned them and classifying progress by source.
Session parser changes:
- Extract `id` from tool_use content blocks into toolUseId
- Extract `tool_use_id` from tool_result blocks into toolUseId (was
previously misassigned to toolName)
- Read `parentToolUseID` from raw progress lines
- Derive progressSubtype from the `data.type` field using a new
deriveProgressSubtype() helper
- Add `toolProgress` map to SessionDetailResponse for grouped progress
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
256 lines
7.3 KiB
TypeScript
256 lines
7.3 KiB
TypeScript
import fs from "fs/promises";
|
|
import type { ParsedMessage, ProgressSubtype } 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;
|
|
id?: 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;
|
|
parentToolUseID?: 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";
|
|
const dataType = typeof data?.type === "string" ? data.type : "";
|
|
const progressSubtype = deriveProgressSubtype(dataType);
|
|
messages.push({
|
|
uuid,
|
|
category: "hook_progress",
|
|
content: progressText,
|
|
timestamp,
|
|
rawIndex,
|
|
parentToolUseId: raw.parentToolUseID,
|
|
progressSubtype,
|
|
});
|
|
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,
|
|
toolUseId: 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,
|
|
toolUseId: block.id,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
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>");
|
|
}
|
|
|
|
function deriveProgressSubtype(dataType: string): ProgressSubtype {
|
|
if (dataType === "bash_progress") return "bash";
|
|
if (dataType === "mcp_progress") return "mcp";
|
|
if (dataType === "agent_progress") return "agent";
|
|
return "hook";
|
|
}
|