turntax 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,227 @@
1
+ const CHECKED_AT = '2026-10-11';
2
+ /** Where each agent reads MCP servers from. Paths and keys come from the cited pages. */
3
+ export const MCP_CONFIGS = [
4
+ { tool: 'claude-code', path: '.mcp.json', scope: 'project', key: 'mcpServers', shape: 'standard', source: 'https://code.claude.com/docs/en/mcp' },
5
+ { tool: 'cursor', path: '.cursor/mcp.json', scope: 'project', key: 'mcpServers', shape: 'standard', source: 'https://cursor.com/docs/context/mcp' },
6
+ { tool: 'copilot', path: '.vscode/mcp.json', scope: 'project', key: 'servers', shape: 'vscode', source: 'https://code.visualstudio.com/docs/copilot/customization/mcp-servers' },
7
+ { tool: 'gemini-cli', path: '.gemini/settings.json', scope: 'project', key: 'mcpServers', shape: 'gemini', source: 'https://geminicli.com/docs/tools/mcp-server/' },
8
+ { tool: 'kiro', path: '.kiro/settings/mcp.json', scope: 'project', key: 'mcpServers', shape: 'standard', source: 'https://kiro.dev/docs/mcp/configuration/' },
9
+ { tool: 'roo-code', path: '.roo/mcp.json', scope: 'project', key: 'mcpServers', shape: 'standard', source: 'https://roocodeinc.github.io/Roo-Code/features/mcp/using-mcp-in-roo' },
10
+ { tool: 'opencode', path: 'opencode.json', scope: 'project', key: 'mcp', shape: 'opencode', source: 'https://opencode.ai/docs/mcp-servers/' },
11
+ { tool: 'opencode', path: 'opencode.jsonc', scope: 'project', key: 'mcp', shape: 'opencode', source: 'https://opencode.ai/docs/mcp-servers/' },
12
+ { tool: 'zed', path: '.zed/settings.json', scope: 'project', key: 'context_servers', shape: 'zed', source: 'https://zed.dev/docs/ai/mcp' },
13
+ { tool: 'codex', path: '.codex/config.toml', scope: 'project', key: 'mcp_servers', shape: 'codex-toml', source: 'https://learn.chatgpt.com/docs/extend/mcp' },
14
+ { tool: 'claude-code', path: '.claude.json', scope: 'user', key: 'mcpServers', shape: 'claude-json', source: 'https://code.claude.com/docs/en/mcp' },
15
+ { tool: 'cursor', path: '.cursor/mcp.json', scope: 'user', key: 'mcpServers', shape: 'standard', source: 'https://cursor.com/docs/context/mcp' },
16
+ { tool: 'gemini-cli', path: '.gemini/settings.json', scope: 'user', key: 'mcpServers', shape: 'gemini', source: 'https://geminicli.com/docs/tools/mcp-server/' },
17
+ { tool: 'kiro', path: '.kiro/settings/mcp.json', scope: 'user', key: 'mcpServers', shape: 'standard', source: 'https://kiro.dev/docs/mcp/configuration/' },
18
+ { tool: 'windsurf', path: '.config/devin/mcp_config.json', scope: 'user', key: 'mcpServers', shape: 'standard', source: 'https://docs.devin.ai/desktop/cascade/mcp' },
19
+ { tool: 'windsurf', path: 'AppData/Roaming/devin/mcp_config.json', scope: 'user', key: 'mcpServers', shape: 'standard', source: 'https://docs.devin.ai/desktop/cascade/mcp' },
20
+ { tool: 'codex', path: '.codex/config.toml', scope: 'user', key: 'mcp_servers', shape: 'codex-toml', source: 'https://learn.chatgpt.com/docs/extend/mcp' },
21
+ { tool: 'opencode', path: '.config/opencode/opencode.json', scope: 'user', key: 'mcp', shape: 'opencode', source: 'https://opencode.ai/docs/mcp-servers/' },
22
+ ];
23
+ export const MCP_CHECKED_AT = CHECKED_AT;
24
+ /** True for a repo-relative path that holds project MCP servers for some agent. */
25
+ export function isMcpConfigFile(path) {
26
+ return MCP_CONFIGS.some((c) => c.scope === 'project' && c.path === path);
27
+ }
28
+ /** Home-relative paths of user-level MCP configs. */
29
+ export const USER_MCP_PATHS = [...new Set(MCP_CONFIGS.filter((c) => c.scope === 'user').map((c) => c.path))];
30
+ /** JSON with comments and trailing commas (VS Code, Zed and OpenCode accept both). */
31
+ export function parseJsonc(text) {
32
+ let out = '';
33
+ let inString = false;
34
+ for (let i = 0; i < text.length; i++) {
35
+ const c = text[i];
36
+ if (inString) {
37
+ out += c;
38
+ if (c === '\\')
39
+ out += text[++i] ?? '';
40
+ else if (c === '"')
41
+ inString = false;
42
+ }
43
+ else if (c === '"') {
44
+ inString = true;
45
+ out += c;
46
+ }
47
+ else if (c === '/' && text[i + 1] === '/') {
48
+ while (i < text.length && text[i] !== '\n')
49
+ i++;
50
+ out += '\n';
51
+ }
52
+ else if (c === '/' && text[i + 1] === '*') {
53
+ i = text.indexOf('*/', i + 2);
54
+ if (i < 0)
55
+ break;
56
+ i++;
57
+ }
58
+ else
59
+ out += c;
60
+ }
61
+ return JSON.parse(out.replace(/,(\s*[}\]])/g, '$1').replace(/^/, ''));
62
+ }
63
+ const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
64
+ const strings = (v) => (Array.isArray(v) ? v.map(String) : undefined);
65
+ const record = (v) => isObj(v) ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, String(x)])) : undefined;
66
+ function fromStandard(name, v, gemini = false) {
67
+ const type = String(v.type ?? v.transport ?? '').toLowerCase();
68
+ // Gemini CLI has no type field: `httpUrl` is streamable HTTP and `url` is SSE.
69
+ // Elsewhere `url` (Windsurf: `serverUrl`) is streamable HTTP unless the type says SSE.
70
+ const httpUrl = typeof v.httpUrl === 'string' ? v.httpUrl : undefined;
71
+ const url = httpUrl ?? (typeof v.url === 'string' ? v.url : typeof v.serverUrl === 'string' ? v.serverUrl : undefined);
72
+ const transport = !url ? 'stdio' : httpUrl ? 'http' : gemini || type === 'sse' ? 'sse' : 'http';
73
+ return {
74
+ name,
75
+ transport,
76
+ command: typeof v.command === 'string' ? v.command : undefined,
77
+ args: strings(v.args),
78
+ env: record(v.env),
79
+ cwd: typeof v.cwd === 'string' ? v.cwd : undefined,
80
+ url,
81
+ headers: record(v.headers),
82
+ disabled: v.disabled === true || v.enabled === false,
83
+ ...(v.alwaysLoad === true ? { alwaysLoad: true } : {}),
84
+ };
85
+ }
86
+ function fromOpencode(name, v) {
87
+ const command = strings(v.command) ?? [];
88
+ const remote = v.type === 'remote' || typeof v.url === 'string';
89
+ return {
90
+ name,
91
+ transport: remote ? 'http' : 'stdio',
92
+ command: remote ? undefined : command[0],
93
+ args: remote ? undefined : command.slice(1),
94
+ env: record(v.environment),
95
+ url: typeof v.url === 'string' ? v.url : undefined,
96
+ headers: record(v.headers),
97
+ disabled: v.enabled === false,
98
+ };
99
+ }
100
+ function fromZed(name, v) {
101
+ // Older Zed settings nest the command: { "command": { "path", "args", "env" } }.
102
+ if (isObj(v.command)) {
103
+ const c = v.command;
104
+ return fromStandard(name, { ...v, command: c.path ?? c.command, args: c.args, env: c.env });
105
+ }
106
+ return fromStandard(name, v);
107
+ }
108
+ function serversIn(container, shape) {
109
+ if (!isObj(container))
110
+ return [];
111
+ return Object.entries(container)
112
+ .filter(([, v]) => isObj(v))
113
+ .map(([name, v]) => shape === 'opencode'
114
+ ? fromOpencode(name, v)
115
+ : shape === 'zed'
116
+ ? fromZed(name, v)
117
+ : fromStandard(name, v, shape === 'gemini'));
118
+ }
119
+ /** Minimal TOML reader for Codex `[mcp_servers.<name>]` tables: strings, string arrays and inline tables. */
120
+ export function parseCodexToml(text) {
121
+ const servers = {};
122
+ let current = null;
123
+ let sub = null;
124
+ const value = (raw) => {
125
+ const t = raw.trim();
126
+ if (t.startsWith('['))
127
+ return [...t.matchAll(/"((?:[^"\\]|\\.)*)"|'([^']*)'/g)].map((m) => m[1] ?? m[2] ?? '');
128
+ if (t.startsWith('{'))
129
+ return Object.fromEntries([...t.matchAll(/([\w-]+|"[^"]*")\s*=\s*(?:"((?:[^"\\]|\\.)*)"|'([^']*)')/g)].map((m) => [m[1].replace(/"/g, ''), m[2] ?? m[3] ?? '']));
130
+ if (t === 'true' || t === 'false')
131
+ return t === 'true';
132
+ const s = /^"((?:[^"\\]|\\.)*)"|^'([^']*)'/.exec(t);
133
+ return s ? (s[1] ?? s[2]) : t;
134
+ };
135
+ for (const line of text.split(/\r?\n/)) {
136
+ const header = /^\s*\[\s*mcp_servers\.(?:"([^"]+)"|([\w-]+))(?:\.(\w+))?\s*\]/.exec(line);
137
+ if (header) {
138
+ const name = (header[1] ?? header[2]);
139
+ servers[name] ??= {};
140
+ current = servers[name];
141
+ sub = header[3] ?? null;
142
+ continue;
143
+ }
144
+ if (/^\s*\[/.test(line)) {
145
+ current = null;
146
+ continue;
147
+ }
148
+ const kv = /^\s*([\w-]+)\s*=\s*(.+?)\s*(?:#.*)?$/.exec(line);
149
+ if (!kv || !current)
150
+ continue;
151
+ if (sub) {
152
+ const table = (current[sub] ??= {});
153
+ table[kv[1]] = value(kv[2]);
154
+ }
155
+ else
156
+ current[kv[1]] = value(kv[2]);
157
+ }
158
+ // Codex calls HTTP headers `http_headers`.
159
+ for (const v of Object.values(servers))
160
+ if (isObj(v) && v.http_headers && !v.headers)
161
+ v.headers = v.http_headers;
162
+ return servers;
163
+ }
164
+ /**
165
+ * Parses the MCP configs among `files`. Project files use repo-relative paths; user files are passed
166
+ * with a `~/` prefix. `projectRoot` (absolute) selects the per-project entry in ~/.claude.json.
167
+ */
168
+ export function parseMcpConfigs(files, projectRoot) {
169
+ const servers = [];
170
+ const problems = [];
171
+ for (const f of files) {
172
+ const user = f.path.startsWith('~/');
173
+ const rel = user ? f.path.slice(2) : f.path;
174
+ const specs = MCP_CONFIGS.filter((c) => c.path === rel && c.scope === (user ? 'user' : 'project'));
175
+ if (!specs.length)
176
+ continue;
177
+ let data;
178
+ try {
179
+ data = specs[0].shape === 'codex-toml' ? parseCodexToml(f.text) : parseJsonc(f.text);
180
+ }
181
+ catch (e) {
182
+ problems.push({ path: f.path, message: `could not be parsed: ${e.message}` });
183
+ continue;
184
+ }
185
+ if (!isObj(data))
186
+ continue;
187
+ for (const spec of specs) {
188
+ const containers = spec.shape === 'codex-toml'
189
+ ? [data]
190
+ : spec.shape === 'claude-json'
191
+ ? [data.mcpServers, projectEntry(data, projectRoot)?.mcpServers]
192
+ : [data[spec.key]];
193
+ for (const c of containers)
194
+ for (const s of serversIn(c, spec.shape))
195
+ servers.push({ ...s, tool: spec.tool, configPath: f.path, scope: spec.scope });
196
+ }
197
+ }
198
+ return { servers, problems };
199
+ }
200
+ /** The `projects["<abs path>"]` entry of ~/.claude.json for this repository, matched case- and slash-insensitively. */
201
+ function projectEntry(data, root) {
202
+ if (!root || !isObj(data.projects))
203
+ return undefined;
204
+ const norm = (p) => p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
205
+ const hit = Object.entries(data.projects).find(([p]) => norm(p) === norm(root));
206
+ return hit && isObj(hit[1]) ? hit[1] : undefined;
207
+ }
208
+ /** Same server started by several agents or configs: one launch, one measurement. */
209
+ export function serverKey(s) {
210
+ return JSON.stringify([s.transport, s.command ?? '', s.args ?? [], s.url ?? '', s.cwd ?? '']);
211
+ }
212
+ /**
213
+ * Claude Code defers MCP tool definitions by default (tool search): only tool names are in context
214
+ * until a tool is used. A server with `alwaysLoad: true` is exempt.
215
+ */
216
+ export function defersTools(s) {
217
+ return s.tool === 'claude-code' && !s.alwaysLoad;
218
+ }
219
+ export const DEFERRED_NOTE = 'Claude Code defers MCP tool definitions by default (tool search): only tool names load every turn; full definitions load when a tool is used. Set ENABLE_TOOL_SEARCH=false or alwaysLoad: true to load them upfront.';
220
+ /** Tool names only, as listed when definitions are deferred. */
221
+ export function toolNamesText(tools) {
222
+ return tools.map((t) => t.name).join('\n');
223
+ }
224
+ /** The text an agent receives for a server's tools, as one JSON document per tool. */
225
+ export function toolDefinitionsText(tools) {
226
+ return tools.map((t) => JSON.stringify({ name: t.name, description: t.description ?? '', input_schema: t.inputSchema ?? {} })).join('\n');
227
+ }
@@ -1,3 +1,4 @@
1
+ import { DEFERRED_NOTE } from './mcp.js';
1
2
  export const METHOD_LABEL = {
2
3
  'offline-exact': 'exact (offline tokenizer)',
3
4
  'offline-proxy': 'estimate (o200k_base proxy)',
@@ -32,7 +33,11 @@ export function groupTools(report) {
32
33
  .filter((f) => f.tool === t.tool && f.mode !== 'ignored')
33
34
  .map((f) => [f.path, f.part, f.mode, f.o200k])
34
35
  .sort();
35
- const key = JSON.stringify([files, t.models.map(({ model, perTurn, method, reason }) => [model, perTurn, method, reason])]);
36
+ const key = JSON.stringify([
37
+ files,
38
+ t.models.map(({ model, perTurn, method, reason }) => [model, perTurn, method, reason]),
39
+ report.mcp.filter((m) => m.tool === t.tool).map((m) => [m.configPath, m.name, m.status, m.o200k]),
40
+ ]);
36
41
  const g = groups.get(key);
37
42
  if (g)
38
43
  g.names.push(t.name);
@@ -54,9 +59,112 @@ export function groupFiles(report) {
54
59
  }
55
60
  return [...groups.values()];
56
61
  }
57
- /** Highest always-loaded o200k total across the reported tools. */
62
+ /**
63
+ * Highest per-turn o200k total across the reported tools, for the badge and --max-tokens:
64
+ * repository files plus measured project MCP tool definitions. User-level files never count here.
65
+ */
58
66
  export function headlineTokens(report) {
59
- return Math.max(0, ...report.tools.map((t) => t.perTurnO200k));
67
+ return Math.max(0, ...report.tools.map((t) => t.perTurnO200k + (t.mcpPerTurnO200k ?? 0)));
68
+ }
69
+ /** MCP cell: measured tokens, a "+" when some servers could not be measured, or how many are unmeasured. */
70
+ export function mcpCell(t) {
71
+ if (!t.mcpServers)
72
+ return '-';
73
+ if (t.mcpMeasured)
74
+ return fmtTokens(t.mcpPerTurnO200k);
75
+ return t.mcpPerTurnO200k > 0 ? `${fmtTokens(t.mcpPerTurnO200k)}+` : `${t.mcpServers} not measured`;
76
+ }
77
+ function signed(n) {
78
+ return n > 0 ? `+${fmtTokens(n)}` : n < 0 ? `-${fmtTokens(-n)}` : '0';
79
+ }
80
+ function fmtMaybe(n) {
81
+ return n === null ? '-' : fmtTokens(n);
82
+ }
83
+ /** Agents with the same before/after numbers share one line. */
84
+ function groupDeltas(diff) {
85
+ const groups = new Map();
86
+ for (const t of diff.tools) {
87
+ const key = `${t.before}|${t.after}`;
88
+ const g = groups.get(key);
89
+ if (g)
90
+ g.names.push(t.name);
91
+ else
92
+ groups.set(key, { names: [t.name], before: t.before, after: t.after, delta: t.delta });
93
+ }
94
+ return [...groups.values()].sort((a, b) => b.delta - a.delta);
95
+ }
96
+ export function renderDiffText(diff) {
97
+ const out = [`Change vs ${diff.base} (o200k tokens per turn, repository files)`];
98
+ const changed = groupDeltas(diff).filter((g) => g.delta !== 0);
99
+ if (!changed.length)
100
+ out.push(' No change in per-turn tokens.');
101
+ else {
102
+ const rows = [['before', 'after', 'change', 'agents']];
103
+ for (const g of changed)
104
+ rows.push([fmtMaybe(g.before), fmtMaybe(g.after), signed(g.delta), g.names.join(', ')]);
105
+ out.push(table(rows, ' '));
106
+ }
107
+ if (diff.files.length) {
108
+ out.push(' Files loaded every turn');
109
+ const rows = [['before', 'after', 'change', 'file']];
110
+ for (const f of diff.files)
111
+ rows.push([fmtMaybe(f.before), fmtMaybe(f.after), signed(f.delta), f.part === 'full' ? f.path : `${f.path} (${f.part})`]);
112
+ out.push(table(rows, ' '));
113
+ }
114
+ for (const s of diff.mcp.added)
115
+ out.push(` MCP server added: ${s}`);
116
+ for (const s of diff.mcp.removed)
117
+ out.push(` MCP server removed: ${s}`);
118
+ return out.join('\n');
119
+ }
120
+ export function renderDiffMarkdown(diff) {
121
+ const out = [`## Change vs \`${diff.base}\``, ''];
122
+ const changed = groupDeltas(diff).filter((g) => g.delta !== 0);
123
+ if (!changed.length)
124
+ out.push('No change in per-turn tokens (o200k, repository files).');
125
+ else {
126
+ out.push('o200k tokens added to every turn by repository instruction files.', '');
127
+ out.push('| Agents | Before | After | Change |', '| --- | ---: | ---: | ---: |');
128
+ for (const g of changed)
129
+ out.push(`| ${g.names.join(', ')} | ${fmtMaybe(g.before)} | ${fmtMaybe(g.after)} | **${signed(g.delta)}** |`);
130
+ }
131
+ if (diff.files.length) {
132
+ out.push('', '| File loaded every turn | Before | After | Change |', '| --- | ---: | ---: | ---: |');
133
+ for (const f of diff.files)
134
+ out.push(`| \`${f.part === 'full' ? f.path : `${f.path} (${f.part})`}\` | ${fmtMaybe(f.before)} | ${fmtMaybe(f.after)} | ${signed(f.delta)} |`);
135
+ }
136
+ if (diff.mcp.added.length || diff.mcp.removed.length) {
137
+ out.push('');
138
+ for (const s of diff.mcp.added)
139
+ out.push(`- MCP server added: ${s}`);
140
+ for (const s of diff.mcp.removed)
141
+ out.push(`- MCP server removed: ${s}`);
142
+ }
143
+ return out.join('\n');
144
+ }
145
+ /** MCP rows that are identical apart from the agent, merged. */
146
+ function groupMcp(rows) {
147
+ const groups = new Map();
148
+ for (const r of rows) {
149
+ const key = JSON.stringify([r.configPath, r.name, r.status, r.tools, r.o200k, r.error]);
150
+ const g = groups.get(key);
151
+ if (g)
152
+ g.tools.push(r.tool);
153
+ else
154
+ groups.set(key, { row: r, tools: [r.tool] });
155
+ }
156
+ return [...groups.values()];
157
+ }
158
+ function mcpDetail(r) {
159
+ if (r.status === 'measured')
160
+ return r.deferredO200k === undefined
161
+ ? `${r.tools} tools`
162
+ : `${r.tools} tools; names only, full definitions (${fmtTokens(r.deferredO200k)}) load when used`;
163
+ if (r.status === 'disabled')
164
+ return 'disabled in the config';
165
+ if (r.status === 'failed')
166
+ return r.error ?? 'failed';
167
+ return 'run with --mcp to count its tool definitions';
60
168
  }
61
169
  export function renderText(report, root) {
62
170
  const out = [];
@@ -66,11 +174,25 @@ export function renderText(report, root) {
66
174
  out.push('No agent instruction files found.');
67
175
  return out.join('\n');
68
176
  }
177
+ if (report.diff) {
178
+ out.push(renderDiffText(report.diff));
179
+ out.push('');
180
+ }
181
+ const showMcp = report.tools.some((t) => t.mcpServers > 0);
182
+ const showUser = report.tools.some((t) => t.userAlwaysFiles > 0 || t.userPerTurnO200k > 0);
183
+ const extra = [...(showMcp ? ['mcp/turn'] : []), ...(showUser ? ['user/turn'] : []), ...(showMcp || showUser ? ['total'] : [])];
69
184
  const rows = [
70
- ['always', 'o200k/turn', 'on-demand', 'model', 'tokens/turn', 'counted by', `${report.turns} turns`, 'cached', 'agents'],
185
+ ['always', 'o200k/turn', 'on-demand', ...extra, 'model', 'tokens/turn', 'counted by', `${report.turns} turns`, 'cached', 'agents'],
71
186
  ];
72
187
  for (const { names, tool: t } of groupTools(report)) {
73
- const head = [String(t.alwaysFiles), fmtTokens(t.perTurnO200k), fmtTokens(t.onDemandO200k)];
188
+ const head = [
189
+ String(t.alwaysFiles),
190
+ fmtTokens(t.perTurnO200k),
191
+ fmtTokens(t.onDemandO200k),
192
+ ...(showMcp ? [mcpCell(t)] : []),
193
+ ...(showUser ? [fmtTokens(t.userPerTurnO200k)] : []),
194
+ ...(showMcp || showUser ? [fmtTokens(t.totalPerTurnO200k) + (t.mcpMeasured ? '' : '+')] : []),
195
+ ];
74
196
  const blank = head.map(() => '');
75
197
  const agents = names.join(', ');
76
198
  if (!t.models.length)
@@ -93,6 +215,14 @@ export function renderText(report, root) {
93
215
  fileRows.push([f.mode, name + (f.importedFrom ? ` <- @${f.importedFrom}` : ''), fmtTokens(f.o200k), f.trigger, tools.join(', ')]);
94
216
  }
95
217
  out.push(table(fileRows, ' '));
218
+ if (report.mcp.length) {
219
+ out.push('');
220
+ out.push('MCP servers (tool definitions are sent every turn)');
221
+ const mcpRows = [['status', 'server', 'config', 'o200k', 'detail', 'agents']];
222
+ for (const { row: r, tools } of groupMcp(report.mcp))
223
+ mcpRows.push([r.status, r.name, r.configPath, r.o200k === undefined ? '-' : fmtTokens(r.o200k), mcpDetail(r), tools.join(', ')]);
224
+ out.push(table(mcpRows, ' '));
225
+ }
96
226
  const notes = [...new Set(report.files.flatMap((f) => f.notes.map((n) => `${f.path}: ${n}`)))];
97
227
  if (notes.length) {
98
228
  out.push('');
@@ -119,6 +249,10 @@ function footer(report) {
119
249
  'o200k = OpenAI o200k_base, counted offline. It is a reference for every file, not a Claude or Gemini count.',
120
250
  'Claude and Gemini tokenizers are not public: their counts come only from the vendor APIs (--claude-api, --gemini-api).',
121
251
  `Cost = tokens/turn x ${report.turns} turns x input price. "Cached" assumes every turn reads the context from the prompt cache.`,
252
+ ...(report.mcp.length
253
+ ? ['MCP tool definitions are counted as JSON with o200k (an estimate of what the agent sends) and are not included in model costs.']
254
+ : []),
255
+ ...(report.mcp.some((m) => m.deferredO200k !== undefined) ? [DEFERRED_NOTE] : []),
122
256
  'Prices (USD per 1M input tokens):',
123
257
  ];
124
258
  for (const m of report.models) {
@@ -129,6 +263,8 @@ function footer(report) {
129
263
  }
130
264
  export function renderMarkdown(report) {
131
265
  const out = ['# Agent context tax report', ''];
266
+ if (report.diff)
267
+ out.push(renderDiffMarkdown(report.diff), '');
132
268
  out.push(`Every agent re-sends its always-loaded instruction files with each turn. Costs are for ${report.turns} turns.`, '');
133
269
  out.push('## Per agent', '');
134
270
  out.push('| Agents | Model | Tokens / turn | Counted by | Uncached | Cached |', '| --- | --- | ---: | --- | ---: | ---: |');
@@ -141,6 +277,17 @@ export function renderMarkdown(report) {
141
277
  ? `| ${agents} | ${m.label} | - | ${m.reason ?? ''} | - | - |`
142
278
  : `| ${agents} | ${m.label} | ${fmtTokens(m.perTurn)} | ${METHOD_LABEL[m.method]} | ${fmtUsd(m.uncached)} | ${fmtUsd(m.cached)} |`);
143
279
  }
280
+ if (report.tools.some((t) => t.mcpServers > 0 || t.userAlwaysFiles > 0 || t.userPerTurnO200k > 0)) {
281
+ out.push('', '### Every turn, by source (o200k)', '');
282
+ out.push('| Agents | Repository files | MCP tools | User level | Total |', '| --- | ---: | ---: | ---: | ---: |');
283
+ for (const { names, tool: t } of groupTools(report))
284
+ out.push(`| ${names.join(', ')} | ${fmtTokens(t.perTurnO200k)} | ${mcpCell(t)} | ${fmtTokens(t.userPerTurnO200k)} | ${fmtTokens(t.totalPerTurnO200k)}${t.mcpMeasured ? '' : '+'} |`);
285
+ }
286
+ if (report.mcp.length) {
287
+ out.push('', '## MCP servers', '', '| Status | Server | Config | o200k tokens | Detail | Agents |', '| --- | --- | --- | ---: | --- | --- |');
288
+ for (const { row: r, tools } of groupMcp(report.mcp))
289
+ out.push(`| ${r.status} | ${escapeCell(r.name)} | \`${r.configPath}\` | ${r.o200k === undefined ? '-' : fmtTokens(r.o200k)} | ${escapeCell(mcpDetail(r))} | ${tools.join(', ')} |`);
290
+ }
144
291
  out.push('', '## Files', '', '| Mode | File | o200k tokens | Loads | Agents |', '| --- | --- | ---: | --- | --- |');
145
292
  for (const { file: f, tools } of groupFiles(report)) {
146
293
  const name = f.part === 'full' ? f.path : `${f.path} (${f.part})`;
@@ -157,6 +304,10 @@ export function renderMarkdown(report) {
157
304
  out.push('- o200k = OpenAI `o200k_base`, counted offline; a reference, not a Claude or Gemini count.');
158
305
  out.push('- Claude counts come only from Anthropic `count_tokens`; Gemini counts only from Google `countTokens`.');
159
306
  out.push('- "Cached" assumes every turn reads the context from the prompt cache.');
307
+ if (report.mcp.length)
308
+ out.push('- MCP tool definitions are counted as JSON with o200k, an estimate of what the agent sends; they are not in the model costs.');
309
+ if (report.mcp.some((m) => m.deferredO200k !== undefined))
310
+ out.push(`- ${DEFERRED_NOTE}`);
160
311
  for (const m of report.models)
161
312
  out.push(`- ${m.label}: $${m.inputPerMTok ?? 'n/a'} input / $${m.cachedInputPerMTok ?? 'n/a'} cached per 1M tokens ([source](${m.source}), checked ${m.checkedAt}).`);
162
313
  out.push('', badgeMarkdown(report));
@@ -177,3 +328,17 @@ export function badgeUrl(report) {
177
328
  export function badgeMarkdown(report) {
178
329
  return `[![context tax (o200k)](${badgeUrl(report)})](https://github.com/TorutDev/turntax)`;
179
330
  }
331
+ export const PR_COMMENT_MARKER = '<!-- turntax-report -->';
332
+ const COMMENT_LIMIT = 60_000;
333
+ /**
334
+ * A pull request comment: the change against the base branch first, the full report folded below.
335
+ * Starts with a marker so the GitHub Action can update its own comment instead of adding new ones.
336
+ */
337
+ export function renderPrComment(report) {
338
+ const top = report.diff ? renderDiffMarkdown(report.diff) : '## Agent context tax';
339
+ const headline = `Largest per-turn cost: **${fmtTokens(headlineTokens(report))}** o200k tokens (repository files and measured project MCP tools).`;
340
+ let full = renderMarkdown({ ...report, diff: undefined }).replace(/^# .*\n\n/, '');
341
+ if (full.length > COMMENT_LIMIT)
342
+ full = full.slice(0, COMMENT_LIMIT) + '\n\n_Report cut to fit in a comment; the job summary has all of it._\n';
343
+ return [PR_COMMENT_MARKER, top, '', headline, '', '<details><summary>Full report</summary>', '', full, '</details>', ''].join('\n');
344
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * User-level instruction files: the ones in the home directory that an agent adds to every project.
3
+ * Each is classified by the same rules as its project counterpart, through a virtual path
4
+ * (for example ~/.codex/AGENTS.md is read like a root AGENTS.md, but only by Codex).
5
+ */
6
+ import { classify } from './classify.js';
7
+ const same = (p) => p;
8
+ export const USER_FILES = [
9
+ { tool: 'claude-code', pattern: /^\.claude\/CLAUDE\.md$/, as: same, source: 'https://code.claude.com/docs/en/memory' },
10
+ { tool: 'claude-code', pattern: /^\.claude\/rules\/.+\.md$/, as: same, source: 'https://code.claude.com/docs/en/memory' },
11
+ { tool: 'claude-code', pattern: /^\.claude\/skills\/[^/]+\/SKILL\.md$/, as: same, source: 'https://code.claude.com/docs/en/skills' },
12
+ { tool: 'claude-code', pattern: /^\.claude\/commands\/.+\.md$/, as: same, source: 'https://code.claude.com/docs/en/skills' },
13
+ { tool: 'claude-code', pattern: /^\.claude\/agents\/[^/]+\.md$/, as: same, source: 'https://code.claude.com/docs/en/sub-agents' },
14
+ { tool: 'codex', pattern: /^\.codex\/AGENTS(\.override)?\.md$/, as: (p) => p.slice('.codex/'.length), source: 'https://learn.chatgpt.com/docs/agent-configuration/agents-md' },
15
+ { tool: 'codex', pattern: /^\.agents\/skills\/[^/]+\/SKILL\.md$/, as: same, source: 'https://learn.chatgpt.com/docs/build-skills' },
16
+ { tool: 'gemini-cli', pattern: /^\.gemini\/GEMINI\.md$/, as: () => 'GEMINI.md', source: 'https://geminicli.com/docs/cli/gemini-md/' },
17
+ { tool: 'windsurf', pattern: /^\.codeium\/windsurf\/memories\/global_rules\.md$/, as: () => '.windsurfrules', source: 'https://docs.devin.ai/desktop/cascade/memories' },
18
+ { tool: 'cline', pattern: /^(Documents\/Cline\/Rules|Cline\/Rules|\.cline\/rules)\/.+\.md$/, as: (p) => '.clinerules/' + p.replace(/^(Documents\/Cline\/Rules|Cline\/Rules|\.cline\/rules)\//, ''), source: 'https://docs.cline.bot/features/cline-rules' },
19
+ { tool: 'roo-code', pattern: /^\.roo\/rules(-[\w-]+)?\/.+\.(md|txt)$/, as: same, source: 'https://roocodeinc.github.io/Roo-Code/features/custom-instructions' },
20
+ { tool: 'kiro', pattern: /^\.kiro\/steering\/[^/]+\.md$/, as: same, source: 'https://kiro.dev/docs/steering/' },
21
+ { tool: 'goose', pattern: /^\.config\/goose\/\.goosehints$/, as: () => '.goosehints', source: 'https://goose-docs.ai/docs/guides/context-engineering/using-goosehints' },
22
+ { tool: 'amp', pattern: /^\.config\/(amp\/)?AGENTS\.md$/, as: () => 'AGENTS.md', source: 'https://ampcode.com/docs/customize/agents-md' },
23
+ { tool: 'opencode', pattern: /^\.config\/opencode\/AGENTS\.md$/, as: () => 'AGENTS.md', source: 'https://opencode.ai/docs/rules/' },
24
+ // OpenCode falls back to ~/.claude/CLAUDE.md when ~/.config/opencode/AGENTS.md is missing.
25
+ { tool: 'opencode', pattern: /^\.claude\/CLAUDE\.md$/, as: () => 'CLAUDE.md', source: 'https://opencode.ai/docs/rules/' },
26
+ ];
27
+ /** Home-relative folders to search, and how deep. Kept narrow so nothing else in the home directory is read. */
28
+ export const USER_ROOTS = [
29
+ { dir: '.claude', depth: 4 },
30
+ { dir: '.codex', depth: 0 },
31
+ { dir: '.agents/skills', depth: 1 },
32
+ { dir: '.gemini', depth: 0 },
33
+ { dir: '.codeium/windsurf/memories', depth: 0 },
34
+ { dir: 'Documents/Cline/Rules', depth: 3 },
35
+ { dir: 'Cline/Rules', depth: 3 },
36
+ { dir: '.cline/rules', depth: 3 },
37
+ { dir: '.roo', depth: 3 },
38
+ { dir: '.kiro/steering', depth: 0 },
39
+ { dir: '.config/goose', depth: 0 },
40
+ { dir: '.config', depth: 0 },
41
+ { dir: '.config/amp', depth: 0 },
42
+ { dir: '.config/opencode', depth: 0 },
43
+ ];
44
+ export function isUserInstructionFile(homePath) {
45
+ return USER_FILES.some((r) => r.pattern.test(homePath));
46
+ }
47
+ /**
48
+ * Classifies user-level files (paths given as `~/...`). Each tool sees only its own files,
49
+ * so ~/.codex/AGENTS.md never counts for Claude Code.
50
+ */
51
+ export function classifyUser(files, toolIds) {
52
+ const units = [];
53
+ const tools = [...new Set(USER_FILES.map((r) => r.tool))].filter((t) => !toolIds || toolIds.includes(t));
54
+ for (const tool of tools) {
55
+ // Files that map to the same place are all read (Amp loads both ~/.config/amp/AGENTS.md and
56
+ // ~/.config/AGENTS.md), so each collision is classified in a batch of its own.
57
+ const batches = [];
58
+ for (const f of files) {
59
+ const home = f.path.replace(/^~\//, '');
60
+ const rule = USER_FILES.find((r) => r.tool === tool && r.pattern.test(home));
61
+ if (!rule)
62
+ continue;
63
+ const as = rule.as(home);
64
+ let batch = batches.find((b) => !b.virtual.has(as));
65
+ if (!batch)
66
+ batches.push((batch = { virtual: new Map(), files: [] }));
67
+ batch.virtual.set(as, f.path);
68
+ batch.files.push({ path: as, text: f.text });
69
+ }
70
+ for (const batch of batches) {
71
+ for (const u of classify(batch.files, [tool])) {
72
+ const real = batch.virtual.get(u.path);
73
+ if (!real)
74
+ continue; // an @import resolved inside the virtual tree; user-level imports are not followed
75
+ units.push({ ...u, path: real, trigger: `user level, ${u.trigger}`, scope: 'user' });
76
+ }
77
+ }
78
+ }
79
+ return units;
80
+ }
@@ -0,0 +1,43 @@
1
+ import { lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { dirname, isAbsolute, relative, resolve } from 'node:path';
3
+ import { realLocation } from './discover.js';
4
+ const inside = (root, p) => {
5
+ const rel = relative(root, p);
6
+ return !rel.startsWith('..') && !isAbsolute(rel);
7
+ };
8
+ /**
9
+ * Writes planned changes under `root`. Every file is checked first: files to change must still have the
10
+ * content the plan was made from, new files must not exist, and nothing may be written through a symlink
11
+ * (dangling ones included) or outside the repository. Nothing is written when a check fails.
12
+ */
13
+ export function applyChanges(root, changes) {
14
+ const realRoot = realpathSync(root);
15
+ const exists = (p) => lstatSync(p, { throwIfNoEntry: false }) !== undefined;
16
+ const abs = (path) => {
17
+ const p = resolve(root, path);
18
+ if (lstatSync(p, { throwIfNoEntry: false })?.isSymbolicLink())
19
+ throw new Error(`${path} is a symlink; nothing was written.`);
20
+ const real = realLocation(p);
21
+ if (!inside(root, p) || real === null || !inside(realRoot, real))
22
+ throw new Error(`${path} is outside ${root}; nothing was written.`);
23
+ return p;
24
+ };
25
+ for (const c of changes) {
26
+ const source = c.renamedFrom ?? c.path;
27
+ if (c.renamedFrom && exists(abs(c.path)))
28
+ throw new Error(`${c.path} already exists; nothing was written.`);
29
+ if (c.before === null ? exists(abs(c.path)) : !exists(abs(source)) || readFileSync(abs(source), 'utf8') !== c.before)
30
+ throw new Error(`${source} ${c.before === null ? 'already exists' : 'changed since it was read'}; nothing was written.`);
31
+ }
32
+ for (const c of changes) {
33
+ const target = abs(c.path);
34
+ mkdirSync(dirname(target), { recursive: true });
35
+ if (c.renamedFrom)
36
+ renameSync(abs(c.renamedFrom), target);
37
+ // New files are created exclusively, so a link placed in the meantime is not followed.
38
+ if (c.before === null)
39
+ writeFileSync(target, c.after, { flag: 'wx' });
40
+ else if (c.after !== c.before || !c.renamedFrom)
41
+ writeFileSync(target, c.after);
42
+ }
43
+ }