1#!/usr/bin/env node
2// @effect-diagnostics nodeBuiltinImport:off
3/**
4 * Turns a raw CLIProxyAPI capture of one T3 Code turn into a template in `templates/`. Keeps only
5 * what run.ts reads, and drops user data. Usage: `pnpm mcp-tool-use:trim-capture
6 * <claude|codex> <capture.json> <out.json.gz>`. Read the output before you commit it.
7 */
8import * as NodeFS from "node:fs";
9import * as NodeZlib from "node:zlib";
10
11type Block = { readonly text?: string; readonly [key: string]: unknown };
12type Message = { readonly role?: string; content?: string | ReadonlyArray<Block> };
13type Item = Message & { readonly [key: string]: unknown };
14// The parts of a capture's request body that this script reads.
15type Native = {
16 readonly system: ReadonlyArray<Block & { readonly text: string }>;
17 readonly messages: ReadonlyArray<Item>;
18 readonly tools: unknown;
19 readonly input: ReadonlyArray<Item>;
20 readonly [key: string]: unknown;
21};
22
23// Skills and agents that ship with the harness. Everything else in a capture is the user's own.
24const CLAUDE_SKILLS = new Set([
25 "update-config",
26 "keybindings-help",
27 "code-review",
28 "simplify",
29 "fewer-permission-prompts",
30 "loop",
31 "claude-api",
32 "workflow-authoring",
33 "run",
34 "plugin-authoring",
35 "init",
36 "security-review",
37]);
38const CLAUDE_AGENTS = new Set(["Explore", "general-purpose", "Plan", "statusline-setup"]);
39// Codex skill roots: r1 is Codex's bundled skills, r0 and r2 the user's and the repo's.
40const USER_SKILL_LINE = /^- `r[02]` = |\(file: r[02]\//;
41// The models run.ts offers delegates. spawn_agent lists others, with details not yet public.
42const CODEX_MODELS = new Set(["gpt-6.1-sol", "gpt-6-astra", "gpt-6-luna"]);
43
44/** Keeps only the ids in CODEX_MODELS in spawn_agent's model list. */
45const trimModelList = (tool: unknown): unknown => {
46 if (Array.isArray(tool)) return tool.map(trimModelList);
47 if (typeof tool !== "object" || tool === null) return tool;
48 return Object.fromEntries(
49 Object.entries(tool).map(([key, value]) => [
50 key,
51 key === "description" && typeof value === "string" && value.includes("model overrides")
52 ? value
53 .split("\n")
54 .flatMap((line) => {
55 const id = /^- `([\w.-]+)`: /.exec(line)?.[1];
56 if (id === undefined) return [line];
57 return CODEX_MODELS.has(id) ? [`- \`${id}\``] : [];
58 })
59 .join("\n")
60 : trimModelList(value),
61 ]),
62 );
63};
64
65/** Keeps the `- name: ...` entries after `header`, with their continuation lines, in `keep`. */
66const keepEntries = (text: string, header: string, keep: ReadonlySet<string>) => {
67 const start = text.indexOf(header) + header.length;
68 const end = text.indexOf("\n\n", text.indexOf("\n- ", start));
69 let keeping = true;
70 const lines = text
71 .slice(start, end)
72 .split("\n")
73 .filter((line) => {
74 const name = /^- ([\w.-]+): /.exec(line)?.[1];
75 if (name !== undefined) keeping = keep.has(name);
76 return keeping || line === "";
77 });
78 return text.slice(0, start) + lines.join("\n") + text.slice(end);
79};
80
81const trimClaude = (native: Native) => {
82 const firstAssistant = native.messages.findIndex((message) => message.role === "assistant");
83 const head = structuredClone(
84 native.messages.slice(0, firstAssistant === -1 ? undefined : firstAssistant),
85 );
86 const [first] = head;
87 if (!first || !Array.isArray(first.content))
88 throw new Error("The capture has no first user turn.");
89 const blocks = (first.content as ReadonlyArray<Block>).flatMap((block) => {
90 // run.ts reads only text here. Images and documents are the user's attachments.
91 if (block.type !== "text") return [];
92 const text = block.text ?? "";
93 // The user's CLAUDE.md files.
94 if (text.includes("Codebase and user instructions are")) return [];
95 if (!text.includes("# gitStatus")) return [block];
96 // Each case writes its own git status into this slot.
97 return [
98 { ...block, text: "<system-reminder>\n# gitStatus\n(Set by each case.)\n</system-reminder>" },
99 ];
100 });
101 blocks[blocks.length - 1] = { ...blocks.at(-1), text: "(Set by each case.)" };
102 first.content = blocks;
103 for (const message of head.slice(1)) {
104 if (typeof message.content !== "string") continue;
105 let text = message.content;
106 // run.ts appends T3's current text after this marker.
107 const runtime = text.indexOf("<runtime_info>");
108 if (runtime !== -1) text = text.slice(0, runtime + "<runtime_info>".length);
109 const agents = "Available agent types for the Agent tool:\n";
110 if (text.includes(agents)) text = keepEntries(text, agents, CLAUDE_AGENTS);
111 const skills = /The following skills are available[^\n]*\n\n/.exec(text)?.[0];
112 if (skills) text = keepEntries(text, skills, CLAUDE_SKILLS);
113 message.content = text;
114 }
115 const system = native.system.filter(
116 (block) => !block.text.startsWith("x-anthropic-billing-header:"),
117 );
118 return { system, messages: head, tools: native.tools };
119};
120
121const textOf = ({ content }: Item) =>
122 Array.isArray(content) ? content.map((block: Block) => block.text ?? "").join("\n") : "";
123
124const trimCodex = (native: Native) => {
125 const input = native.input.flatMap(({ id: _id, ...item }): Array<Item> => {
126 // A captured turn's own answers and tool calls are not part of the harness.
127 if (item.type !== "message" && item.type !== "additional_tools") return [];
128 if (item.role === "assistant") return [];
129 const text = textOf(item);
130 // T3's own text comes from source, and the prompt from each case.
131 if (text.includes("<t3_code_")) return [];
132 if (item.role === "user") {
133 const env = /<environment_context>[\s\S]*?<\/environment_context>/.exec(text)?.[0];
134 return env ? [{ ...item, content: [{ type: "input_text", text: env }] }] : [];
135 }
136 if (item.type === "additional_tools") return [{ ...item, tools: trimModelList(item.tools) }];
137 if (!text.includes("<skills_instructions>")) return [item];
138 const content = (item.content as ReadonlyArray<Block>).map((block) =>
139 typeof block.text === "string"
140 ? {
141 ...block,
142 text: block.text
143 .split("\n")
144 .filter((line: string) => !USER_SKILL_LINE.test(line))
145 .join("\n"),
146 }
147 : block,
148 );
149 return [{ ...item, content }];
150 });
151 // run.ts requires this slot and fills it from source.
152 input.push({
153 type: "message",
154 role: "developer",
155 content: [{ type: "input_text", text: "<t3_code_runtime></t3_code_runtime>" }],
156 });
157 const { reasoning, instructions, text, tool_choice } = native;
158 return { reasoning, instructions, text, tool_choice, input };
159};
160
161const [harness, capturePath, outPath] = process.argv.slice(2);
162if ((harness !== "claude" && harness !== "codex") || !capturePath || !outPath) {
163 throw new Error("Usage: trim-capture.ts <claude|codex> <capture.json> <out.json.gz>");
164}
165const native: Native = JSON.parse(NodeFS.readFileSync(capturePath, "utf8")).request.provider_native;
166const raw = JSON.stringify({
167 request: { provider_native: harness === "claude" ? trimClaude(native) : trimCodex(native) },
168});
169// Home paths name the user. Cases use /home/user too.
170const user = /\/(?:home|Users)\/([^/\s"\\]+)/.exec(raw)?.[1];
171const body = user ? raw.replace(new RegExp(`/(home|Users)/${user}\\b`, "g"), "/$1/user") : raw;
172const leak = new RegExp(
173 `account_uuid|device_id|account_hmac|chatgpt-account${user && user !== "user" ? `|\\b${user}\\b` : ""}`,
174 "i",
175).exec(body);
176if (leak)
177 throw new Error(`User data is left near: ${body.slice(leak.index - 60, leak.index + 60)}`);
178NodeFS.writeFileSync(outPath, NodeZlib.gzipSync(body));
179process.stdout.write(`Wrote ${outPath}. Read it for personal data before you commit.\n`);