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.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.ko.md +637 -0
  3. package/README.md +758 -0
  4. package/bin/cli.js +801 -0
  5. package/examples/statusline-command.ps1 +43 -0
  6. package/examples/statusline-command.sh +36 -0
  7. package/package.json +62 -0
  8. package/presets/cohesion/cohesion-en.md +26 -0
  9. package/presets/doc2md/convert.py +363 -0
  10. package/presets/korean-style/LICENSE-fluent-korean +21 -0
  11. package/presets/korean-style/fluent-korean.md +52 -0
  12. package/presets/korean-style/supplement.md +93 -0
  13. package/presets/model-rules.json +115 -0
  14. package/presets/ratchet-rules.json +38 -0
  15. package/src/advice.js +564 -0
  16. package/src/agents.js +52 -0
  17. package/src/brief.js +264 -0
  18. package/src/caps-cache.js +84 -0
  19. package/src/cli-args.js +51 -0
  20. package/src/cohesion.js +70 -0
  21. package/src/commands/brief.js +31 -0
  22. package/src/commands/cohesion.js +59 -0
  23. package/src/commands/compact-window.js +93 -0
  24. package/src/commands/doc2md.js +166 -0
  25. package/src/commands/feedback.js +132 -0
  26. package/src/commands/handoff.js +33 -0
  27. package/src/commands/harness.js +459 -0
  28. package/src/commands/history.js +46 -0
  29. package/src/commands/install.js +358 -0
  30. package/src/commands/korean.js +220 -0
  31. package/src/commands/last.js +151 -0
  32. package/src/commands/mode.js +46 -0
  33. package/src/commands/route-scan.js +454 -0
  34. package/src/commands/seed.js +105 -0
  35. package/src/commands/uninstall.js +42 -0
  36. package/src/commands/update-check.js +77 -0
  37. package/src/commands/upgrade.js +68 -0
  38. package/src/compact-window.js +205 -0
  39. package/src/config.js +232 -0
  40. package/src/cost.js +253 -0
  41. package/src/debug.js +29 -0
  42. package/src/demo.js +331 -0
  43. package/src/doc2md-ledger.cjs +227 -0
  44. package/src/doc2md.cjs +997 -0
  45. package/src/fig2md-runner.cjs +21 -0
  46. package/src/fig2md.cjs +191 -0
  47. package/src/first-run-note.js +63 -0
  48. package/src/format-time.js +44 -0
  49. package/src/formatters/csv.js +8 -0
  50. package/src/formatters/json.js +3 -0
  51. package/src/formatters/statusline.js +750 -0
  52. package/src/formatters/table.js +299 -0
  53. package/src/handoff.js +161 -0
  54. package/src/harness-analyzer.cjs +264 -0
  55. package/src/harness-templates.js +153 -0
  56. package/src/harness.js +613 -0
  57. package/src/history.js +383 -0
  58. package/src/hook-manager.js +96 -0
  59. package/src/hook.cjs +196 -0
  60. package/src/installer.js +614 -0
  61. package/src/korean-lint.cjs +303 -0
  62. package/src/korean-style.js +187 -0
  63. package/src/litellm-budget.js +223 -0
  64. package/src/model-alias.js +484 -0
  65. package/src/model-rules.js +527 -0
  66. package/src/month-spend.js +47 -0
  67. package/src/parser.js +330 -0
  68. package/src/paths.js +41 -0
  69. package/src/prompt.js +52 -0
  70. package/src/route-scan.js +832 -0
  71. package/src/savings-ledger.js +137 -0
  72. package/src/seed-rules.js +280 -0
  73. package/src/session-cache.js +160 -0
  74. package/src/session-records.js +188 -0
  75. package/src/stats.js +380 -0
  76. package/src/stdin-payload.js +122 -0
  77. package/src/subagent-records.js +214 -0
  78. package/src/update-check.js +201 -0
  79. 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
+ }