turntax 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 TORUT LTD
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,111 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # turntax
2
+
3
+ Coding agents re-send their instruction files with every turn. `turntax` measures that fixed per-turn cost for a repository, per tool and per model, and shows where to cut it.
4
+
5
+ ```bash
6
+ npx turntax
7
+ ```
8
+
9
+ It reads the files each agent actually loads (`CLAUDE.md`, `AGENTS.md`, `GEMINI.md`, `.cursor/rules`, `.github/copilot-instructions.md`, Windsurf rules, skills) and applies each tool's documented loading rules: what is sent on every turn, what loads only when the agent works in a folder or touches a matching file, and what is ignored.
10
+
11
+ ```text
12
+ tool always o200k/turn on-demand model tokens/turn counted by 100 turns cached
13
+ Claude Code 4 272 53 Claude Sonnet 5.5 - needs --claude-api and ANTHROPIC_API_KEY - -
14
+ OpenAI Codex 1 63 0 GPT-6.1 Sol 63 estimate (o200k_base proxy) $0.01 $0.0006
15
+ Cursor 4 297 37 Claude Sonnet 5.5 - needs --claude-api and ANTHROPIC_API_KEY - -
16
+ GPT-6.1 Sol 297 estimate (o200k_base proxy) $0.06 $0.0030
17
+
18
+ Advice (savings are estimates in o200k tokens per turn)
19
+ HIGH duplicate CLAUDE.md, AGENTS.md [saves ~48 tokens/turn]
20
+ 3 identical lines are loaded twice every turn by cursor. Keep them in one file.
21
+ HIGH contradiction AGENTS.md, CLAUDE.md [saves ~11 tokens/turn]
22
+ Conflicting package manager: "Always use npm ..." (AGENTS.md:3) vs "Always use pnpm ..." (CLAUDE.md:5).
23
+ MEDIUM scopable-section CLAUDE.md:11 [saves ~128 tokens/turn]
24
+ Section "API package" is mostly about packages/api/. Move it to packages/api/AGENTS.md ...
25
+ ```
26
+
27
+ (Output for [`test/fixtures/mixed`](test/fixtures/mixed).)
28
+
29
+ ## What it counts, and how
30
+
31
+ | Model family | How tokens are counted | Label |
32
+ | --- | --- | --- |
33
+ | OpenAI GPT-5.x, gpt-oss | Offline with `o200k_base` (the encoding OpenAI's tiktoken maps them to) | exact |
34
+ | OpenAI GPT-6.x | Offline with `o200k_base`; OpenAI has not published a newer encoding | estimate |
35
+ | Claude | Only Anthropic's `count_tokens` API, with your key and `--claude-api` | API count |
36
+ | Gemini | Only Google's `countTokens` API, with your key and `--gemini-api` | API count |
37
+
38
+ Claude's tokenizer is not public, and Anthropic's docs note that newer Claude models produce noticeably more tokens than older ones for the same text. `turntax` never shows an OpenAI count as a Claude or Gemini number: without a key those rows say "needs API key". The `o200k` column is a labelled reference count for every file.
39
+
40
+ Cost per N turns = tokens per turn × N × input price. The "cached" column assumes every turn reads the context from the prompt cache. Prices come from a dated table with a source link per model (`turntax --list-models`); pass `--prices my-prices.json` to use your own.
41
+
42
+ See [docs/how-it-counts.md](docs/how-it-counts.md) for the loading rules per tool and their sources.
43
+
44
+ ## Advice
45
+
46
+ All checks are deterministic. Each one shows an estimated saving in o200k tokens per turn.
47
+
48
+ - **duplicate**: identical lines in two files. A per-turn saving only when one tool loads both; otherwise flagged as drift risk.
49
+ - **contradiction**: conflicting rules reaching the same agent (package manager, indentation, quotes, semicolons).
50
+ - **scopable-section**: a section mostly about one directory that could load only there (nested file, path-scoped rule, `applyTo`).
51
+ - **oversized-section**: a large always-loaded section that could become an on-demand skill.
52
+ - **size-limit**: documented limits (CLAUDE.md under 200 lines, Codex's 32 KiB cut-off, Windsurf's 12,000 characters, Cursor's 500 lines).
53
+ - **misconfigured**: files a tool ignores, such as `.md` files in `.cursor/rules` or Windsurf rules without a `trigger`.
54
+ - **stale-reference**: referenced files or `npm run` scripts that no longer exist.
55
+ - **non-english**: non-English sections. The token multiplier is measured only against an English version you supply (`--english CLAUDE.md=CLAUDE.en.md`) or, if you opt in, a Claude translation (`--translate`).
56
+
57
+ ## Options
58
+
59
+ ```text
60
+ turntax [path]
61
+ --tools claude-code,codex,gemini-cli,cursor,copilot,windsurf
62
+ --models <ids> see --list-models (default: claude-sonnet-5.5, gpt-6.1-sol, gemini-3.8-flash)
63
+ --turns <n> cost projection (default 100)
64
+ --json | --md | --badge output format (default: terminal table)
65
+ --out <file> write the output to a file
66
+ --max-tokens <n> exit 1 when any tool loads more than n o200k tokens per turn (CI gate)
67
+ --claude-api exact Claude counts (ANTHROPIC_API_KEY)
68
+ --gemini-api Gemini counts (GEMINI_API_KEY)
69
+ --english <file=en> measure a language multiplier against your English version
70
+ --translate opt-in Claude translation for that measurement (needs --claude-api)
71
+ --prices <file.json> your own price table
72
+ --section-tokens <n> threshold for oversized sections (default 600)
73
+ ```
74
+
75
+ ## Privacy
76
+
77
+ Offline by default: no network calls, no telemetry. File contents are sent only when you pass `--claude-api`, `--gemini-api` or `--translate`, and only to that vendor's API. Keys are read from environment variables and sent only in request headers.
78
+
79
+ ## Web page
80
+
81
+ [`web/`](web) is a static page that runs the same engine in the browser with the offline tokenizer only. Drop a repository folder or paste files; nothing is uploaded.
82
+
83
+ Try it: https://torutdev.github.io/turntax/
84
+
85
+ ```bash
86
+ npm run build:web
87
+ ```
88
+
89
+ ## Badge
90
+
91
+ ```bash
92
+ npx turntax --badge
93
+ ```
94
+
95
+ [![context tax (o200k)](https://img.shields.io/badge/context%20tax%20(o200k)-297%20tokens%2Fturn-brightgreen)](https://github.com/TorutDev/turntax)
96
+
97
+ The badge shows the largest always-loaded total among the tools found, counted with o200k_base.
98
+
99
+ ## Limits
100
+
101
+ - Assumes the agent starts at the repository root. Files outside the repository (user-level `~/.claude/CLAUDE.md`, global rules, team rules in a dashboard) are not counted.
102
+ - Counts instruction files only, not the agent's own system prompt, tool definitions or MCP servers.
103
+ - Loading rules change. Each rule cites the vendor page and the date it was checked; please open an issue when one is out of date.
104
+
105
+ ## Contributing
106
+
107
+ Loading rules and prices go out of date. Corrections with a link to the vendor's page are the most useful contribution; see [CONTRIBUTING.md](CONTRIBUTING.md).
108
+
109
+ ## License
110
+
111
+ MIT
package/dist/bin.js ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ import { main } from './cli.js';
3
+ main(process.argv.slice(2)).then((code) => {
4
+ process.exitCode = code;
5
+ });
package/dist/cli.js ADDED
@@ -0,0 +1,210 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
+ import { relative, resolve } from 'node:path';
3
+ import { parseArgs } from 'node:util';
4
+ import { hasNonEnglish } from './core/advice.js';
5
+ import { audit } from './core/audit.js';
6
+ import { classify } from './core/classify.js';
7
+ import { DEFAULT_MODEL_IDS, MODELS, validateModels } from './core/prices.js';
8
+ import { badgeMarkdown, headlineTokens, renderMarkdown, renderText } from './core/render.js';
9
+ import { TOOL_IDS } from './core/tools.js';
10
+ import { discover, existsIn, packageScripts } from './node/discover.js';
11
+ import { anthropicCounter, geminiCounter, translateToEnglish } from './node/remote.js';
12
+ const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
13
+ const HELP = `turntax ${VERSION}
14
+ Measure the token tax that agent instruction files add to every turn.
15
+
16
+ Usage: turntax [path] [options]
17
+
18
+ Options:
19
+ --tools <list> Tools to report: ${TOOL_IDS.join(', ')} (default: tools with files)
20
+ --models <list> Model ids to price (default: ${DEFAULT_MODEL_IDS.join(', ')})
21
+ --list-models Print the price table and exit
22
+ --turns <n> Turns for the cost projection (default: 100)
23
+ --json Print the full report as JSON
24
+ --md Print a Markdown report
25
+ --out <file> Write the report to a file instead of stdout
26
+ --badge Print a shields.io badge snippet
27
+ --max-tokens <n> Exit with code 1 if any tool loads more than n o200k tokens per turn
28
+ --claude-api Count Claude tokens with Anthropic's count_tokens (needs ANTHROPIC_API_KEY;
29
+ sends file contents to api.anthropic.com)
30
+ --gemini-api Count Gemini tokens with Google's countTokens (needs GEMINI_API_KEY;
31
+ sends file contents to generativelanguage.googleapis.com)
32
+ --english <file=en> English version of a file, to measure its language token multiplier (repeatable)
33
+ --translate Translate non-English files to English with Claude to measure the multiplier
34
+ (needs --claude-api; sends those files to api.anthropic.com)
35
+ --prices <file.json> Use your own price table (same shape as --list-models --json)
36
+ --section-tokens <n> Flag always-loaded sections at or above n o200k tokens (default: 600)
37
+ -h, --help Show help
38
+ -v, --version Show version
39
+
40
+ Without --claude-api or --gemini-api nothing leaves your machine.
41
+ `;
42
+ class UsageError extends Error {
43
+ }
44
+ function list(value) {
45
+ return value
46
+ ?.split(',')
47
+ .map((s) => s.trim())
48
+ .filter(Boolean);
49
+ }
50
+ function positiveInt(name, value, fallback) {
51
+ if (value === undefined)
52
+ return fallback;
53
+ const n = Number(value);
54
+ if (!Number.isInteger(n) || n <= 0)
55
+ throw new UsageError(`--${name} must be a positive integer`);
56
+ return n;
57
+ }
58
+ export async function main(argv, env = process.env) {
59
+ let parsed;
60
+ try {
61
+ parsed = parseArgs({
62
+ args: argv,
63
+ allowPositionals: true,
64
+ options: {
65
+ tools: { type: 'string' },
66
+ models: { type: 'string' },
67
+ 'list-models': { type: 'boolean' },
68
+ turns: { type: 'string' },
69
+ json: { type: 'boolean' },
70
+ md: { type: 'boolean' },
71
+ out: { type: 'string' },
72
+ badge: { type: 'boolean' },
73
+ 'max-tokens': { type: 'string' },
74
+ 'claude-api': { type: 'boolean' },
75
+ 'gemini-api': { type: 'boolean' },
76
+ english: { type: 'string', multiple: true },
77
+ translate: { type: 'boolean' },
78
+ prices: { type: 'string' },
79
+ 'section-tokens': { type: 'string' },
80
+ help: { type: 'boolean', short: 'h' },
81
+ version: { type: 'boolean', short: 'v' },
82
+ },
83
+ });
84
+ }
85
+ catch (e) {
86
+ process.stderr.write(`${e.message}\n\n${HELP}`);
87
+ return 2;
88
+ }
89
+ const { values: o, positionals } = parsed;
90
+ if (o.help) {
91
+ process.stdout.write(HELP);
92
+ return 0;
93
+ }
94
+ if (o.version) {
95
+ process.stdout.write(`${VERSION}\n`);
96
+ return 0;
97
+ }
98
+ try {
99
+ let catalog = MODELS;
100
+ if (o.prices) {
101
+ const data = JSON.parse(readFileSync(o.prices, 'utf8'));
102
+ const problems = validateModels(data);
103
+ if (problems.length)
104
+ throw new UsageError(`Invalid price table ${o.prices}:\n ${problems.join('\n ')}`);
105
+ catalog = data;
106
+ }
107
+ if (o['list-models']) {
108
+ process.stdout.write(o.json ? JSON.stringify(catalog, null, 2) + '\n' : catalog.map(describeModel).join('\n') + '\n');
109
+ return 0;
110
+ }
111
+ const modelIds = list(o.models) ?? DEFAULT_MODEL_IDS.filter((id) => catalog.some((m) => m.id === id));
112
+ const models = modelIds.map((id) => {
113
+ const m = catalog.find((x) => x.id === id);
114
+ if (!m)
115
+ throw new UsageError(`Unknown model "${id}". Run --list-models.`);
116
+ return m;
117
+ });
118
+ const tools = list(o.tools);
119
+ for (const t of tools ?? [])
120
+ if (!TOOL_IDS.includes(t))
121
+ throw new UsageError(`Unknown tool "${t}". Use: ${TOOL_IDS.join(', ')}`);
122
+ const root = resolve(positionals[0] ?? '.');
123
+ const files = await discover(root);
124
+ const counters = {};
125
+ if (o['claude-api']) {
126
+ const key = env.ANTHROPIC_API_KEY;
127
+ if (!key)
128
+ throw new UsageError('--claude-api needs the ANTHROPIC_API_KEY environment variable.');
129
+ counters.claude = anthropicCounter(key);
130
+ if (models.some((m) => m.family === 'claude'))
131
+ notice(`Sending instruction file contents to api.anthropic.com to count Claude tokens.`);
132
+ }
133
+ if (o['gemini-api']) {
134
+ const key = env.GEMINI_API_KEY;
135
+ if (!key)
136
+ throw new UsageError('--gemini-api needs the GEMINI_API_KEY environment variable.');
137
+ counters.gemini = geminiCounter(key);
138
+ if (models.some((m) => m.family === 'gemini'))
139
+ notice(`Sending instruction file contents to generativelanguage.googleapis.com to count Gemini tokens.`);
140
+ }
141
+ const english = new Map();
142
+ for (const pair of o.english ?? []) {
143
+ const [target, source] = pair.split('=');
144
+ if (!target || !source)
145
+ throw new UsageError(`--english expects file=englishFile, got "${pair}"`);
146
+ english.set(relative(root, resolve(root, target)).replace(/\\/g, '/'), readFileSync(source, 'utf8'));
147
+ }
148
+ if (o.translate) {
149
+ if (!o['claude-api'] || !env.ANTHROPIC_API_KEY)
150
+ throw new UsageError('--translate needs --claude-api and ANTHROPIC_API_KEY.');
151
+ const always = classify(files, tools).filter((u) => u.mode === 'always' && u.part !== 'description');
152
+ const targets = [...new Set(always.filter((u) => hasNonEnglish(u.text)).map((u) => u.path))];
153
+ for (const path of targets) {
154
+ if (english.has(path))
155
+ continue;
156
+ notice(`Translating ${path} with Claude (api.anthropic.com) to measure its language multiplier.`);
157
+ try {
158
+ english.set(path, await translateToEnglish(env.ANTHROPIC_API_KEY, files.find((f) => f.path === path).text));
159
+ }
160
+ catch (e) {
161
+ notice(`Could not translate ${path}: ${e.message}. Its multiplier is not measured.`);
162
+ }
163
+ }
164
+ }
165
+ const report = await audit(files, {
166
+ tools,
167
+ models,
168
+ counters,
169
+ turns: positiveInt('turns', o.turns, 100),
170
+ advice: {
171
+ exists: existsIn(root),
172
+ packageScripts: packageScripts(root),
173
+ english,
174
+ sectionTokens: positiveInt('section-tokens', o['section-tokens'], 600),
175
+ },
176
+ });
177
+ const output = o.json
178
+ ? JSON.stringify({ root, ...report }, null, 2) + '\n'
179
+ : o.md
180
+ ? renderMarkdown(report)
181
+ : o.badge
182
+ ? badgeMarkdown(report) + '\n'
183
+ : renderText(report, root) + '\n';
184
+ if (o.out)
185
+ writeFileSync(o.out, output);
186
+ else
187
+ process.stdout.write(output);
188
+ const max = o['max-tokens'] === undefined ? undefined : positiveInt('max-tokens', o['max-tokens'], 0);
189
+ if (max !== undefined && headlineTokens(report) > max) {
190
+ process.stderr.write(`turntax: ${headlineTokens(report)} o200k tokens per turn exceeds --max-tokens ${max}\n`);
191
+ return 1;
192
+ }
193
+ return 0;
194
+ }
195
+ catch (e) {
196
+ if (e instanceof UsageError) {
197
+ process.stderr.write(`turntax: ${e.message}\n`);
198
+ return 2;
199
+ }
200
+ process.stderr.write(`turntax: ${e.message}\n`);
201
+ return 1;
202
+ }
203
+ }
204
+ function notice(message) {
205
+ process.stderr.write(`turntax: ${message}\n`);
206
+ }
207
+ function describeModel(m) {
208
+ const counting = m.offline === 'o200k-exact' ? 'offline exact' : m.offline === 'o200k-proxy' ? 'offline proxy (o200k)' : 'API only';
209
+ return `${m.id.padEnd(20)} ${m.label.padEnd(26)} in $${m.inputPerMTok ?? 'n/a'} cached $${m.cachedInputPerMTok ?? 'n/a'} ${counting} (${m.checkedAt})`;
210
+ }