turntax 0.2.0 → 0.3.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.
- package/README.md +221 -138
- package/dist/cli.js +120 -11
- package/dist/core/advice.js +4 -2
- package/dist/core/audit.js +40 -6
- package/dist/core/diff.js +63 -0
- package/dist/core/mcp.js +227 -0
- package/dist/core/render.js +170 -5
- package/dist/core/user.js +80 -0
- package/dist/node/discover.js +45 -0
- package/dist/node/git.js +46 -0
- package/dist/node/mcp.js +212 -0
- package/package.json +64 -63
package/dist/core/audit.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { buildAdvice } from './advice.js';
|
|
2
2
|
import { classify, detectTools } from './classify.js';
|
|
3
|
+
import { defersTools, serverKey } from './mcp.js';
|
|
3
4
|
import { countO200k, offlineCounter } from './tokenize.js';
|
|
4
5
|
import { TOOLS } from './tools.js';
|
|
6
|
+
import { classifyUser } from './user.js';
|
|
5
7
|
export function modelsForTool(tool, models) {
|
|
6
8
|
const spec = TOOLS.find((t) => t.id === tool);
|
|
7
9
|
return spec.families.length ? models.filter((m) => spec.families.includes(m.family)) : models;
|
|
@@ -15,9 +17,12 @@ function counterFor(model, opts) {
|
|
|
15
17
|
}
|
|
16
18
|
export async function audit(files, opts) {
|
|
17
19
|
const turns = opts.turns ?? 100;
|
|
18
|
-
const allUnits = classify(files);
|
|
19
|
-
const
|
|
20
|
+
const allUnits = [...classify(files), ...classifyUser(opts.userFiles ?? [])];
|
|
21
|
+
const servers = opts.mcp?.servers ?? [];
|
|
22
|
+
const toolIds = opts.tools ??
|
|
23
|
+
[...new Set([...detectTools(allUnits), ...servers.filter((s) => !s.disabled).map((s) => s.tool)])].sort((a, b) => TOOLS.findIndex((t) => t.id === a) - TOOLS.findIndex((t) => t.id === b));
|
|
20
24
|
const units = allUnits.filter((u) => toolIds.includes(u.tool));
|
|
25
|
+
const mcp = servers.filter((s) => toolIds.includes(s.tool)).map((s) => mcpRow(s, opts.mcp?.measured));
|
|
21
26
|
// Count each distinct text once per model, only for the tools that run that model.
|
|
22
27
|
const counts = new Map();
|
|
23
28
|
for (const model of opts.models) {
|
|
@@ -39,6 +44,8 @@ export async function audit(files, opts) {
|
|
|
39
44
|
};
|
|
40
45
|
if (u.importedFrom)
|
|
41
46
|
row.importedFrom = u.importedFrom;
|
|
47
|
+
if (u.scope)
|
|
48
|
+
row.scope = u.scope;
|
|
42
49
|
if (u.mode !== 'ignored')
|
|
43
50
|
for (const m of modelsForTool(u.tool, opts.models)) {
|
|
44
51
|
const c = counts.get(m.id)?.get(u.text);
|
|
@@ -51,21 +58,48 @@ export async function audit(files, opts) {
|
|
|
51
58
|
const spec = TOOLS.find((t) => t.id === id);
|
|
52
59
|
const rows = files_.filter((r) => r.tool === id);
|
|
53
60
|
const always = rows.filter((r) => r.mode === 'always');
|
|
54
|
-
const
|
|
61
|
+
const repoAlways = always.filter((r) => !r.scope);
|
|
62
|
+
const userAlways = always.filter((r) => r.scope === 'user');
|
|
63
|
+
const onDemand = rows.filter((r) => r.mode === 'on-demand' && !r.scope);
|
|
55
64
|
const models = modelsForTool(id, opts.models).map((m) => modelCost(m, always, turns));
|
|
65
|
+
const toolMcp = mcp.filter((r) => r.tool === id && r.status !== 'disabled');
|
|
66
|
+
const mcpTokens = (scope) => sum(toolMcp.filter((r) => r.scope === scope).map((r) => r.o200k ?? 0));
|
|
67
|
+
const perTurnO200k = sum(repoAlways.map((r) => r.o200k));
|
|
68
|
+
const userPerTurnO200k = sum(userAlways.map((r) => r.o200k)) + mcpTokens('user');
|
|
69
|
+
const mcpPerTurnO200k = mcpTokens('project');
|
|
56
70
|
return {
|
|
57
71
|
tool: id,
|
|
58
72
|
name: spec.name,
|
|
59
|
-
alwaysFiles: new Set(
|
|
73
|
+
alwaysFiles: new Set(repoAlways.map((r) => r.path)).size,
|
|
60
74
|
onDemandFiles: new Set(onDemand.map((r) => r.path)).size,
|
|
61
|
-
perTurnO200k
|
|
75
|
+
perTurnO200k,
|
|
62
76
|
onDemandO200k: sum(onDemand.map((r) => r.o200k)),
|
|
77
|
+
userAlwaysFiles: new Set(userAlways.map((r) => r.path)).size,
|
|
78
|
+
userPerTurnO200k,
|
|
79
|
+
mcpServers: toolMcp.length,
|
|
80
|
+
mcpPerTurnO200k,
|
|
81
|
+
mcpMeasured: toolMcp.every((r) => r.status === 'measured'),
|
|
82
|
+
totalPerTurnO200k: perTurnO200k + mcpPerTurnO200k + userPerTurnO200k,
|
|
63
83
|
models,
|
|
64
84
|
notes: spec.notes,
|
|
65
85
|
sources: spec.sources,
|
|
66
86
|
};
|
|
67
87
|
});
|
|
68
|
-
|
|
88
|
+
const adviceFiles = [...files, ...(opts.userFiles ?? [])];
|
|
89
|
+
return { turns, tools, files: files_, advice: buildAdvice(units, adviceFiles, opts.advice), models: opts.models, mcp };
|
|
90
|
+
}
|
|
91
|
+
function mcpRow(s, measured) {
|
|
92
|
+
const row = { name: s.name, tool: s.tool, configPath: s.configPath, scope: s.scope, transport: s.transport, status: 'not-measured' };
|
|
93
|
+
if (s.disabled)
|
|
94
|
+
return { ...row, status: 'disabled' };
|
|
95
|
+
const m = measured?.get(serverKey(s));
|
|
96
|
+
if (!m)
|
|
97
|
+
return row;
|
|
98
|
+
if ('error' in m)
|
|
99
|
+
return { ...row, status: 'failed', error: m.error };
|
|
100
|
+
if (defersTools(s))
|
|
101
|
+
return { ...row, status: 'measured', tools: m.tools, o200k: m.namesO200k, deferredO200k: m.o200k };
|
|
102
|
+
return { ...row, status: 'measured', tools: m.tools, o200k: m.o200k };
|
|
69
103
|
}
|
|
70
104
|
function modelCost(model, always, turns) {
|
|
71
105
|
const counts = always.map((r) => r.counts[model.id]);
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export function diffReports(before, after, base) {
|
|
2
|
+
const ids = [...new Set([...after.tools.map((t) => t.tool), ...before.tools.map((t) => t.tool)])];
|
|
3
|
+
const tools = ids.map((id) => {
|
|
4
|
+
const a = after.tools.find((t) => t.tool === id);
|
|
5
|
+
const b = before.tools.find((t) => t.tool === id);
|
|
6
|
+
const av = a?.perTurnO200k ?? null;
|
|
7
|
+
const bv = b?.perTurnO200k ?? null;
|
|
8
|
+
return { tool: id, name: (a ?? b).name, before: bv, after: av, delta: (av ?? 0) - (bv ?? 0) };
|
|
9
|
+
});
|
|
10
|
+
const alwaysFiles = (r) => {
|
|
11
|
+
const m = new Map();
|
|
12
|
+
for (const f of r.files) {
|
|
13
|
+
if (f.mode !== 'always' || f.scope === 'user')
|
|
14
|
+
continue;
|
|
15
|
+
const key = `${f.path}\0${f.part}`;
|
|
16
|
+
const prev = m.get(key);
|
|
17
|
+
if (prev)
|
|
18
|
+
prev.tools.add(f.tool);
|
|
19
|
+
else
|
|
20
|
+
m.set(key, { path: f.path, part: f.part, o200k: f.o200k, tools: new Set([f.tool]) });
|
|
21
|
+
}
|
|
22
|
+
return m;
|
|
23
|
+
};
|
|
24
|
+
const bf = alwaysFiles(before);
|
|
25
|
+
const af = alwaysFiles(after);
|
|
26
|
+
const files = [];
|
|
27
|
+
for (const key of new Set([...bf.keys(), ...af.keys()])) {
|
|
28
|
+
const b = bf.get(key);
|
|
29
|
+
const a = af.get(key);
|
|
30
|
+
const delta = (a?.o200k ?? 0) - (b?.o200k ?? 0);
|
|
31
|
+
if (a && b && delta === 0)
|
|
32
|
+
continue;
|
|
33
|
+
files.push({
|
|
34
|
+
path: (a ?? b).path,
|
|
35
|
+
part: (a ?? b).part,
|
|
36
|
+
before: b?.o200k ?? null,
|
|
37
|
+
after: a?.o200k ?? null,
|
|
38
|
+
delta,
|
|
39
|
+
tools: [...new Set([...(b?.tools ?? []), ...(a?.tools ?? [])])],
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
files.sort((x, y) => Math.abs(y.delta) - Math.abs(x.delta) || x.path.localeCompare(y.path));
|
|
43
|
+
const servers = (r) => new Set((r.mcp ?? []).filter((s) => s.scope === 'project' && s.status !== 'disabled').map((s) => `${s.name} (${s.tool})`));
|
|
44
|
+
const bs = servers(before);
|
|
45
|
+
const as = servers(after);
|
|
46
|
+
return {
|
|
47
|
+
base,
|
|
48
|
+
tools,
|
|
49
|
+
files,
|
|
50
|
+
mcp: { added: [...as].filter((s) => !bs.has(s)), removed: [...bs].filter((s) => !as.has(s)) },
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/** Largest per-turn increase across agents (0 when nothing grew). */
|
|
54
|
+
export function maxIncrease(diff) {
|
|
55
|
+
return Math.max(0, ...diff.tools.map((t) => t.delta));
|
|
56
|
+
}
|
|
57
|
+
/** Validates a parsed JSON report enough to diff against it. */
|
|
58
|
+
export function asDiffInput(data) {
|
|
59
|
+
const r = data;
|
|
60
|
+
if (!r || !Array.isArray(r.tools) || !Array.isArray(r.files))
|
|
61
|
+
throw new Error('not a turntax --json report');
|
|
62
|
+
return r;
|
|
63
|
+
}
|
package/dist/core/mcp.js
ADDED
|
@@ -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
|
+
}
|
package/dist/core/render.js
CHANGED
|
@@ -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([
|
|
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
|
-
/**
|
|
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 = [
|
|
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 `[})](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
|
+
}
|