| 1 | /** |
| 2 | * How a session's entries read on its page: tool calls run together |
| 3 | * between its messages, each with what it was called on. |
| 4 | */ |
| 5 | import type { SessionEntry } from "@g1t/contracts"; |
| 6 | |
| 7 | /** A tool call as it reads: which tool, on what, and of what sort. */ |
| 8 | export type ToolCall = { |
| 9 | /** The tool's own name, as `issue` of g1t's `issue` over MCP. */ |
| 10 | name: string; |
| 11 | /** The MCP server it came from, if any: `g1t`. */ |
| 12 | server: string | null; |
| 13 | /** What it was called with, without the tool's name in front. */ |
| 14 | detail: string; |
| 15 | /** A command line, a file, or anything else. */ |
| 16 | sort: "shell" | "file" | "other"; |
| 17 | }; |
| 18 | |
| 19 | const SHELLS = new Set(["bash", "sh", "zsh", "shell", "powershell", "pwsh", "cmd"]); |
| 20 | const FILES = new Set(["read", "write", "edit", "multiedit", "notebookedit", "notebook_edit"]); |
| 21 | |
| 22 | /** A server's name from Claude Code's `mcp__<server>__<tool>`: `plugin_g1t_g1t` is g1t, `claude_ai_Google_Drive` Google Drive. */ |
| 23 | function serverName(raw: string): string { |
| 24 | const plugin = /^plugin_[^_]+_(.+)$/.exec(raw); |
| 25 | const name = plugin ? plugin[1] : raw.replace(/^claude_ai_/, ""); |
| 26 | return name.replaceAll("_", " "); |
| 27 | } |
| 28 | |
| 29 | /** |
| 30 | * A tool call's entry, read: its tool (the entry's own, else the name its |
| 31 | * text starts with), what it was called with, with that name taken off the |
| 32 | * front where the text repeats it, and its MCP server (`g1t:issue` from a |
| 33 | * session g1t ran, `mcp__plugin_g1t_g1t__issue` from Claude Code). |
| 34 | */ |
| 35 | export function readToolCall(entry: Pick<SessionEntry, "text" | "tool">): ToolCall { |
| 36 | const text = entry.text.trim(); |
| 37 | let tool = entry.tool?.trim() || null; |
| 38 | let detail = text; |
| 39 | if (tool) { |
| 40 | const prefix = `${tool}:`; |
| 41 | if (text.toLowerCase() === tool.toLowerCase()) detail = ""; |
| 42 | else if (text.toLowerCase().startsWith(prefix.toLowerCase())) detail = text.slice(prefix.length).trim(); |
| 43 | } else { |
| 44 | // A name, then ": " and what it was called with (the plugin's summary). |
| 45 | const match = /^([A-Za-z][\w.:-]*?):\s+([\s\S]*)$/.exec(text) ?? (/^[A-Za-z][\w.:-]*$/.test(text) ? [text, text, ""] : null); |
| 46 | if (match) { |
| 47 | tool = match[1]; |
| 48 | detail = match[2].trim(); |
| 49 | } |
| 50 | } |
| 51 | let name = tool ?? "tool"; |
| 52 | let server: string | null = null; |
| 53 | const mcp = /^mcp__(.+?)__(.+)$/.exec(name); |
| 54 | if (mcp) { |
| 55 | server = serverName(mcp[1]); |
| 56 | name = mcp[2]; |
| 57 | } else if (name.includes(":")) { |
| 58 | const at = name.indexOf(":"); |
| 59 | server = name.slice(0, at); |
| 60 | name = name.slice(at + 1); |
| 61 | } |
| 62 | const key = name.toLowerCase(); |
| 63 | // An MCP tool's name reads as words: g1t's `pull_request` is "pull request". |
| 64 | if (server) name = name.replaceAll("_", " "); |
| 65 | const sort = server ? "other" : SHELLS.has(key) ? "shell" : FILES.has(key) && detail ? "file" : "other"; |
| 66 | return { name, server, detail, sort }; |
| 67 | } |
| 68 | |
| 69 | /** One tool call and what came back from it. */ |
| 70 | export type ToolStep = { call: SessionEntry | null; results: SessionEntry[] }; |
| 71 | |
| 72 | /** A part of the transcript: one entry, or tool calls that ran one after another. */ |
| 73 | export type TranscriptItem = { kind: "entry"; entry: SessionEntry } | { kind: "tools"; steps: ToolStep[]; first: SessionEntry }; |
| 74 | |
| 75 | /** |
| 76 | * Entries as the transcript shows them: prompts, messages, skills and notes |
| 77 | * one by one, and each run of tool calls between them as one group, every |
| 78 | * result under the call before it. |
| 79 | */ |
| 80 | export function transcript(entries: SessionEntry[]): TranscriptItem[] { |
| 81 | const items: TranscriptItem[] = []; |
| 82 | let group: Extract<TranscriptItem, { kind: "tools" }> | null = null; |
| 83 | for (const entry of entries) { |
| 84 | if (entry.kind !== "tool_call" && entry.kind !== "tool_result") { |
| 85 | group = null; |
| 86 | items.push({ kind: "entry", entry }); |
| 87 | continue; |
| 88 | } |
| 89 | if (!group) { |
| 90 | group = { kind: "tools", steps: [], first: entry }; |
| 91 | items.push(group); |
| 92 | } |
| 93 | const last = group.steps.at(-1); |
| 94 | if (entry.kind === "tool_call") group.steps.push({ call: entry, results: [] }); |
| 95 | else if (last) last.results.push(entry); |
| 96 | else group.steps.push({ call: null, results: [entry] }); |
| 97 | } |
| 98 | return items; |
| 99 | } |
| 100 | |
| 101 | /** |
| 102 | * A prompt with g1t's `<issue>` and `<comment>` blocks (what it quotes of |
| 103 | * an issue or a comment) as quotes under a label, so they read as markdown |
| 104 | * rather than as tags that vanish. |
| 105 | */ |
| 106 | export function quotedBlocks(text: string): string { |
| 107 | return text.replace(/^<(issue|comment)\b[^>\n]*>\n([\s\S]*?)\n<\/\1>[ \t]*$/gm, (_, tag: string, body: string) => { |
| 108 | const label = tag === "issue" ? "**The issue**" : "**The comment**"; |
| 109 | // A blank line after it, so the text that follows is not part of the quote. |
| 110 | return [label, "", ...body.split("\n").map((line) => (line ? `> ${line}` : ">")), ""].join("\n"); |
| 111 | }); |
| 112 | } |