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 +21 -0
- package/README.md +111 -3
- package/dist/bin.js +5 -0
- package/dist/cli.js +210 -0
- package/dist/core/advice.js +447 -0
- package/dist/core/audit.js +98 -0
- package/dist/core/classify.js +211 -0
- package/dist/core/files.js +74 -0
- package/dist/core/markdown.js +170 -0
- package/dist/core/prices.js +163 -0
- package/dist/core/render.js +143 -0
- package/dist/core/tokenize.js +21 -0
- package/dist/core/tools.js +301 -0
- package/dist/core/types.js +1 -0
- package/dist/node/discover.js +63 -0
- package/dist/node/remote.js +100 -0
- package/package.json +53 -6
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
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
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
|
+
[-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
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
|
+
}
|