Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

112 linesCodeBlameRaw
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 */
5import type { SessionEntry } from "@g1t/contracts";
6
7/** A tool call as it reads: which tool, on what, and of what sort. */
8export 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
19const SHELLS = new Set(["bash", "sh", "zsh", "shell", "powershell", "pwsh", "cmd"]);
20const 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. */
23function 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 */
35export 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. */
70export type ToolStep = { call: SessionEntry | null; results: SessionEntry[] };
71
72/** A part of the transcript: one entry, or tool calls that ran one after another. */
73export 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 */
80export 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 */
106export 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}