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
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
export const METHOD_LABEL = {
|
|
2
|
+
'offline-exact': 'exact (offline tokenizer)',
|
|
3
|
+
'offline-proxy': 'estimate (o200k_base proxy)',
|
|
4
|
+
api: 'vendor API count',
|
|
5
|
+
unavailable: 'not counted',
|
|
6
|
+
'none-loaded': 'nothing always loaded',
|
|
7
|
+
};
|
|
8
|
+
export function fmtTokens(n) {
|
|
9
|
+
return n.toLocaleString('en-US');
|
|
10
|
+
}
|
|
11
|
+
export function fmtUsd(n) {
|
|
12
|
+
if (n === null)
|
|
13
|
+
return 'n/a';
|
|
14
|
+
if (n === 0)
|
|
15
|
+
return '$0';
|
|
16
|
+
if (n < 0.01)
|
|
17
|
+
return `$${n.toFixed(4)}`;
|
|
18
|
+
return `$${n.toFixed(2)}`;
|
|
19
|
+
}
|
|
20
|
+
function table(rows, indent = '') {
|
|
21
|
+
const widths = rows[0].map((_, c) => Math.max(...rows.map((r) => (r[c] ?? '').length)));
|
|
22
|
+
return rows.map((r) => indent + r.map((cell, c) => cell.padEnd(widths[c])).join(' ').trimEnd()).join('\n');
|
|
23
|
+
}
|
|
24
|
+
/** Highest always-loaded o200k total across the reported tools. */
|
|
25
|
+
export function headlineTokens(report) {
|
|
26
|
+
return Math.max(0, ...report.tools.map((t) => t.perTurnO200k));
|
|
27
|
+
}
|
|
28
|
+
export function renderText(report, root) {
|
|
29
|
+
const out = [];
|
|
30
|
+
out.push(`turntax: per-turn context tax${root ? ` for ${root}` : ''}`);
|
|
31
|
+
out.push('');
|
|
32
|
+
if (!report.tools.length) {
|
|
33
|
+
out.push('No agent instruction files found.');
|
|
34
|
+
return out.join('\n');
|
|
35
|
+
}
|
|
36
|
+
const rows = [
|
|
37
|
+
['tool', 'always', 'o200k/turn', 'on-demand', 'model', 'tokens/turn', 'counted by', `${report.turns} turns`, 'cached'],
|
|
38
|
+
];
|
|
39
|
+
for (const t of report.tools) {
|
|
40
|
+
const head = [t.name, String(t.alwaysFiles), fmtTokens(t.perTurnO200k), fmtTokens(t.onDemandO200k)];
|
|
41
|
+
const blank = head.map(() => '');
|
|
42
|
+
if (!t.models.length)
|
|
43
|
+
rows.push([...head, '(no selected model)', '', '', '', '']);
|
|
44
|
+
t.models.forEach((m, i) => rows.push([
|
|
45
|
+
...(i === 0 ? head : blank),
|
|
46
|
+
m.label,
|
|
47
|
+
...(m.perTurn === null
|
|
48
|
+
? ['-', m.reason ?? METHOD_LABEL[m.method], '-', '-']
|
|
49
|
+
: [fmtTokens(m.perTurn), METHOD_LABEL[m.method], fmtUsd(m.uncached), fmtUsd(m.cached)]),
|
|
50
|
+
]));
|
|
51
|
+
}
|
|
52
|
+
out.push(table(rows));
|
|
53
|
+
out.push('');
|
|
54
|
+
out.push('Files');
|
|
55
|
+
const fileRows = [['tool', 'mode', 'file', 'o200k', 'loads']];
|
|
56
|
+
for (const f of report.files) {
|
|
57
|
+
const name = f.part === 'full' ? f.path : `${f.path} (${f.part})`;
|
|
58
|
+
fileRows.push([f.tool, f.mode, name + (f.importedFrom ? ` <- @${f.importedFrom}` : ''), fmtTokens(f.o200k), f.trigger]);
|
|
59
|
+
}
|
|
60
|
+
out.push(table(fileRows, ' '));
|
|
61
|
+
const notes = [...new Set(report.files.flatMap((f) => f.notes.map((n) => `${f.path}: ${n}`)))];
|
|
62
|
+
if (notes.length) {
|
|
63
|
+
out.push('');
|
|
64
|
+
out.push('Notes');
|
|
65
|
+
for (const n of notes)
|
|
66
|
+
out.push(` - ${n}`);
|
|
67
|
+
}
|
|
68
|
+
out.push('');
|
|
69
|
+
out.push('Advice (savings are estimates in o200k tokens per turn)');
|
|
70
|
+
if (!report.advice.length)
|
|
71
|
+
out.push(' No issues found.');
|
|
72
|
+
for (const a of report.advice) {
|
|
73
|
+
const saving = a.savingTokens === null ? '' : ` [saves ~${fmtTokens(a.savingTokens)} tokens/turn]`;
|
|
74
|
+
const where = a.paths.join(', ') + (a.line ? `:${a.line}` : '');
|
|
75
|
+
out.push(` ${a.severity.toUpperCase()} ${a.kind} ${where}${saving}`);
|
|
76
|
+
out.push(` ${a.message}${a.savingNote ? ` (${a.savingNote})` : ''}`);
|
|
77
|
+
}
|
|
78
|
+
out.push('');
|
|
79
|
+
out.push(footer(report));
|
|
80
|
+
return out.join('\n');
|
|
81
|
+
}
|
|
82
|
+
function footer(report) {
|
|
83
|
+
const lines = [
|
|
84
|
+
'o200k = OpenAI o200k_base, counted offline. It is a reference for every file, not a Claude or Gemini count.',
|
|
85
|
+
'Claude and Gemini tokenizers are not public: their counts come only from the vendor APIs (--claude-api, --gemini-api).',
|
|
86
|
+
`Cost = tokens/turn x ${report.turns} turns x input price. "Cached" assumes every turn reads the context from the prompt cache.`,
|
|
87
|
+
'Prices (USD per 1M input tokens):',
|
|
88
|
+
];
|
|
89
|
+
for (const m of report.models) {
|
|
90
|
+
lines.push(` ${m.label}: ${m.inputPerMTok ?? 'n/a'} input, ${m.cachedInputPerMTok ?? 'n/a'} cached` +
|
|
91
|
+
`${m.priceNote ? ` (${m.priceNote})` : ''}; ${m.source}, checked ${m.checkedAt}`);
|
|
92
|
+
}
|
|
93
|
+
return lines.join('\n');
|
|
94
|
+
}
|
|
95
|
+
export function renderMarkdown(report) {
|
|
96
|
+
const out = ['# Agent context tax report', ''];
|
|
97
|
+
out.push(`Every agent re-sends its always-loaded instruction files with each turn. Costs are for ${report.turns} turns.`, '');
|
|
98
|
+
out.push('## Per tool', '');
|
|
99
|
+
out.push('| Tool | Model | Tokens / turn | Counted by | Uncached | Cached |', '| --- | --- | ---: | --- | ---: | ---: |');
|
|
100
|
+
for (const t of report.tools) {
|
|
101
|
+
if (!t.models.length)
|
|
102
|
+
out.push(`| ${t.name} | - | ${fmtTokens(t.perTurnO200k)} (o200k) | reference | - | - |`);
|
|
103
|
+
for (const m of t.models)
|
|
104
|
+
out.push(m.perTurn === null
|
|
105
|
+
? `| ${t.name} | ${m.label} | - | ${m.reason ?? ''} | - | - |`
|
|
106
|
+
: `| ${t.name} | ${m.label} | ${fmtTokens(m.perTurn)} | ${METHOD_LABEL[m.method]} | ${fmtUsd(m.uncached)} | ${fmtUsd(m.cached)} |`);
|
|
107
|
+
}
|
|
108
|
+
out.push('', '## Files', '', '| Tool | Mode | File | o200k tokens | Loads |', '| --- | --- | --- | ---: | --- |');
|
|
109
|
+
for (const f of report.files) {
|
|
110
|
+
const name = f.part === 'full' ? f.path : `${f.path} (${f.part})`;
|
|
111
|
+
out.push(`| ${f.tool} | ${f.mode} | \`${name}\` | ${fmtTokens(f.o200k)} | ${escapeCell(f.trigger)} |`);
|
|
112
|
+
}
|
|
113
|
+
out.push('', '## Advice', '');
|
|
114
|
+
if (!report.advice.length)
|
|
115
|
+
out.push('No issues found.');
|
|
116
|
+
for (const a of report.advice) {
|
|
117
|
+
const saving = a.savingTokens === null ? '' : ` Saves about ${fmtTokens(a.savingTokens)} tokens per turn.`;
|
|
118
|
+
out.push(`- **${a.severity}** \`${a.kind}\` ${a.paths.map((p) => `\`${p}\``).join(', ')}${a.line ? ` line ${a.line}` : ''}: ${a.message}${saving}${a.savingNote ? ` _${a.savingNote}_` : ''}`);
|
|
119
|
+
}
|
|
120
|
+
out.push('', '## Method', '');
|
|
121
|
+
out.push('- o200k = OpenAI `o200k_base`, counted offline; a reference, not a Claude or Gemini count.');
|
|
122
|
+
out.push('- Claude counts come only from Anthropic `count_tokens`; Gemini counts only from Google `countTokens`.');
|
|
123
|
+
out.push('- "Cached" assumes every turn reads the context from the prompt cache.');
|
|
124
|
+
for (const m of report.models)
|
|
125
|
+
out.push(`- ${m.label}: $${m.inputPerMTok ?? 'n/a'} input / $${m.cachedInputPerMTok ?? 'n/a'} cached per 1M tokens ([source](${m.source}), checked ${m.checkedAt}).`);
|
|
126
|
+
out.push('', badgeMarkdown(report));
|
|
127
|
+
return out.join('\n') + '\n';
|
|
128
|
+
}
|
|
129
|
+
function escapeCell(s) {
|
|
130
|
+
return s.replace(/\|/g, '\\|');
|
|
131
|
+
}
|
|
132
|
+
function shieldsEscape(s) {
|
|
133
|
+
return encodeURIComponent(s.replace(/-/g, '--').replace(/_/g, '__'));
|
|
134
|
+
}
|
|
135
|
+
export function badgeUrl(report) {
|
|
136
|
+
const n = headlineTokens(report);
|
|
137
|
+
const value = n >= 1000 ? `${(n / 1000).toFixed(1)}k tokens/turn` : `${n} tokens/turn`;
|
|
138
|
+
const color = n < 2000 ? 'brightgreen' : n < 5000 ? 'yellow' : n < 10000 ? 'orange' : 'red';
|
|
139
|
+
return `https://img.shields.io/badge/${shieldsEscape('context tax (o200k)')}-${shieldsEscape(value)}-${color}`;
|
|
140
|
+
}
|
|
141
|
+
export function badgeMarkdown(report) {
|
|
142
|
+
return `[})](https://github.com/TorutDev/turntax)`;
|
|
143
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { countTokens } from 'gpt-tokenizer/encoding/o200k_base';
|
|
2
|
+
const PLAIN_TEXT = { disallowedSpecial: new Set() };
|
|
3
|
+
/** OpenAI o200k_base token count. Special-token strings are counted as ordinary text. */
|
|
4
|
+
export function countO200k(text) {
|
|
5
|
+
return text ? countTokens(text, PLAIN_TEXT) : 0;
|
|
6
|
+
}
|
|
7
|
+
/** Counts offline where a public tokenizer exists; reports the rest as unavailable. */
|
|
8
|
+
export const offlineCounter = {
|
|
9
|
+
async count(model, texts) {
|
|
10
|
+
if (model.offline === 'none') {
|
|
11
|
+
const reason = model.family === 'claude'
|
|
12
|
+
? 'needs --claude-api and ANTHROPIC_API_KEY'
|
|
13
|
+
: model.family === 'gemini'
|
|
14
|
+
? 'needs --gemini-api and GEMINI_API_KEY'
|
|
15
|
+
: 'no offline tokenizer';
|
|
16
|
+
return texts.map(() => ({ tokens: null, method: 'unavailable', reason }));
|
|
17
|
+
}
|
|
18
|
+
const method = model.offline === 'o200k-exact' ? 'offline-exact' : 'offline-proxy';
|
|
19
|
+
return texts.map((t) => ({ tokens: countO200k(t), method }));
|
|
20
|
+
},
|
|
21
|
+
};
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
const str = (v) => v === undefined ? '' : Array.isArray(v) ? v.join(', ') : String(v);
|
|
2
|
+
const has = (fm, key) => str(fm[key]).trim() !== '';
|
|
3
|
+
export const TOOLS = [
|
|
4
|
+
{
|
|
5
|
+
id: 'claude-code',
|
|
6
|
+
name: 'Claude Code',
|
|
7
|
+
families: ['claude'],
|
|
8
|
+
sources: [
|
|
9
|
+
'https://code.claude.com/docs/en/memory',
|
|
10
|
+
'https://code.claude.com/docs/en/skills',
|
|
11
|
+
'https://code.claude.com/docs/en/sub-agents',
|
|
12
|
+
],
|
|
13
|
+
checkedAt: '2026-10-10',
|
|
14
|
+
rules: [
|
|
15
|
+
{
|
|
16
|
+
kind: 'hierarchical',
|
|
17
|
+
names: ['CLAUDE.md', 'CLAUDE.local.md'],
|
|
18
|
+
sameLevelDirs: ['.claude'],
|
|
19
|
+
nested: 'on-demand',
|
|
20
|
+
nestedTrigger: 'when Claude reads or edits a file in',
|
|
21
|
+
stripHtmlComments: true,
|
|
22
|
+
importDepth: 4,
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
kind: 'hierarchical',
|
|
26
|
+
names: ['AGENTS.md'],
|
|
27
|
+
sameLevelDirs: ['.claude'],
|
|
28
|
+
nested: 'on-demand',
|
|
29
|
+
nestedTrigger: 'when Claude reads a file in',
|
|
30
|
+
importDepth: 4,
|
|
31
|
+
condition: 'claude-reads-agents-md',
|
|
32
|
+
note: 'Read only because no CLAUDE.md or CLAUDE.local.md exists at the root (default setting).',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
kind: 'frontmatter',
|
|
36
|
+
pattern: /^(?:.*\/)?\.claude\/rules\/.+\.md$/,
|
|
37
|
+
rootPrefixes: ['.claude/rules/'],
|
|
38
|
+
decide: (fm) => has(fm, 'paths')
|
|
39
|
+
? { mode: 'on-demand', trigger: `paths: ${str(fm.paths)}` }
|
|
40
|
+
: { mode: 'always', trigger: 'rule without paths' },
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
kind: 'skill',
|
|
44
|
+
pattern: /^(?:.*\/)?\.claude\/skills\/[^/]+\/SKILL\.md$/,
|
|
45
|
+
rootPrefix: '.claude/skills/',
|
|
46
|
+
descriptionCap: 1536,
|
|
47
|
+
hideFlag: 'disable-model-invocation',
|
|
48
|
+
bodyTrigger: 'when the skill is invoked',
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
kind: 'skill',
|
|
52
|
+
pattern: /^(?:.*\/)?\.claude\/commands\/.+\.md$/,
|
|
53
|
+
rootPrefix: '.claude/commands/',
|
|
54
|
+
descriptionCap: 1536,
|
|
55
|
+
hideFlag: 'disable-model-invocation',
|
|
56
|
+
bodyTrigger: 'when the command is invoked',
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
kind: 'skill',
|
|
60
|
+
pattern: /^(?:.*\/)?\.claude\/agents\/[^/]+\.md$/,
|
|
61
|
+
rootPrefix: '.claude/agents/',
|
|
62
|
+
bodyTrigger: "as the subagent's own system prompt when it runs",
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
limits: [
|
|
66
|
+
{
|
|
67
|
+
pattern: /(^|\/)CLAUDE(\.local)?\.md$/,
|
|
68
|
+
unit: 'lines',
|
|
69
|
+
max: 200,
|
|
70
|
+
severity: 'advice',
|
|
71
|
+
text: 'Claude Code docs recommend keeping each CLAUDE.md under 200 lines.',
|
|
72
|
+
source: 'https://code.claude.com/docs/en/memory',
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
notes: [
|
|
76
|
+
'User (~/.claude/CLAUDE.md), managed policy and auto-memory files live outside the repository and are not counted.',
|
|
77
|
+
'With the "claude-md-and-agents-md" setting Claude Code reads AGENTS.md as well; the default reads it only when no CLAUDE.md exists.',
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
id: 'codex',
|
|
82
|
+
name: 'OpenAI Codex',
|
|
83
|
+
families: ['openai'],
|
|
84
|
+
sources: [
|
|
85
|
+
'https://learn.chatgpt.com/docs/agent-configuration/agents-md',
|
|
86
|
+
'https://learn.chatgpt.com/docs/build-skills',
|
|
87
|
+
],
|
|
88
|
+
checkedAt: '2026-10-10',
|
|
89
|
+
rules: [
|
|
90
|
+
{
|
|
91
|
+
kind: 'hierarchical',
|
|
92
|
+
names: ['AGENTS.override.md', 'AGENTS.md'],
|
|
93
|
+
onePerDirectory: true,
|
|
94
|
+
nested: 'on-demand',
|
|
95
|
+
nestedTrigger: 'only when Codex is launched in',
|
|
96
|
+
maxBytes: 32 * 1024,
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
kind: 'skill',
|
|
100
|
+
pattern: /^(?:.*\/)?\.agents\/skills\/[^/]+\/SKILL\.md$/,
|
|
101
|
+
rootPrefix: '.agents/skills/',
|
|
102
|
+
bodyTrigger: 'when the skill is selected',
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
limits: [
|
|
106
|
+
{
|
|
107
|
+
pattern: /(^|\/)AGENTS(\.override)?\.md$/,
|
|
108
|
+
unit: 'bytes',
|
|
109
|
+
max: 32 * 1024,
|
|
110
|
+
severity: 'truncate',
|
|
111
|
+
text: 'Codex stops reading project docs at project_doc_max_bytes (default 32 KiB); the rest is dropped.',
|
|
112
|
+
source: 'https://learn.chatgpt.com/docs/agent-configuration/agents-md',
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
notes: ['Global ~/.codex/AGENTS.md is outside the repository and not counted.'],
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
id: 'gemini-cli',
|
|
119
|
+
name: 'Gemini CLI',
|
|
120
|
+
families: ['gemini'],
|
|
121
|
+
sources: ['https://geminicli.com/docs/cli/gemini-md/'],
|
|
122
|
+
checkedAt: '2026-10-10',
|
|
123
|
+
rules: [
|
|
124
|
+
{
|
|
125
|
+
kind: 'hierarchical',
|
|
126
|
+
names: ['GEMINI.md'],
|
|
127
|
+
nested: 'on-demand',
|
|
128
|
+
nestedTrigger: 'when a tool accesses',
|
|
129
|
+
// The docs describe @file.md imports but not a depth limit; deeper chains are rare.
|
|
130
|
+
importDepth: 5,
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
limits: [],
|
|
134
|
+
notes: [
|
|
135
|
+
'Only GEMINI.md is read by default; context.fileName can add AGENTS.md (not modelled).',
|
|
136
|
+
'Global ~/.gemini/GEMINI.md is outside the repository and not counted.',
|
|
137
|
+
],
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
id: 'cursor',
|
|
141
|
+
name: 'Cursor',
|
|
142
|
+
families: [],
|
|
143
|
+
sources: ['https://cursor.com/docs/context/rules', 'https://cursor.com/help/customization/rules'],
|
|
144
|
+
checkedAt: '2026-10-10',
|
|
145
|
+
rules: [
|
|
146
|
+
{
|
|
147
|
+
kind: 'frontmatter',
|
|
148
|
+
pattern: /^(?:.*\/)?\.cursor\/rules\/.+\.mdc$/,
|
|
149
|
+
rootPrefixes: ['.cursor/rules/'],
|
|
150
|
+
decide: (fm) => {
|
|
151
|
+
if (fm.alwaysApply === true || str(fm.alwaysApply) === 'true')
|
|
152
|
+
return { mode: 'always', trigger: 'alwaysApply: true' };
|
|
153
|
+
if (has(fm, 'globs'))
|
|
154
|
+
return { mode: 'on-demand', trigger: `globs: ${str(fm.globs)}` };
|
|
155
|
+
if (has(fm, 'description'))
|
|
156
|
+
return {
|
|
157
|
+
mode: 'on-demand',
|
|
158
|
+
trigger: 'agent decides from description',
|
|
159
|
+
descriptionOnly: true,
|
|
160
|
+
note: 'Description assumed to be in context so the agent can decide (inferred, not documented).',
|
|
161
|
+
};
|
|
162
|
+
return { mode: 'manual', trigger: '@-mention only' };
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
kind: 'ignored',
|
|
167
|
+
pattern: /^(?:.*\/)?\.cursor\/rules\/.+\.md$/,
|
|
168
|
+
reason: 'Cursor ignores plain .md files in .cursor/rules; rename to .mdc.',
|
|
169
|
+
},
|
|
170
|
+
{ kind: 'root-file', path: '.cursorrules', note: 'Legacy file; Cursor plans to deprecate it.' },
|
|
171
|
+
{
|
|
172
|
+
kind: 'hierarchical',
|
|
173
|
+
names: ['AGENTS.md'],
|
|
174
|
+
nested: 'on-demand',
|
|
175
|
+
nestedTrigger: 'when working in',
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
kind: 'hierarchical',
|
|
179
|
+
names: ['CLAUDE.md'],
|
|
180
|
+
nested: 'on-demand',
|
|
181
|
+
nestedTrigger: 'when working in',
|
|
182
|
+
},
|
|
183
|
+
],
|
|
184
|
+
limits: [
|
|
185
|
+
{
|
|
186
|
+
pattern: /\.mdc$/,
|
|
187
|
+
unit: 'lines',
|
|
188
|
+
max: 500,
|
|
189
|
+
severity: 'advice',
|
|
190
|
+
text: 'Cursor docs recommend keeping each rule under 500 lines.',
|
|
191
|
+
source: 'https://cursor.com/docs/context/rules',
|
|
192
|
+
},
|
|
193
|
+
],
|
|
194
|
+
notes: ['User and Team rules are configured in Cursor settings, not in the repository, and are not counted.'],
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
id: 'copilot',
|
|
198
|
+
name: 'GitHub Copilot',
|
|
199
|
+
families: [],
|
|
200
|
+
sources: [
|
|
201
|
+
'https://docs.github.com/en/copilot/how-tos/configure-custom-instructions/add-repository-instructions',
|
|
202
|
+
'https://code.visualstudio.com/docs/copilot/customization/custom-instructions',
|
|
203
|
+
],
|
|
204
|
+
checkedAt: '2026-10-10',
|
|
205
|
+
rules: [
|
|
206
|
+
{ kind: 'root-file', path: '.github/copilot-instructions.md' },
|
|
207
|
+
{
|
|
208
|
+
kind: 'frontmatter',
|
|
209
|
+
pattern: /^\.github\/instructions\/.+\.instructions\.md$/,
|
|
210
|
+
rootPrefixes: ['.github/instructions/'],
|
|
211
|
+
decide: (fm) => {
|
|
212
|
+
if (has(fm, 'applyTo'))
|
|
213
|
+
return { mode: 'on-demand', trigger: `applyTo: ${str(fm.applyTo)}` };
|
|
214
|
+
if (has(fm, 'description'))
|
|
215
|
+
return {
|
|
216
|
+
mode: 'on-demand',
|
|
217
|
+
trigger: 'agent decides from description (VS Code)',
|
|
218
|
+
descriptionOnly: true,
|
|
219
|
+
note: 'Description assumed to be in context so the agent can decide (inferred, not documented).',
|
|
220
|
+
};
|
|
221
|
+
return { mode: 'manual', trigger: 'attached manually' };
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
kind: 'hierarchical',
|
|
226
|
+
names: ['AGENTS.md'],
|
|
227
|
+
nested: 'on-demand',
|
|
228
|
+
nestedTrigger: 'nearest AGENTS.md wins when working in',
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
kind: 'hierarchical',
|
|
232
|
+
names: ['CLAUDE.md', 'GEMINI.md'],
|
|
233
|
+
onePerDirectory: true,
|
|
234
|
+
nested: 'ignored',
|
|
235
|
+
nestedTrigger: '',
|
|
236
|
+
condition: 'copilot-no-agents-md',
|
|
237
|
+
note: 'Copilot docs allow a single root CLAUDE.md or GEMINI.md as an alternative to AGENTS.md. When both exist the docs do not say which wins; CLAUDE.md is assumed.',
|
|
238
|
+
},
|
|
239
|
+
],
|
|
240
|
+
limits: [],
|
|
241
|
+
notes: ['Support for AGENTS.md and CLAUDE.md differs by Copilot surface and VS Code settings.'],
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
id: 'windsurf',
|
|
245
|
+
name: 'Windsurf (Devin Desktop)',
|
|
246
|
+
families: [],
|
|
247
|
+
sources: ['https://docs.devin.ai/desktop/cascade/memories'],
|
|
248
|
+
checkedAt: '2026-10-10',
|
|
249
|
+
rules: [
|
|
250
|
+
{
|
|
251
|
+
kind: 'frontmatter',
|
|
252
|
+
pattern: /^(?:.*\/)?\.(?:devin|windsurf)\/rules\/.+\.md$/,
|
|
253
|
+
rootPrefixes: ['.devin/rules/', '.windsurf/rules/'],
|
|
254
|
+
decide: (fm) => {
|
|
255
|
+
switch (str(fm.trigger).trim()) {
|
|
256
|
+
case 'always_on':
|
|
257
|
+
return { mode: 'always', trigger: 'trigger: always_on' };
|
|
258
|
+
case 'model_decision':
|
|
259
|
+
return { mode: 'on-demand', trigger: 'trigger: model_decision', descriptionOnly: true };
|
|
260
|
+
case 'glob':
|
|
261
|
+
return { mode: 'on-demand', trigger: `trigger: glob ${str(fm.globs)}`.trim() };
|
|
262
|
+
case 'manual':
|
|
263
|
+
return { mode: 'manual', trigger: 'trigger: manual (@rule-name)' };
|
|
264
|
+
default:
|
|
265
|
+
return {
|
|
266
|
+
mode: 'manual',
|
|
267
|
+
trigger: 'no trigger set',
|
|
268
|
+
note: 'Docs do not state the default when trigger is missing; not counted.',
|
|
269
|
+
problem: 'Rule has no trigger frontmatter; set always_on, model_decision, glob or manual.',
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
{ kind: 'root-file', path: '.windsurfrules', note: 'Legacy single-file rules, still read.' },
|
|
275
|
+
{
|
|
276
|
+
kind: 'hierarchical',
|
|
277
|
+
names: ['AGENTS.md'],
|
|
278
|
+
nested: 'on-demand',
|
|
279
|
+
nestedTrigger: 'auto-glob for files in',
|
|
280
|
+
},
|
|
281
|
+
],
|
|
282
|
+
limits: [
|
|
283
|
+
{
|
|
284
|
+
pattern: /(^|\/)(\.(devin|windsurf)\/rules\/.+\.md|\.windsurfrules)$/,
|
|
285
|
+
unit: 'chars',
|
|
286
|
+
max: 12000,
|
|
287
|
+
severity: 'limit',
|
|
288
|
+
text: 'Windsurf limits workspace rule files to 12,000 characters each.',
|
|
289
|
+
source: 'https://docs.devin.ai/desktop/cascade/memories',
|
|
290
|
+
},
|
|
291
|
+
],
|
|
292
|
+
notes: ['Global rules (~/.codeium/windsurf/memories/global_rules.md) are outside the repository and not counted.'],
|
|
293
|
+
},
|
|
294
|
+
];
|
|
295
|
+
export const TOOL_IDS = TOOLS.map((t) => t.id);
|
|
296
|
+
export function getTool(id) {
|
|
297
|
+
const tool = TOOLS.find((t) => t.id === id);
|
|
298
|
+
if (!tool)
|
|
299
|
+
throw new Error(`Unknown tool: ${id}`);
|
|
300
|
+
return tool;
|
|
301
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { existsSync, lstatSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { MAX_DEPTH, addImports, isInstructionFile, isSkippedDir } from '../core/files.js';
|
|
4
|
+
const MAX_BYTES = 2 * 1024 * 1024;
|
|
5
|
+
function readText(abs) {
|
|
6
|
+
try {
|
|
7
|
+
if (statSync(abs).size > MAX_BYTES)
|
|
8
|
+
return null;
|
|
9
|
+
return readFileSync(abs, 'utf8');
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/** Finds agent instruction files under `root`, plus any in-repo files they @import. Read-only. */
|
|
16
|
+
export async function discover(root) {
|
|
17
|
+
const found = new Map();
|
|
18
|
+
const walk = (rel, depth) => {
|
|
19
|
+
if (depth > MAX_DEPTH)
|
|
20
|
+
return;
|
|
21
|
+
let entries;
|
|
22
|
+
try {
|
|
23
|
+
entries = readdirSync(join(root, rel), { withFileTypes: true });
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
for (const e of entries) {
|
|
29
|
+
const path = rel ? `${rel}/${e.name}` : e.name;
|
|
30
|
+
if (e.isDirectory()) {
|
|
31
|
+
if (isSkippedDir(path))
|
|
32
|
+
continue;
|
|
33
|
+
walk(path, depth + 1);
|
|
34
|
+
}
|
|
35
|
+
else if ((e.isFile() || e.isSymbolicLink()) && isInstructionFile(path)) {
|
|
36
|
+
const text = readText(join(root, path));
|
|
37
|
+
if (text !== null)
|
|
38
|
+
found.set(path, { path, text });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
walk('', 0);
|
|
43
|
+
// Pull in @imported files (for example a CLAUDE.md that contains @docs/agents.md).
|
|
44
|
+
await addImports(found, (rel) => {
|
|
45
|
+
const abs = join(root, rel);
|
|
46
|
+
return existsSync(abs) && !lstatSync(abs).isDirectory() ? readText(abs) : null;
|
|
47
|
+
});
|
|
48
|
+
return [...found.values()].sort((a, b) => a.path.localeCompare(b.path));
|
|
49
|
+
}
|
|
50
|
+
export function existsIn(root) {
|
|
51
|
+
return (path) => existsSync(join(root, path));
|
|
52
|
+
}
|
|
53
|
+
export function packageScripts(root) {
|
|
54
|
+
const text = readText(join(root, 'package.json'));
|
|
55
|
+
if (text === null)
|
|
56
|
+
return undefined;
|
|
57
|
+
try {
|
|
58
|
+
return new Set(Object.keys(JSON.parse(text).scripts ?? {}));
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages/count_tokens';
|
|
2
|
+
const ANTHROPIC_MESSAGES_URL = 'https://api.anthropic.com/v1/messages';
|
|
3
|
+
const GEMINI_BASE = 'https://generativelanguage.googleapis.com/v1beta/models';
|
|
4
|
+
const PROBE = [{ role: 'user', content: '.' }];
|
|
5
|
+
async function postJson(fetchFn, url, headers, body) {
|
|
6
|
+
const res = await fetchFn(url, {
|
|
7
|
+
method: 'POST',
|
|
8
|
+
headers: { 'content-type': 'application/json', ...headers },
|
|
9
|
+
body: JSON.stringify(body),
|
|
10
|
+
});
|
|
11
|
+
if (!res.ok) {
|
|
12
|
+
// Only the status goes into the message: response bodies can echo request content.
|
|
13
|
+
throw new Error(`HTTP ${res.status}`);
|
|
14
|
+
}
|
|
15
|
+
return res.json();
|
|
16
|
+
}
|
|
17
|
+
async function mapLimited(items, limit, fn) {
|
|
18
|
+
const results = new Array(items.length);
|
|
19
|
+
let next = 0;
|
|
20
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
21
|
+
while (next < items.length) {
|
|
22
|
+
const i = next++;
|
|
23
|
+
results[i] = await fn(items[i]);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
await Promise.all(workers);
|
|
27
|
+
return results;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Exact Claude counts from Anthropic's count_tokens endpoint.
|
|
31
|
+
* Each text is sent as a system prompt next to a one-character user message; the same request
|
|
32
|
+
* without the system prompt is subtracted, so the result is the cost the text adds to a turn.
|
|
33
|
+
*/
|
|
34
|
+
export function anthropicCounter(apiKey, fetchFn = fetch) {
|
|
35
|
+
const headers = { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' };
|
|
36
|
+
const baselines = new Map();
|
|
37
|
+
return {
|
|
38
|
+
async count(model, texts) {
|
|
39
|
+
if (!model.apiModel)
|
|
40
|
+
return texts.map(() => ({ tokens: null, method: 'unavailable', reason: 'no API model id' }));
|
|
41
|
+
const apiModel = model.apiModel;
|
|
42
|
+
const baseline = () => {
|
|
43
|
+
if (!baselines.has(apiModel))
|
|
44
|
+
baselines.set(apiModel, postJson(fetchFn, ANTHROPIC_URL, headers, { model: apiModel, messages: PROBE }).then((r) => r.input_tokens));
|
|
45
|
+
return baselines.get(apiModel);
|
|
46
|
+
};
|
|
47
|
+
return mapLimited(texts, 4, async (text) => {
|
|
48
|
+
if (!text.trim())
|
|
49
|
+
return { tokens: 0, method: 'api' };
|
|
50
|
+
try {
|
|
51
|
+
const [base, full] = await Promise.all([
|
|
52
|
+
baseline(),
|
|
53
|
+
postJson(fetchFn, ANTHROPIC_URL, headers, { model: apiModel, system: text, messages: PROBE }),
|
|
54
|
+
]);
|
|
55
|
+
return { tokens: Math.max(0, full.input_tokens - base), method: 'api' };
|
|
56
|
+
}
|
|
57
|
+
catch (e) {
|
|
58
|
+
return { tokens: null, method: 'unavailable', reason: `Anthropic count_tokens failed: ${e.message}` };
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/** Gemini counts from Google's models.countTokens endpoint. */
|
|
65
|
+
export function geminiCounter(apiKey, fetchFn = fetch) {
|
|
66
|
+
const headers = { 'x-goog-api-key': apiKey };
|
|
67
|
+
return {
|
|
68
|
+
async count(model, texts) {
|
|
69
|
+
if (!model.apiModel)
|
|
70
|
+
return texts.map(() => ({ tokens: null, method: 'unavailable', reason: 'no API model id' }));
|
|
71
|
+
const url = `${GEMINI_BASE}/${encodeURIComponent(model.apiModel)}:countTokens`;
|
|
72
|
+
return mapLimited(texts, 4, async (text) => {
|
|
73
|
+
if (!text.trim())
|
|
74
|
+
return { tokens: 0, method: 'api' };
|
|
75
|
+
try {
|
|
76
|
+
const r = await postJson(fetchFn, url, headers, { contents: [{ role: 'user', parts: [{ text }] }] });
|
|
77
|
+
return { tokens: r.totalTokens, method: 'api' };
|
|
78
|
+
}
|
|
79
|
+
catch (e) {
|
|
80
|
+
return { tokens: null, method: 'unavailable', reason: `Gemini countTokens failed: ${e.message}` };
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/** Opt-in English translation through Claude, used only to measure a language token multiplier. */
|
|
87
|
+
export async function translateToEnglish(apiKey, text, fetchFn = fetch) {
|
|
88
|
+
const r = await postJson(fetchFn, ANTHROPIC_MESSAGES_URL, { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' }, {
|
|
89
|
+
model: 'claude-haiku-5-5',
|
|
90
|
+
max_tokens: 8000,
|
|
91
|
+
system: 'Translate the user text into natural English. Keep markdown structure, code, paths and commands unchanged. Output only the translation.',
|
|
92
|
+
messages: [{ role: 'user', content: text }],
|
|
93
|
+
});
|
|
94
|
+
if (r.stop_reason === 'max_tokens')
|
|
95
|
+
throw new Error('translation was cut off (file too long)');
|
|
96
|
+
return (r.content ?? [])
|
|
97
|
+
.filter((b) => b.type === 'text')
|
|
98
|
+
.map((b) => b.text)
|
|
99
|
+
.join('');
|
|
100
|
+
}
|