turntax 0.0.0-stage → 0.2.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,252 @@
1
+ import { basename, configList, dirname, findImports, firstParagraph, parseFrontmatter, resolvePath, stripHtmlComments } from './markdown.js';
2
+ import { TOOLS } from './tools.js';
3
+ /** Works out which files each tool loads, when, and with what text. */
4
+ export function classify(input, toolIds) {
5
+ // Same text on every platform: a CRLF checkout must not count or compare differently.
6
+ const files = input.map((f) => ({ path: f.path, text: f.text.replace(/\r\n/g, '\n') }));
7
+ const byPath = new Map(files.map((f) => [f.path, f]));
8
+ const tools = toolIds ? TOOLS.filter((t) => toolIds.includes(t.id)) : TOOLS;
9
+ const units = [];
10
+ for (const tool of tools) {
11
+ const toolUnits = [];
12
+ for (const rule of tool.rules)
13
+ toolUnits.push(...applyRule(tool, rule, files, byPath));
14
+ units.push(...dedupe(toolUnits));
15
+ }
16
+ return units;
17
+ }
18
+ /** Tools that read at least one of the given files (in any mode except ignored). */
19
+ export function detectTools(units) {
20
+ return TOOLS.map((t) => t.id).filter((id) => units.some((u) => u.tool === id && u.mode !== 'ignored'));
21
+ }
22
+ function unit(tool, path, mode, trigger, text, extra = {}) {
23
+ return { tool, path, part: 'full', mode, trigger, text, notes: [], ...extra };
24
+ }
25
+ /** Directory level a hierarchical file belongs to, e.g. `pkg/.claude/CLAUDE.md` -> `pkg`. */
26
+ function levelOf(path, sameLevelDirs = []) {
27
+ const dir = dirname(path);
28
+ for (const same of sameLevelDirs) {
29
+ if (dir === same)
30
+ return '';
31
+ if (dir.endsWith('/' + same))
32
+ return dir.slice(0, -same.length - 1);
33
+ }
34
+ return dir;
35
+ }
36
+ function isRootClaudeMd(f) {
37
+ return ['CLAUDE.md', '.claude/CLAUDE.md', 'CLAUDE.local.md', '.claude/CLAUDE.local.md'].includes(f.path);
38
+ }
39
+ function applyRule(tool, rule, files, byPath) {
40
+ switch (rule.kind) {
41
+ case 'root-file': {
42
+ const f = byPath.get(rule.path);
43
+ if (!f)
44
+ return [];
45
+ return [unit(tool.id, f.path, 'always', 'project root', f.text, { notes: rule.note ? [rule.note] : [] })];
46
+ }
47
+ case 'ignored':
48
+ return files
49
+ .filter((f) => rule.pattern.test(f.path))
50
+ .map((f) => unit(tool.id, f.path, 'ignored', rule.reason, f.text, { problem: rule.reason }));
51
+ case 'pattern':
52
+ return files
53
+ .filter((f) => rule.pattern.test(f.path))
54
+ .map((f) => unit(tool.id, f.path, rule.mode, rule.trigger, f.text, { notes: rule.note ? [rule.note] : [] }));
55
+ case 'precedence': {
56
+ const inGroup = (g) => files.filter((f) => g.patterns.some((p) => p.test(f.path)));
57
+ const winner = rule.groups.find((g) => inGroup(g).length > 0);
58
+ if (!winner)
59
+ return [];
60
+ return rule.groups.flatMap((g) => inGroup(g).map((f) => g === winner
61
+ ? unit(tool.id, f.path, 'always', 'project instructions', f.text, { notes: rule.note ? [rule.note] : [] })
62
+ : unit(tool.id, f.path, 'ignored', `${winner.label} takes precedence`, f.text)));
63
+ }
64
+ case 'config-list': {
65
+ const config = byPath.get(rule.configPath);
66
+ if (!config)
67
+ return [];
68
+ return configList(config, rule.key)
69
+ .map((p) => byPath.get(p))
70
+ .filter((f) => f !== undefined)
71
+ .map((f) => unit(tool.id, f.path, 'always', rule.trigger, f.text));
72
+ }
73
+ case 'frontmatter':
74
+ return files
75
+ .filter((f) => rule.pattern.test(f.path))
76
+ .flatMap((f) => {
77
+ const { frontmatter, body } = parseFrontmatter(f.text);
78
+ const d = rule.decide(frontmatter);
79
+ const notes = d.note ? [d.note] : [];
80
+ const problem = d.problem;
81
+ // A rule folder inside a subdirectory only applies when the agent works there.
82
+ const nestedIn = rule.rootPrefixes.some((p) => f.path.startsWith(p)) ? null : f.path.slice(0, f.path.indexOf('/.') + 1);
83
+ const scoped = (mode, trigger) => nestedIn && mode === 'always' ? ['on-demand', `when working in ${nestedIn}`] : [mode, trigger];
84
+ if (!d.descriptionOnly) {
85
+ const [mode, trigger] = scoped(d.mode, d.trigger);
86
+ return [unit(tool.id, f.path, mode, trigger, body, { notes, problem })];
87
+ }
88
+ const description = String(frontmatter.description ?? '');
89
+ const [descMode, descTrigger] = scoped('always', 'description shown to the agent');
90
+ return [
91
+ unit(tool.id, f.path, descMode, descTrigger, description, { part: 'description', notes }),
92
+ unit(tool.id, f.path, d.mode, d.trigger, body, { part: 'body' }),
93
+ ];
94
+ });
95
+ case 'skill':
96
+ return files
97
+ .filter((f) => rule.pattern.test(f.path))
98
+ .flatMap((f) => {
99
+ const { frontmatter, body } = parseFrontmatter(f.text);
100
+ const isRoot = f.path.startsWith(rule.rootPrefix);
101
+ const hidden = rule.hideFlag ? frontmatter[rule.hideFlag] === true : false;
102
+ const name = String(frontmatter.name ?? basename(dirname(f.path)) ?? '');
103
+ let description = String(frontmatter.description ?? '') || firstParagraph(body);
104
+ if (frontmatter.when_to_use)
105
+ description += ' ' + String(frontmatter.when_to_use);
106
+ const notes = [];
107
+ if (rule.descriptionCap && description.length > rule.descriptionCap) {
108
+ description = description.slice(0, rule.descriptionCap);
109
+ notes.push(`Description cut to ${rule.descriptionCap} characters in the listing.`);
110
+ }
111
+ const listed = `${name}: ${description}`;
112
+ const descMode = hidden ? 'manual' : isRoot ? 'always' : 'on-demand';
113
+ const descTrigger = hidden
114
+ ? `${rule.hideFlag}: true`
115
+ : isRoot
116
+ ? 'listed every session'
117
+ : `listed when working in ${f.path.slice(0, f.path.indexOf('/.') + 1) || './'}`;
118
+ return [
119
+ unit(tool.id, f.path, descMode, descTrigger, listed, { part: 'description', notes }),
120
+ unit(tool.id, f.path, hidden ? 'manual' : 'on-demand', rule.bodyTrigger, body, { part: 'body' }),
121
+ ];
122
+ });
123
+ case 'hierarchical':
124
+ return applyHierarchical(tool, rule, files, byPath);
125
+ }
126
+ }
127
+ function applyHierarchical(tool, rule, files, byPath) {
128
+ const matches = files.filter((f) => rule.names.includes(basename(f.path)));
129
+ if (!matches.length)
130
+ return [];
131
+ let conditionFails = null;
132
+ if (rule.condition === 'claude-reads-agents-md' && files.some(isRootClaudeMd))
133
+ conditionFails = 'CLAUDE.md exists at the root, so Claude Code reads it instead of AGENTS.md (default setting).';
134
+ if (rule.condition === 'copilot-no-agents-md' && files.some((f) => f.path === 'AGENTS.md'))
135
+ conditionFails = 'A root AGENTS.md exists, which Copilot uses instead.';
136
+ // Every matching file is read, except where the tool reads only one file per directory.
137
+ const firstPerLevel = new Map();
138
+ const read = [];
139
+ const out = [];
140
+ for (const name of rule.names) {
141
+ for (const f of matches.filter((m) => basename(m.path) === name)) {
142
+ const level = levelOf(f.path, rule.sameLevelDirs);
143
+ // The docs do not say when subdirectory copies load, so they are not counted either way.
144
+ if (level !== '' && rule.nested === 'skip')
145
+ continue;
146
+ const first = firstPerLevel.get(level);
147
+ if (rule.onePerDirectory && first) {
148
+ out.push(unit(tool.id, f.path, 'ignored', `${basename(first.path)} takes precedence`, f.text));
149
+ continue;
150
+ }
151
+ if (!first)
152
+ firstPerLevel.set(level, f);
153
+ read.push(f);
154
+ }
155
+ }
156
+ for (const f of read) {
157
+ if (conditionFails) {
158
+ out.push(unit(tool.id, f.path, 'ignored', conditionFails, f.text));
159
+ continue;
160
+ }
161
+ const level = levelOf(f.path, rule.sameLevelDirs);
162
+ const isRoot = level === '';
163
+ // Claude reads a subdirectory AGENTS.md only when that directory has no CLAUDE.md of its own.
164
+ if (rule.condition === 'claude-reads-agents-md' &&
165
+ !isRoot &&
166
+ files.some((o) => /^CLAUDE(\.local)?\.md$/.test(basename(o.path)) && levelOf(o.path, ['.claude']) === level)) {
167
+ out.push(unit(tool.id, f.path, 'ignored', `CLAUDE.md in ${level}/ is read instead`, f.text));
168
+ continue;
169
+ }
170
+ if (!isRoot && rule.nested === 'ignored') {
171
+ out.push(unit(tool.id, f.path, 'ignored', 'only the root file is read', f.text));
172
+ continue;
173
+ }
174
+ const mode = isRoot ? 'always' : 'on-demand';
175
+ const trigger = isRoot ? 'project root' : `${rule.nestedTrigger} ${level}/`;
176
+ const notes = rule.note ? [rule.note] : [];
177
+ const text = transform(f.text, rule, notes);
178
+ const main = unit(tool.id, f.path, mode, trigger, text, { notes });
179
+ out.push(main);
180
+ if (rule.importDepth)
181
+ out.push(...expandImports(tool.id, main, rule, byPath, rule.importDepth));
182
+ }
183
+ return out;
184
+ }
185
+ function transform(text, rule, notes) {
186
+ let t = text;
187
+ if (rule.stripHtmlComments) {
188
+ const stripped = stripHtmlComments(t);
189
+ if (stripped.length !== t.length)
190
+ notes.push('HTML comments removed before counting (the tool strips them).');
191
+ t = stripped;
192
+ }
193
+ if (rule.maxBytes) {
194
+ const bytes = new TextEncoder().encode(t);
195
+ if (bytes.length > rule.maxBytes) {
196
+ t = new TextDecoder().decode(bytes.slice(0, rule.maxBytes)).replace(/�$/, '');
197
+ notes.push(`Truncated: only the first ${rule.maxBytes} of ${bytes.length} bytes are read; the rest never reaches the agent.`);
198
+ }
199
+ }
200
+ return t;
201
+ }
202
+ function expandImports(tool, parent, rule, byPath, depth, seen = new Set([parent.path])) {
203
+ if (depth <= 0)
204
+ return [];
205
+ const out = [];
206
+ for (const target of findImports(parent.text)) {
207
+ const resolved = resolvePath(parent.path, target);
208
+ if (resolved === null) {
209
+ parent.notes.push(`@${target} points outside the repository and is not counted.`);
210
+ continue;
211
+ }
212
+ if (seen.has(resolved))
213
+ continue;
214
+ const f = byPath.get(resolved);
215
+ if (!f)
216
+ continue;
217
+ seen.add(resolved);
218
+ const notes = [];
219
+ let mode = parent.mode;
220
+ let trigger = parent.trigger;
221
+ let text = f.text;
222
+ if (rule.importGlobs) {
223
+ const { frontmatter, body } = parseFrontmatter(f.text);
224
+ if (frontmatter.globs !== undefined && String(frontmatter.globs) !== '') {
225
+ mode = 'on-demand';
226
+ trigger = `globs: ${Array.isArray(frontmatter.globs) ? frontmatter.globs.join(', ') : String(frontmatter.globs)}`;
227
+ text = body;
228
+ }
229
+ }
230
+ const child = unit(tool, f.path, mode, trigger, transform(text, rule, notes), {
231
+ importedFrom: parent.path,
232
+ notes,
233
+ });
234
+ out.push(child, ...expandImports(tool, child, rule, byPath, depth - 1, seen));
235
+ }
236
+ return out;
237
+ }
238
+ /**
239
+ * A file can be reached twice by one tool (for example imported and also loaded directly).
240
+ * Keep the strongest load for each (path, part).
241
+ */
242
+ function dedupe(units) {
243
+ const rank = { always: 0, 'on-demand': 1, manual: 2, ignored: 3 };
244
+ const best = new Map();
245
+ for (const u of units) {
246
+ const key = `${u.path}\0${u.part}`;
247
+ const prev = best.get(key);
248
+ if (!prev || rank[u.mode] < rank[prev.mode])
249
+ best.set(key, u);
250
+ }
251
+ return units.filter((u) => best.get(`${u.path}\0${u.part}`) === u);
252
+ }
@@ -0,0 +1,95 @@
1
+ /** Which repository paths can hold agent instructions. Shared by the CLI and the web page. */
2
+ import { configList, findImports, resolvePath } from './markdown.js';
3
+ export const SKIP_DIRS = new Set([
4
+ 'node_modules',
5
+ '.git',
6
+ '.hg',
7
+ '.svn',
8
+ 'dist',
9
+ 'build',
10
+ 'out',
11
+ 'target',
12
+ 'bin',
13
+ 'obj',
14
+ 'vendor',
15
+ 'coverage',
16
+ '.next',
17
+ '.nuxt',
18
+ '.venv',
19
+ 'venv',
20
+ '__pycache__',
21
+ '.worktrees',
22
+ '.turbo',
23
+ '.cache',
24
+ ]);
25
+ const NAMES = new Set([
26
+ 'CLAUDE.md',
27
+ 'CLAUDE.local.md',
28
+ 'AGENTS.md',
29
+ 'AGENTS.override.md',
30
+ 'GEMINI.md',
31
+ '.cursorrules',
32
+ '.windsurfrules',
33
+ '.clinerules',
34
+ '.roorules',
35
+ '.rules',
36
+ 'AGENT.md',
37
+ 'CONVENTIONS.md',
38
+ '.goosehints',
39
+ '.aider.conf.yml',
40
+ ]);
41
+ const PATTERNS = [
42
+ /^\.github\/copilot-instructions\.md$/,
43
+ /^\.github\/instructions\/.+\.instructions\.md$/,
44
+ /(^|\/)\.claude\/(rules|commands)\/.+\.md$/,
45
+ /(^|\/)\.claude\/skills\/[^/]+\/SKILL\.md$/,
46
+ /(^|\/)\.claude\/agents\/[^/]+\.md$/,
47
+ /(^|\/)\.agents\/skills\/[^/]+\/SKILL\.md$/,
48
+ /(^|\/)\.cursor\/rules\/.+\.mdc?$/,
49
+ /(^|\/)\.(devin|windsurf)\/rules\/.+\.md$/,
50
+ /^\.(clinerules|cline\/rules)\/.+\.md$/,
51
+ /^\.roo\/rules(-[\w-]+)?\/.+\.(md|txt)$/,
52
+ /^\.roorules-[\w-]+$/,
53
+ /^\.kiro\/steering\/[^/]+\.md$/,
54
+ /^\.junie\/(AGENTS|playbook|guidelines)\.md$/,
55
+ /^\.junie\/(rules|guidelines)\/[^/]+\.md$/,
56
+ /^\.continue\/rules\/[^/]+\.md$/,
57
+ ];
58
+ export const MAX_DEPTH = 12;
59
+ export function isInstructionFile(path) {
60
+ const name = path.slice(path.lastIndexOf('/') + 1);
61
+ return NAMES.has(name) || PATTERNS.some((p) => p.test(path));
62
+ }
63
+ /** Directories never searched: dependencies, build output, VCS data and agent worktrees. */
64
+ export function isSkippedDir(path) {
65
+ const name = path.slice(path.lastIndexOf('/') + 1);
66
+ return SKIP_DIRS.has(name) || path === '.claude/worktrees' || path.endsWith('/.claude/worktrees');
67
+ }
68
+ /** Files a file pulls in: its @imports, or the read: list of .aider.conf.yml. */
69
+ function references(f) {
70
+ if (f.path === '.aider.conf.yml')
71
+ return configList(f, 'read');
72
+ return findImports(f.text)
73
+ .map((t) => resolvePath(f.path, t))
74
+ .filter((p) => p !== null);
75
+ }
76
+ /** Adds in-repo files reached through @imports or config read lists (up to five levels) to `found`. */
77
+ export async function addImports(found, read) {
78
+ let queue = [...found.values()];
79
+ for (let depth = 0; depth < 5 && queue.length; depth++) {
80
+ const next = [];
81
+ for (const f of queue) {
82
+ for (const rel of references(f)) {
83
+ if (found.has(rel))
84
+ continue;
85
+ const text = await read(rel);
86
+ if (text === null)
87
+ continue;
88
+ const file = { path: rel, text };
89
+ found.set(rel, file);
90
+ next.push(file);
91
+ }
92
+ }
93
+ queue = next;
94
+ }
95
+ }
@@ -0,0 +1,180 @@
1
+ const FM_RE = /^?---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
2
+ /** Parses a minimal YAML frontmatter block: scalars, inline [lists], and "- item" lists. */
3
+ export function parseFrontmatter(text) {
4
+ const match = FM_RE.exec(text);
5
+ if (!match)
6
+ return { frontmatter: {}, body: text };
7
+ const frontmatter = {};
8
+ let listKey = null;
9
+ for (const raw of (match[1] ?? '').split(/\r?\n/)) {
10
+ const item = /^\s+-\s+(.*)$/.exec(raw) ?? /^-\s+(.*)$/.exec(raw);
11
+ if (item && listKey) {
12
+ const prev = frontmatter[listKey];
13
+ const list = Array.isArray(prev) ? prev : [];
14
+ list.push(unquote(item[1] ?? ''));
15
+ frontmatter[listKey] = list;
16
+ continue;
17
+ }
18
+ const kv = /^([A-Za-z_][\w-]*)\s*:\s*(.*)$/.exec(raw);
19
+ if (!kv)
20
+ continue;
21
+ const key = kv[1];
22
+ const value = (kv[2] ?? '').trim();
23
+ listKey = null;
24
+ if (value === '') {
25
+ listKey = key;
26
+ frontmatter[key] = '';
27
+ }
28
+ else if (value.startsWith('[') && value.endsWith(']')) {
29
+ frontmatter[key] = value
30
+ .slice(1, -1)
31
+ .split(',')
32
+ .map((s) => unquote(s.trim()))
33
+ .filter(Boolean);
34
+ }
35
+ else if (value === 'true' || value === 'false') {
36
+ frontmatter[key] = value === 'true';
37
+ }
38
+ else {
39
+ frontmatter[key] = unquote(value);
40
+ }
41
+ }
42
+ return { frontmatter, body: text.slice(match[0].length) };
43
+ }
44
+ function unquote(s) {
45
+ const t = s.trim();
46
+ if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'")))
47
+ return t.slice(1, -1);
48
+ return t;
49
+ }
50
+ /** Splits text into lines, tagging lines that sit inside fenced code blocks. */
51
+ export function linesWithFences(text) {
52
+ const out = [];
53
+ let fence = null;
54
+ for (const line of text.split(/\r?\n/)) {
55
+ const m = /^\s{0,3}(`{3,}|~{3,})/.exec(line);
56
+ if (m) {
57
+ const marker = m[1][0];
58
+ if (fence === null)
59
+ fence = marker;
60
+ else if (fence === marker) {
61
+ out.push({ line, inFence: true });
62
+ fence = null;
63
+ continue;
64
+ }
65
+ }
66
+ out.push({ line, inFence: fence !== null });
67
+ }
68
+ return out;
69
+ }
70
+ /** Removes block-level HTML comments outside fenced code, as Claude Code does for CLAUDE.md. */
71
+ export function stripHtmlComments(text) {
72
+ const kept = [];
73
+ let inComment = false;
74
+ for (const { line, inFence } of linesWithFences(text)) {
75
+ if (inFence && !inComment) {
76
+ kept.push(line);
77
+ continue;
78
+ }
79
+ if (!inComment && /^\s*<!--/.test(line)) {
80
+ if (line.includes('-->')) {
81
+ const rest = line.slice(line.indexOf('-->') + 3);
82
+ if (rest.trim())
83
+ kept.push(rest);
84
+ continue;
85
+ }
86
+ inComment = true;
87
+ continue;
88
+ }
89
+ if (inComment) {
90
+ if (line.includes('-->')) {
91
+ inComment = false;
92
+ const rest = line.slice(line.indexOf('-->') + 3);
93
+ if (rest.trim())
94
+ kept.push(rest);
95
+ }
96
+ continue;
97
+ }
98
+ kept.push(line);
99
+ }
100
+ return kept.join('\n');
101
+ }
102
+ /** Finds `@path` imports outside code spans and fenced blocks. */
103
+ export function findImports(text) {
104
+ const found = [];
105
+ for (const { line, inFence } of linesWithFences(text)) {
106
+ if (inFence)
107
+ continue;
108
+ const noCode = line.replace(/`[^`]*`/g, '');
109
+ for (const m of noCode.matchAll(/(?:^|\s)@((?:~|\.{1,2})?\/?[\w.-][\w./-]*)/g)) {
110
+ const target = m[1].replace(/[.,;:)]+$/, '');
111
+ if (target.includes('/') || /\.\w+$/.test(target))
112
+ found.push(target);
113
+ }
114
+ }
115
+ return found;
116
+ }
117
+ /** Splits markdown into sections at ATX headings of level 1-3 (outside fences). */
118
+ export function splitSections(text) {
119
+ const sections = [];
120
+ let current = { heading: '(top)', line: 1, text: '' };
121
+ const lines = linesWithFences(text);
122
+ lines.forEach(({ line, inFence }, i) => {
123
+ const h = inFence ? null : /^\s{0,3}(#{1,3})\s+(.+?)\s*#*\s*$/.exec(line);
124
+ if (h) {
125
+ if (current.text.trim())
126
+ sections.push(current);
127
+ current = { heading: h[2], line: i + 1, text: line + '\n' };
128
+ }
129
+ else {
130
+ current.text += line + '\n';
131
+ }
132
+ });
133
+ if (current.text.trim())
134
+ sections.push(current);
135
+ return sections;
136
+ }
137
+ /** First non-empty paragraph of a markdown body, ignoring headings. */
138
+ export function firstParagraph(body) {
139
+ const paras = body
140
+ .split(/\r?\n\s*\r?\n/)
141
+ .map((p) => p.trim())
142
+ .filter((p) => p && !/^#/.test(p));
143
+ return paras[0] ?? '';
144
+ }
145
+ export function dirname(path) {
146
+ const i = path.lastIndexOf('/');
147
+ return i < 0 ? '' : path.slice(0, i);
148
+ }
149
+ export function basename(path) {
150
+ return path.slice(path.lastIndexOf('/') + 1);
151
+ }
152
+ /** Resolves a relative import against the importing file's directory. Returns null when it escapes the repo. */
153
+ export function resolvePath(fromFile, target) {
154
+ // Home-relative and absolute paths point outside the repository.
155
+ if (target.startsWith('~') || target.startsWith('/') || /^[A-Za-z]:/.test(target))
156
+ return null;
157
+ const parts = dirname(fromFile).split('/').filter(Boolean);
158
+ for (const seg of target.split('/')) {
159
+ if (seg === '' || seg === '.')
160
+ continue;
161
+ if (seg === '..') {
162
+ if (!parts.length)
163
+ return null;
164
+ parts.pop();
165
+ }
166
+ else
167
+ parts.push(seg);
168
+ }
169
+ return parts.join('/');
170
+ }
171
+ /** Paths listed under `key` in a small YAML config (string, inline list or block list). */
172
+ export function configList(config, key) {
173
+ const value = parseFrontmatter(`---\n${config.text}\n---\n`).frontmatter[key];
174
+ const list = value === undefined || value === '' ? [] : Array.isArray(value) ? value : [String(value)];
175
+ return list
176
+ .map((p) => p.replace(/\s+#.*$/, '').trim())
177
+ .filter(Boolean)
178
+ .map((p) => resolvePath(config.path, p))
179
+ .filter((p) => p !== null);
180
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Dated price table. USD per 1M tokens, standard tier, shortest-context tier.
3
+ * Each entry cites the official pricing page and the date it was read.
4
+ * Override at runtime with `--prices <file.json>` (same shape, array of ModelInfo).
5
+ *
6
+ * offline:
7
+ * o200k-exact - OpenAI's tiktoken maps this model to o200k_base (exact offline count)
8
+ * o200k-proxy - no published encoding for this model; o200k_base is used as a labelled proxy
9
+ * none - no public tokenizer; count only through the vendor API
10
+ */
11
+ const ANTHROPIC = 'https://platform.claude.com/docs/en/about-claude/pricing';
12
+ const OPENAI = 'https://developers.openai.com/api/docs/pricing';
13
+ const GOOGLE = 'https://ai.google.dev/gemini-api/docs/pricing';
14
+ const CHECKED = '2026-10-10';
15
+ export const MODELS = [
16
+ {
17
+ id: 'claude-opus-5.5',
18
+ label: 'Claude Opus 5.5',
19
+ family: 'claude',
20
+ inputPerMTok: 4,
21
+ cachedInputPerMTok: 0.2,
22
+ source: ANTHROPIC,
23
+ checkedAt: CHECKED,
24
+ offline: 'none',
25
+ apiModel: 'claude-opus-5-5',
26
+ },
27
+ {
28
+ id: 'claude-sonnet-5.5',
29
+ label: 'Claude Sonnet 5.5',
30
+ family: 'claude',
31
+ inputPerMTok: 2,
32
+ cachedInputPerMTok: 0.1,
33
+ source: ANTHROPIC,
34
+ checkedAt: CHECKED,
35
+ offline: 'none',
36
+ apiModel: 'claude-sonnet-5-5',
37
+ },
38
+ {
39
+ id: 'claude-haiku-5.5',
40
+ label: 'Claude Haiku 5.5',
41
+ family: 'claude',
42
+ inputPerMTok: 0.1,
43
+ cachedInputPerMTok: 0.01,
44
+ priceNote: 'Prompts up to 100K tokens; $0.50 / $0.05 above.',
45
+ source: ANTHROPIC,
46
+ checkedAt: CHECKED,
47
+ offline: 'none',
48
+ apiModel: 'claude-haiku-5-5',
49
+ },
50
+ {
51
+ id: 'gpt-6.1-sol',
52
+ label: 'GPT-6.1 Sol',
53
+ family: 'openai',
54
+ inputPerMTok: 2,
55
+ cachedInputPerMTok: 0.1,
56
+ priceNote: 'Prompts up to 272K tokens; $4 / $0.20 above.',
57
+ source: OPENAI,
58
+ checkedAt: CHECKED,
59
+ offline: 'o200k-proxy',
60
+ },
61
+ {
62
+ id: 'gpt-6-astra',
63
+ label: 'GPT-6 Astra',
64
+ family: 'openai',
65
+ inputPerMTok: 10,
66
+ cachedInputPerMTok: 1,
67
+ priceNote: 'Prompts up to 272K tokens; $20 / $2 above.',
68
+ source: OPENAI,
69
+ checkedAt: CHECKED,
70
+ offline: 'o200k-proxy',
71
+ },
72
+ {
73
+ id: 'gpt-6-luna',
74
+ label: 'GPT-6 Luna',
75
+ family: 'openai',
76
+ inputPerMTok: 0.1,
77
+ cachedInputPerMTok: 0.01,
78
+ priceNote: 'Prompts up to 272K tokens; $0.20 / $0.02 above.',
79
+ source: OPENAI,
80
+ checkedAt: CHECKED,
81
+ offline: 'o200k-proxy',
82
+ },
83
+ {
84
+ id: 'gpt-5.3-codex',
85
+ label: 'GPT-5.3 Codex',
86
+ family: 'openai',
87
+ inputPerMTok: 1.75,
88
+ cachedInputPerMTok: 0.175,
89
+ source: OPENAI,
90
+ checkedAt: CHECKED,
91
+ offline: 'o200k-exact',
92
+ },
93
+ {
94
+ id: 'gemini-3.1-pro',
95
+ label: 'Gemini 3.1 Pro (preview)',
96
+ family: 'gemini',
97
+ inputPerMTok: 2,
98
+ cachedInputPerMTok: 0.2,
99
+ priceNote: 'Prompts up to 200K tokens; $4 / $0.40 above. Cache storage billed per hour.',
100
+ source: GOOGLE,
101
+ checkedAt: CHECKED,
102
+ offline: 'none',
103
+ apiModel: 'gemini-3.1-pro-preview',
104
+ },
105
+ {
106
+ id: 'gemini-3.8-flash',
107
+ label: 'Gemini 3.8 Flash',
108
+ family: 'gemini',
109
+ inputPerMTok: 0.75,
110
+ cachedInputPerMTok: 0.075,
111
+ priceNote: 'Price through 2026-12-31; $1.50 / $0.15 from 2027-01-01. Cache storage billed per hour.',
112
+ source: GOOGLE,
113
+ checkedAt: CHECKED,
114
+ offline: 'none',
115
+ apiModel: 'gemini-3.8-flash',
116
+ },
117
+ {
118
+ id: 'gpt-oss',
119
+ label: 'gpt-oss (open weights)',
120
+ family: 'open',
121
+ inputPerMTok: null,
122
+ cachedInputPerMTok: null,
123
+ priceNote: 'Self-hosted; no per-token list price.',
124
+ source: 'https://github.com/openai/tiktoken/blob/main/tiktoken/model.py',
125
+ checkedAt: CHECKED,
126
+ offline: 'o200k-exact',
127
+ },
128
+ ];
129
+ /** Models shown when the user does not pass --models. */
130
+ export const DEFAULT_MODEL_IDS = ['claude-sonnet-5.5', 'gpt-6.1-sol', 'gemini-3.8-flash'];
131
+ export function findModel(models, id) {
132
+ return models.find((m) => m.id === id);
133
+ }
134
+ /** Validates a user-supplied price table. Returns a list of problems (empty when valid). */
135
+ export function validateModels(input) {
136
+ if (!Array.isArray(input))
137
+ return ['price table must be a JSON array'];
138
+ const problems = [];
139
+ input.forEach((m, i) => {
140
+ const at = `entry ${i}${m?.id ? ` (${m.id})` : ''}`;
141
+ if (!m || typeof m.id !== 'string' || !m.id)
142
+ problems.push(`${at}: missing id`);
143
+ if (typeof m?.label !== 'string')
144
+ problems.push(`${at}: missing label`);
145
+ if (!['claude', 'openai', 'gemini', 'open'].includes(String(m?.family)))
146
+ problems.push(`${at}: bad family`);
147
+ for (const key of ['inputPerMTok', 'cachedInputPerMTok']) {
148
+ const v = m?.[key];
149
+ if (!(v === null || (typeof v === 'number' && v >= 0)))
150
+ problems.push(`${at}: ${key} must be a number >= 0 or null`);
151
+ }
152
+ if (typeof m?.source !== 'string' || !m.source.startsWith('https://'))
153
+ problems.push(`${at}: source must be an https URL`);
154
+ if (typeof m?.checkedAt !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(m.checkedAt))
155
+ problems.push(`${at}: checkedAt must be YYYY-MM-DD`);
156
+ if (!['o200k-exact', 'o200k-proxy', 'none'].includes(String(m?.offline)))
157
+ problems.push(`${at}: bad offline`);
158
+ // Claude and Gemini tokenizers are not public: an offline count must never be labelled as theirs.
159
+ if ((m?.family === 'claude' || m?.family === 'gemini') && m?.offline !== 'none')
160
+ problems.push(`${at}: ${m.family} models must use offline "none" (counted only through the vendor API)`);
161
+ });
162
+ return problems;
163
+ }