sprag-cli 3.40.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.ko.md +637 -0
- package/README.md +758 -0
- package/bin/cli.js +801 -0
- package/examples/statusline-command.ps1 +43 -0
- package/examples/statusline-command.sh +36 -0
- package/package.json +62 -0
- package/presets/cohesion/cohesion-en.md +26 -0
- package/presets/doc2md/convert.py +363 -0
- package/presets/korean-style/LICENSE-fluent-korean +21 -0
- package/presets/korean-style/fluent-korean.md +52 -0
- package/presets/korean-style/supplement.md +93 -0
- package/presets/model-rules.json +115 -0
- package/presets/ratchet-rules.json +38 -0
- package/src/advice.js +564 -0
- package/src/agents.js +52 -0
- package/src/brief.js +264 -0
- package/src/caps-cache.js +84 -0
- package/src/cli-args.js +51 -0
- package/src/cohesion.js +70 -0
- package/src/commands/brief.js +31 -0
- package/src/commands/cohesion.js +59 -0
- package/src/commands/compact-window.js +93 -0
- package/src/commands/doc2md.js +166 -0
- package/src/commands/feedback.js +132 -0
- package/src/commands/handoff.js +33 -0
- package/src/commands/harness.js +459 -0
- package/src/commands/history.js +46 -0
- package/src/commands/install.js +358 -0
- package/src/commands/korean.js +220 -0
- package/src/commands/last.js +151 -0
- package/src/commands/mode.js +46 -0
- package/src/commands/route-scan.js +454 -0
- package/src/commands/seed.js +105 -0
- package/src/commands/uninstall.js +42 -0
- package/src/commands/update-check.js +77 -0
- package/src/commands/upgrade.js +68 -0
- package/src/compact-window.js +205 -0
- package/src/config.js +232 -0
- package/src/cost.js +253 -0
- package/src/debug.js +29 -0
- package/src/demo.js +331 -0
- package/src/doc2md-ledger.cjs +227 -0
- package/src/doc2md.cjs +997 -0
- package/src/fig2md-runner.cjs +21 -0
- package/src/fig2md.cjs +191 -0
- package/src/first-run-note.js +63 -0
- package/src/format-time.js +44 -0
- package/src/formatters/csv.js +8 -0
- package/src/formatters/json.js +3 -0
- package/src/formatters/statusline.js +750 -0
- package/src/formatters/table.js +299 -0
- package/src/handoff.js +161 -0
- package/src/harness-analyzer.cjs +264 -0
- package/src/harness-templates.js +153 -0
- package/src/harness.js +613 -0
- package/src/history.js +383 -0
- package/src/hook-manager.js +96 -0
- package/src/hook.cjs +196 -0
- package/src/installer.js +614 -0
- package/src/korean-lint.cjs +303 -0
- package/src/korean-style.js +187 -0
- package/src/litellm-budget.js +223 -0
- package/src/model-alias.js +484 -0
- package/src/model-rules.js +527 -0
- package/src/month-spend.js +47 -0
- package/src/parser.js +330 -0
- package/src/paths.js +41 -0
- package/src/prompt.js +52 -0
- package/src/route-scan.js +832 -0
- package/src/savings-ledger.js +137 -0
- package/src/seed-rules.js +280 -0
- package/src/session-cache.js +160 -0
- package/src/session-records.js +188 -0
- package/src/stats.js +380 -0
- package/src/stdin-payload.js +122 -0
- package/src/subagent-records.js +214 -0
- package/src/update-check.js +201 -0
- package/src/window-labels.js +64 -0
package/src/config.js
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User-level config persistence — keeps the user's preferred statusline mode
|
|
3
|
+
* across runs without forcing them to edit ~/.claude/settings.json or any
|
|
4
|
+
* wrapper script. CLI flags (e.g. --icon) still override what's stored here.
|
|
5
|
+
*
|
|
6
|
+
* Location is resolved per-platform by paths.userDataDir():
|
|
7
|
+
* Windows: %APPDATA%\claude-token-saver\config.json
|
|
8
|
+
* macOS: ~/Library/Application Support/claude-token-saver/config.json
|
|
9
|
+
* Linux: $XDG_CONFIG_HOME/claude-token-saver/config.json or ~/.config/...
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { userDataDir } from './paths.js';
|
|
15
|
+
|
|
16
|
+
const CONFIG_DIR = userDataDir();
|
|
17
|
+
const CONFIG_PATH = join(CONFIG_DIR, 'config.json');
|
|
18
|
+
|
|
19
|
+
export function configPath() {
|
|
20
|
+
return CONFIG_PATH;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function loadConfig() {
|
|
24
|
+
try {
|
|
25
|
+
if (!existsSync(CONFIG_PATH)) return {};
|
|
26
|
+
return JSON.parse(readFileSync(CONFIG_PATH, 'utf8')) || {};
|
|
27
|
+
} catch {
|
|
28
|
+
return {};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function saveConfig(cfg) {
|
|
33
|
+
if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true });
|
|
34
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + '\n');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Each keyword maps to a single statusline option toggle.
|
|
38
|
+
// Orthogonal — `mode icon verbose` flips both without resetting the rest.
|
|
39
|
+
// Statusline-only toggles. Stored under `cfg.statusline`.
|
|
40
|
+
const KEYWORDS = {
|
|
41
|
+
icon: { key: 'icon', value: true },
|
|
42
|
+
text: { key: 'icon', value: false },
|
|
43
|
+
verbose: { key: 'verbose', value: true },
|
|
44
|
+
compact: { key: 'verbose', value: false },
|
|
45
|
+
timer: { key: 'timer', value: true },
|
|
46
|
+
'no-timer': { key: 'timer', value: false },
|
|
47
|
+
color: { key: 'color', value: true },
|
|
48
|
+
'no-color': { key: 'color', value: false },
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// Output language for `last`/`history`/advice. Stored at top level
|
|
52
|
+
// (`cfg.language`) because it has nothing to do with statusline rendering —
|
|
53
|
+
// the chips themselves stay symbolic regardless.
|
|
54
|
+
const LANG_KEYWORDS = {
|
|
55
|
+
en: 'en',
|
|
56
|
+
english: 'en',
|
|
57
|
+
ko: 'ko',
|
|
58
|
+
korean: 'ko',
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// Window preset accepts forms like:
|
|
62
|
+
// `1h`, `6h`, `24h` — hours
|
|
63
|
+
// `1d`, `7d`, `30d` — days (× 24h)
|
|
64
|
+
// `days=14`, `hours=6` — explicit
|
|
65
|
+
// Returns hours (number) or null if not a window keyword.
|
|
66
|
+
function parseWindow(word) {
|
|
67
|
+
const lower = String(word).toLowerCase();
|
|
68
|
+
const mh = lower.match(/^(\d+)h$/);
|
|
69
|
+
if (mh) return parseInt(mh[1], 10);
|
|
70
|
+
const md = lower.match(/^(\d+)d$/);
|
|
71
|
+
if (md) return parseInt(md[1], 10) * 24;
|
|
72
|
+
const eh = lower.match(/^hours?=(\d+)$/);
|
|
73
|
+
if (eh) return parseInt(eh[1], 10);
|
|
74
|
+
const ed = lower.match(/^days?=(\d+)$/);
|
|
75
|
+
if (ed) return parseInt(ed[1], 10) * 24;
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const VALID_KEYWORDS = Object.keys(KEYWORDS)
|
|
80
|
+
.concat(Object.keys(LANG_KEYWORDS))
|
|
81
|
+
.concat([
|
|
82
|
+
'<N>h (e.g. 1h, 6h, 24h)',
|
|
83
|
+
'<N>d (e.g. 1d, 7d, 30d)',
|
|
84
|
+
'lang=en | lang=ko',
|
|
85
|
+
'ttl=5m | ttl=1h | ttl=auto',
|
|
86
|
+
'reset',
|
|
87
|
+
'default',
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Apply user-supplied mode keywords to the persisted config.
|
|
92
|
+
* Returns { applied, unknown } so the caller can report success/failure.
|
|
93
|
+
*/
|
|
94
|
+
export function applyMode(words) {
|
|
95
|
+
const cfg = loadConfig();
|
|
96
|
+
if (!cfg.statusline) cfg.statusline = {};
|
|
97
|
+
|
|
98
|
+
const applied = [];
|
|
99
|
+
const unknown = [];
|
|
100
|
+
|
|
101
|
+
for (const w of words) {
|
|
102
|
+
const lower = String(w).toLowerCase();
|
|
103
|
+
if (lower === 'reset' || lower === 'default') {
|
|
104
|
+
cfg.statusline = {};
|
|
105
|
+
delete cfg.language;
|
|
106
|
+
applied.push(lower);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const langMatch = lower.match(/^lang(?:uage)?=(en|english|ko|korean)$/);
|
|
110
|
+
if (langMatch) {
|
|
111
|
+
const v = langMatch[1].startsWith('k') ? 'ko' : 'en';
|
|
112
|
+
cfg.language = v;
|
|
113
|
+
// Migrate any legacy value that lived under cfg.statusline.language.
|
|
114
|
+
if (cfg.statusline) delete cfg.statusline.language;
|
|
115
|
+
applied.push(`lang=${v}`);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (LANG_KEYWORDS[lower]) {
|
|
119
|
+
cfg.language = LANG_KEYWORDS[lower];
|
|
120
|
+
if (cfg.statusline) delete cfg.statusline.language;
|
|
121
|
+
applied.push(`lang=${cfg.language}`);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
// Checked before parseWindow, which would otherwise read the `5m`/`1h`
|
|
125
|
+
// part as an analysis window.
|
|
126
|
+
const ttlMatch = lower.match(/^ttl=(5m|1h|auto)$/);
|
|
127
|
+
if (ttlMatch) {
|
|
128
|
+
if (ttlMatch[1] === 'auto') delete cfg.statusline.ttlBucket;
|
|
129
|
+
else cfg.statusline.ttlBucket = ttlMatch[1];
|
|
130
|
+
applied.push(`ttl=${ttlMatch[1]}`);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const hours = parseWindow(lower);
|
|
134
|
+
if (hours !== null && hours > 0) {
|
|
135
|
+
cfg.statusline.windowHours = hours;
|
|
136
|
+
// Drop legacy `days` field if present so a single source of truth wins.
|
|
137
|
+
delete cfg.statusline.days;
|
|
138
|
+
applied.push(formatWindow(hours));
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const kw = KEYWORDS[lower];
|
|
142
|
+
if (!kw) {
|
|
143
|
+
unknown.push(w);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
cfg.statusline[kw.key] = kw.value;
|
|
147
|
+
applied.push(lower);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (applied.length && unknown.length === 0) saveConfig(cfg);
|
|
151
|
+
return { cfg, applied, unknown };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Effective statusline defaults, derived from the persisted config.
|
|
156
|
+
* Defaults for new users: icon=true, verbose=true, timer=true, color=true.
|
|
157
|
+
* Verbose+icon is the most readable preset (full labels + emoji anchors)
|
|
158
|
+
* and avoids the "1h bucket vs clock" ambiguity in compact mode.
|
|
159
|
+
* Users who explicitly opt out via `mode text` / `mode compact` get their
|
|
160
|
+
* choice persisted and respected.
|
|
161
|
+
*/
|
|
162
|
+
export function statuslineDefaults() {
|
|
163
|
+
const s = loadConfig().statusline || {};
|
|
164
|
+
// windowHours is the source of truth. Legacy `days` field still honored
|
|
165
|
+
// for users with old configs.
|
|
166
|
+
let windowHours;
|
|
167
|
+
if (Number.isFinite(s.windowHours) && s.windowHours > 0) {
|
|
168
|
+
windowHours = s.windowHours;
|
|
169
|
+
} else if (Number.isFinite(s.days) && s.days > 0) {
|
|
170
|
+
windowHours = s.days * 24;
|
|
171
|
+
} else {
|
|
172
|
+
windowHours = 24; // default: last 1 day
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
icon: s.icon !== false,
|
|
176
|
+
verbose: s.verbose !== false,
|
|
177
|
+
timer: s.timer !== false,
|
|
178
|
+
color: s.color !== false,
|
|
179
|
+
windowHours,
|
|
180
|
+
windowLabel: formatWindow(windowHours),
|
|
181
|
+
// 'auto' | '5m' | '1h'. Auto lets the measured split decide and falls back
|
|
182
|
+
// to gateway detection. An explicit value exists because detection can be
|
|
183
|
+
// wrong in either direction, and a user who can read their own clock
|
|
184
|
+
// should not have to wait for a release to correct it.
|
|
185
|
+
ttlBucket: s.ttlBucket === '5m' || s.ttlBucket === '1h' ? s.ttlBucket : 'auto',
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Resolve the user's preferred output language for advice/history/last.
|
|
190
|
+
// Returns 'en' or 'ko'; defaults to 'en' for first-time users.
|
|
191
|
+
// Reads `cfg.language` (current location) with a fallback to the legacy
|
|
192
|
+
// `cfg.statusline.language` slot so configs from earlier 2.9.x installs
|
|
193
|
+
// keep working.
|
|
194
|
+
export function userLanguage() {
|
|
195
|
+
const cfg = loadConfig();
|
|
196
|
+
const v = cfg.language || (cfg.statusline && cfg.statusline.language);
|
|
197
|
+
return v === 'ko' ? 'ko' : 'en';
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Whether the language is a recorded choice rather than the 'en' fallback.
|
|
202
|
+
*
|
|
203
|
+
* The install needs the distinction: a user who picked English must not be
|
|
204
|
+
* asked again on the next upgrade, and "nothing recorded" has to stay
|
|
205
|
+
* distinguishable from "chose en", which `userLanguage()` alone cannot say.
|
|
206
|
+
*/
|
|
207
|
+
export function languageDecided() {
|
|
208
|
+
const cfg = loadConfig();
|
|
209
|
+
const v = cfg.language || (cfg.statusline && cfg.statusline.language);
|
|
210
|
+
return v === 'ko' || v === 'en';
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Record the output language ('ko' | 'en'). Anything else is ignored. */
|
|
214
|
+
export function setUserLanguage(v) {
|
|
215
|
+
const lang = v === 'ko' ? 'ko' : v === 'en' ? 'en' : null;
|
|
216
|
+
if (!lang) return null;
|
|
217
|
+
const cfg = loadConfig();
|
|
218
|
+
cfg.language = lang;
|
|
219
|
+
// The legacy slot would win on a later read for configs written by 2.9.x.
|
|
220
|
+
if (cfg.statusline) delete cfg.statusline.language;
|
|
221
|
+
saveConfig(cfg);
|
|
222
|
+
return lang;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Render hours as the most natural unit:
|
|
227
|
+
* 24h → "1d", 168h → "7d", 6h → "6h", 36h → "36h" (not whole days).
|
|
228
|
+
*/
|
|
229
|
+
export function formatWindow(hours) {
|
|
230
|
+
if (hours >= 24 && hours % 24 === 0) return `${hours / 24}d`;
|
|
231
|
+
return `${hours}h`;
|
|
232
|
+
}
|
package/src/cost.js
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cost impact estimation based on Anthropic pricing.
|
|
3
|
+
* Source: https://docs.claude.com/en/docs/about-claude/pricing
|
|
4
|
+
* Prices per million tokens (USD). Updated 2026-04 for Opus 4.7 release.
|
|
5
|
+
*
|
|
6
|
+
* Note: Opus 4.5/4.6/4.7 use reduced pricing ($5/$25) vs. older Opus 4/4.1 ($15/$75).
|
|
7
|
+
* Cache writes are now tracked separately for 5m and 1h TTLs, each with their own rate.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const PRICING = {
|
|
11
|
+
// Fable 5 / Mythos 5 — premium tier above Opus ($10/$50). Cache write
|
|
12
|
+
// rates follow the standard multipliers (1.25x input for 5m, 2x for 1h),
|
|
13
|
+
// cache read is 0.1x input.
|
|
14
|
+
'claude-fable-5': {
|
|
15
|
+
input: 10.0,
|
|
16
|
+
cacheWrite5m: 12.5,
|
|
17
|
+
cacheWrite1h: 20.0,
|
|
18
|
+
cacheRead: 1.0,
|
|
19
|
+
output: 50.0,
|
|
20
|
+
},
|
|
21
|
+
// Opus 4.5+ (new pricing tier — includes 4.5, 4.6, 4.7, 4.8, and future)
|
|
22
|
+
'claude-opus-new': {
|
|
23
|
+
input: 5.0,
|
|
24
|
+
cacheWrite5m: 6.25,
|
|
25
|
+
cacheWrite1h: 10.0,
|
|
26
|
+
cacheRead: 0.5,
|
|
27
|
+
output: 25.0,
|
|
28
|
+
},
|
|
29
|
+
// Opus 4 / 4.1 / Opus 3 (legacy premium pricing)
|
|
30
|
+
'claude-opus-legacy': {
|
|
31
|
+
input: 15.0,
|
|
32
|
+
cacheWrite5m: 18.75,
|
|
33
|
+
cacheWrite1h: 30.0,
|
|
34
|
+
cacheRead: 1.5,
|
|
35
|
+
output: 75.0,
|
|
36
|
+
},
|
|
37
|
+
// Sonnet 4 / 4.5 / 4.6 / 3.7
|
|
38
|
+
'claude-sonnet': {
|
|
39
|
+
input: 3.0,
|
|
40
|
+
cacheWrite5m: 3.75,
|
|
41
|
+
cacheWrite1h: 6.0,
|
|
42
|
+
cacheRead: 0.3,
|
|
43
|
+
output: 15.0,
|
|
44
|
+
},
|
|
45
|
+
// Haiku 4.5
|
|
46
|
+
'claude-haiku-4-5': {
|
|
47
|
+
input: 1.0,
|
|
48
|
+
cacheWrite5m: 1.25,
|
|
49
|
+
cacheWrite1h: 2.0,
|
|
50
|
+
cacheRead: 0.1,
|
|
51
|
+
output: 5.0,
|
|
52
|
+
},
|
|
53
|
+
// Haiku 3.5
|
|
54
|
+
'claude-haiku-3-5': {
|
|
55
|
+
input: 0.8,
|
|
56
|
+
cacheWrite5m: 1.0,
|
|
57
|
+
cacheWrite1h: 1.6,
|
|
58
|
+
cacheRead: 0.08,
|
|
59
|
+
output: 4.0,
|
|
60
|
+
},
|
|
61
|
+
// Haiku 3 (deprecated)
|
|
62
|
+
'claude-haiku-3': {
|
|
63
|
+
input: 0.25,
|
|
64
|
+
cacheWrite5m: 0.3,
|
|
65
|
+
cacheWrite1h: 0.5,
|
|
66
|
+
cacheRead: 0.03,
|
|
67
|
+
output: 1.25,
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Detect pricing tier from Claude model identifier.
|
|
73
|
+
* Examples: 'claude-opus-4-7', 'claude-sonnet-4-5', 'claude-haiku-4-5'.
|
|
74
|
+
*/
|
|
75
|
+
function detectPricingTier(model) {
|
|
76
|
+
if (!model) return 'claude-sonnet';
|
|
77
|
+
const m = model.toLowerCase();
|
|
78
|
+
|
|
79
|
+
// Fable 5 / Mythos 5 — must be checked before the generic fallback:
|
|
80
|
+
// without this, 'claude-fable-5' fell through to the Sonnet tier and
|
|
81
|
+
// under-estimated costs ~3x ($3/$15 vs the real $10/$50).
|
|
82
|
+
if (m.includes('fable') || m.includes('mythos')) return 'claude-fable-5';
|
|
83
|
+
|
|
84
|
+
if (m.includes('opus')) {
|
|
85
|
+
// Opus 4.5, 4.6, 4.7, and future 5+ use the new reduced pricing.
|
|
86
|
+
if (/opus[-_.]?4[-_.]?[5-9]\b/.test(m)) return 'claude-opus-new';
|
|
87
|
+
if (/opus[-_.]?[5-9]/.test(m)) return 'claude-opus-new';
|
|
88
|
+
// Opus 4, 4.1, 3 → legacy premium pricing.
|
|
89
|
+
return 'claude-opus-legacy';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (m.includes('haiku')) {
|
|
93
|
+
if (/haiku[-_.]?4[-_.]?5/.test(m)) return 'claude-haiku-4-5';
|
|
94
|
+
if (/haiku[-_.]?3[-_.]?5/.test(m)) return 'claude-haiku-3-5';
|
|
95
|
+
if (/haiku[-_.]?3\b/.test(m)) return 'claude-haiku-3';
|
|
96
|
+
return 'claude-haiku-4-5';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Sonnet (default fallback): 3.7, 4, 4.5, 4.6 all share the same pricing.
|
|
100
|
+
return 'claude-sonnet';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Relative price rank of a model's pricing tier. Delegation only pays off
|
|
105
|
+
* when the target tier is genuinely cheaper than the model that did the work,
|
|
106
|
+
* so route-scan needs an ORDER, not just "is it haiku?" — a Sonnet session
|
|
107
|
+
* must not produce "delegate to Sonnet" rules (the subagent would rebuild
|
|
108
|
+
* context for zero price difference).
|
|
109
|
+
*/
|
|
110
|
+
const TIER_RANK = {
|
|
111
|
+
'claude-fable-5': 3,
|
|
112
|
+
'claude-opus-legacy': 2,
|
|
113
|
+
'claude-opus-new': 2,
|
|
114
|
+
'claude-sonnet': 1,
|
|
115
|
+
'claude-haiku-4-5': 0,
|
|
116
|
+
'claude-haiku-3-5': 0,
|
|
117
|
+
'claude-haiku-3': 0,
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* True for the explicit 'unknown' marker — an id that could not be resolved
|
|
122
|
+
* at all (see model-alias.js), as opposed to an id this table simply has no
|
|
123
|
+
* entry for. The two must not share a fate: an unresolved gateway id counted
|
|
124
|
+
* as Sonnet silently corrupts every delegation statistic, so it is dropped
|
|
125
|
+
* from the ranking instead of guessed at.
|
|
126
|
+
*/
|
|
127
|
+
export function isUnknownModel(model) {
|
|
128
|
+
if (!model) return true;
|
|
129
|
+
const m = String(model).toLowerCase();
|
|
130
|
+
// "<synthetic>" is Claude Code's placeholder for locally-generated error
|
|
131
|
+
// stubs — no real API call happened, so pricing it as Sonnet would be a
|
|
132
|
+
// silent guess (session-records.js skips it for the same reason).
|
|
133
|
+
return m === 'unknown' || m === '<synthetic>';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* True when the id actually names a Claude family this table can price, as
|
|
138
|
+
* opposed to falling through to the Sonnet default.
|
|
139
|
+
*
|
|
140
|
+
* `detectPricingTier` must keep defaulting — a plain cost estimate is better
|
|
141
|
+
* off guessing Sonnet than refusing to answer. But anything that compares two
|
|
142
|
+
* models must not: behind a company gateway an id can be a house alias
|
|
143
|
+
* (`prod-large`, `team-fast`) carrying no family name, and pricing that as
|
|
144
|
+
* Sonnet silently invents or erases a delegation saving. Callers that need a
|
|
145
|
+
* real comparison gate on this and skip when it is false.
|
|
146
|
+
*
|
|
147
|
+
* Covers the shapes gateways actually emit — Bedrock
|
|
148
|
+
* (`anthropic.claude-opus-4-5-v1:0`, `us.anthropic.…`), Vertex
|
|
149
|
+
* (`claude-opus-4-5@20251101`), and the `[1m]` context suffix — because all of
|
|
150
|
+
* them keep the family name in the string. House aliases that do not are
|
|
151
|
+
* exactly what this returns false for; map those in profile-map.json's
|
|
152
|
+
* `modelAliases`.
|
|
153
|
+
*/
|
|
154
|
+
export function isRecognizedModelId(model) {
|
|
155
|
+
if (isUnknownModel(model)) return false;
|
|
156
|
+
return /fable|mythos|opus|sonnet|haiku/i.test(String(model));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function modelRank(model) {
|
|
160
|
+
// -1 sits below every real tier, so worthDelegating() rejects it and
|
|
161
|
+
// tierForRank() attributes no saving to it: the run leaves the aggregate
|
|
162
|
+
// rather than distorting it.
|
|
163
|
+
if (isUnknownModel(model)) return -1;
|
|
164
|
+
const rank = TIER_RANK[detectPricingTier(model)];
|
|
165
|
+
// Unknown ids fall through detectPricingTier to the Sonnet tier; ranking
|
|
166
|
+
// them 1 keeps the conservative reading (cheap enough that a Sonnet-target
|
|
167
|
+
// rule is not worth it, expensive enough that a haiku one still is).
|
|
168
|
+
return rank ?? 1;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Price rank each delegation tier targets: T2 → haiku, T1 → sonnet. */
|
|
172
|
+
export const TIER_TARGET_RANK = { T2: 0, T1: 1 };
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Which delegation tier a subagent run at this price rank represents.
|
|
176
|
+
* null = the run was NOT a downgrade (same tier or higher), so it carries no
|
|
177
|
+
* delegation saving to attribute to a rule.
|
|
178
|
+
*/
|
|
179
|
+
export function tierForRank(rank) {
|
|
180
|
+
if (rank === 0) return 'T2';
|
|
181
|
+
if (rank === 1) return 'T1';
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function tokensToMillions(n) {
|
|
186
|
+
return n / 1_000_000;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Estimate costs for given token totals.
|
|
191
|
+
*
|
|
192
|
+
* totals shape (from parser.js):
|
|
193
|
+
* input — non-cached input tokens
|
|
194
|
+
* cacheCreation — total cache-write tokens (5m + 1h combined, as reported by API)
|
|
195
|
+
* cacheRead — cache-hit tokens
|
|
196
|
+
* ephemeral5m — portion of cacheCreation billed at 5m rate (1.25x input)
|
|
197
|
+
* ephemeral1h — portion of cacheCreation billed at 1h rate (2x input)
|
|
198
|
+
* output — output tokens
|
|
199
|
+
*/
|
|
200
|
+
export function estimateCost(totals, model) {
|
|
201
|
+
const tier = detectPricingTier(model);
|
|
202
|
+
const p = PRICING[tier];
|
|
203
|
+
|
|
204
|
+
// Prefer explicit 5m/1h split when available; fall back to cacheCreation at 5m rate
|
|
205
|
+
// (conservative — 5m is cheaper than 1h).
|
|
206
|
+
const write5m = totals.ephemeral5m ?? 0;
|
|
207
|
+
const write1h = totals.ephemeral1h ?? 0;
|
|
208
|
+
const trackedWrites = write5m + write1h;
|
|
209
|
+
const untracked = Math.max(0, (totals.cacheCreation ?? 0) - trackedWrites);
|
|
210
|
+
|
|
211
|
+
const actual =
|
|
212
|
+
tokensToMillions(totals.input) * p.input +
|
|
213
|
+
tokensToMillions(write5m + untracked) * p.cacheWrite5m +
|
|
214
|
+
tokensToMillions(write1h) * p.cacheWrite1h +
|
|
215
|
+
tokensToMillions(totals.cacheRead) * p.cacheRead +
|
|
216
|
+
tokensToMillions(totals.output) * p.output;
|
|
217
|
+
|
|
218
|
+
// What it would cost without any caching (all input billed at base rate).
|
|
219
|
+
const totalInput = totals.input + totals.cacheCreation + totals.cacheRead;
|
|
220
|
+
const noCacheCost =
|
|
221
|
+
tokensToMillions(totalInput) * p.input +
|
|
222
|
+
tokensToMillions(totals.output) * p.output;
|
|
223
|
+
|
|
224
|
+
// What it would cost if all 1h-tier writes had been 5m instead
|
|
225
|
+
// (higher miss rate — estimate 3x more cache re-creation for sessions > 5min).
|
|
226
|
+
const extra5mCreation = write1h * 2; // sessions that would re-create under 5m TTL
|
|
227
|
+
const scenario5mWrites = write5m + write1h + untracked + extra5mCreation;
|
|
228
|
+
const scenario5mCost =
|
|
229
|
+
tokensToMillions(totals.input) * p.input +
|
|
230
|
+
tokensToMillions(scenario5mWrites) * p.cacheWrite5m +
|
|
231
|
+
tokensToMillions(Math.max(0, totals.cacheRead - extra5mCreation)) * p.cacheRead +
|
|
232
|
+
tokensToMillions(totals.output) * p.output;
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
tier,
|
|
236
|
+
actual: round(actual),
|
|
237
|
+
noCacheCost: round(noCacheCost),
|
|
238
|
+
savings: round(noCacheCost - actual),
|
|
239
|
+
savingsRate: noCacheCost > 0 ? (noCacheCost - actual) / noCacheCost : 0,
|
|
240
|
+
scenario5mCost: round(scenario5mCost),
|
|
241
|
+
extraCostIf5m: round(scenario5mCost - actual),
|
|
242
|
+
// The row asks a counterfactual: what would dropping to 5m-only cost you?
|
|
243
|
+
// With no 1h writes there is nothing to lose, and the arithmetically
|
|
244
|
+
// honest `+$0` it printed read as "5m-only is free" — the opposite of the
|
|
245
|
+
// truth for a gateway user already confined to 5m. The display layer has
|
|
246
|
+
// to change the question rather than the number.
|
|
247
|
+
extraCostIf5mApplicable: write1h > 0,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function round(n) {
|
|
252
|
+
return Math.round(n * 100) / 100;
|
|
253
|
+
}
|
package/src/debug.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in diagnostics for the tool's deliberately silent paths.
|
|
3
|
+
*
|
|
4
|
+
* Most catches here are best-effort by design: a failed history append or
|
|
5
|
+
* cache write must never break a statusline that renders every few seconds,
|
|
6
|
+
* and a hook that throws would disrupt the user's session. The cost is that
|
|
7
|
+
* a genuinely broken path (bad permissions on the state dir, a corrupt hook
|
|
8
|
+
* payload) is indistinguishable from "nothing to do".
|
|
9
|
+
*
|
|
10
|
+
* `CTS_DEBUG=1` makes those swallowed failures visible on stderr — which the
|
|
11
|
+
* statusline contract discards and hooks surface in Claude Code's debug
|
|
12
|
+
* output — without changing behavior in any way.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const ENABLED = !!process.env.CTS_DEBUG;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {string} scope short label for where the failure happened
|
|
19
|
+
* @param {unknown} err the swallowed error
|
|
20
|
+
*/
|
|
21
|
+
export function debug(scope, err) {
|
|
22
|
+
if (!ENABLED) return;
|
|
23
|
+
const msg = err && err.stack ? err.stack : String(err);
|
|
24
|
+
process.stderr.write(`[cts:${scope}] ${msg}\n`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function debugEnabled() {
|
|
28
|
+
return ENABLED;
|
|
29
|
+
}
|