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
@@ -0,0 +1,214 @@
1
+ /**
2
+ * subagent-records — read the transcripts of subagent runs a session spawned.
3
+ *
4
+ * Why this exists: rule-health used to be a PROXY. It measured the error rate
5
+ * of episodes the expensive model handled DIRECTLY that merely *looked*
6
+ * delegable by shape — never the outcome of an actual delegation. So a rule
7
+ * could be quietly failing every time it fired and the signal would not move.
8
+ * Claude Code writes each subagent run to its own transcript, which makes the
9
+ * real outcome measurable:
10
+ *
11
+ * ~/.claude/projects/<munged>/<sessionId>/subagents/agent-<id>.jsonl
12
+ * ~/.claude/projects/<munged>/<sessionId>/subagents/agent-<id>.meta.json
13
+ *
14
+ * The .jsonl is byte-identical in shape to a main transcript (`isSidechain:
15
+ * true`, assistant entries carrying `message.model` + `message.usage`,
16
+ * tool_result blocks carrying `is_error`), so collectSessionRecords parses it
17
+ * unchanged — including the rejection / self-corrected error filters, which
18
+ * must apply here for the same reason they apply to main sessions.
19
+ *
20
+ * The .meta.json carries `{ agentType, description, toolUseId, spawnDepth }`.
21
+ * `toolUseId` is the join key back to the Task/Agent tool_use block in the
22
+ * parent transcript, which is how a run is attributed to the episode (and
23
+ * therefore the category) that caused it.
24
+ *
25
+ * Everything is best-effort: this layout is a Claude Code internal, so a
26
+ * missing directory, an absent meta file, or an unparseable line degrades to
27
+ * less data, never to a throw. route-scan keeps its proxy signal as fallback.
28
+ */
29
+
30
+ import { readdir, readFile, stat } from 'node:fs/promises';
31
+ import { join, dirname, basename } from 'node:path';
32
+ import { collectSessionRecords, normalizeModelId } from './session-records.js';
33
+
34
+ /** Directory holding a session's subagent transcripts (may not exist). */
35
+ export function subagentDirFor(sessionPath) {
36
+ return join(dirname(sessionPath), basename(sessionPath, '.jsonl'), 'subagents');
37
+ }
38
+
39
+ async function readMeta(metaPath) {
40
+ try {
41
+ return JSON.parse(await readFile(metaPath, 'utf8'));
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * A run's model when several appear in one transcript (a harness retry can
49
+ * switch tiers mid-run): the one that produced the most output, since that is
50
+ * what dominates both the bill and the counterfactual.
51
+ */
52
+ function primaryModel(byModelOut) {
53
+ let best = null;
54
+ let bestOut = -1;
55
+ for (const [model, out] of byModelOut) {
56
+ if (out > bestOut) { best = model; bestOut = out; }
57
+ }
58
+ return best;
59
+ }
60
+
61
+ /**
62
+ * Aggregate one subagent transcript into a single run record.
63
+ * Returns null when the file carries no billable API call.
64
+ */
65
+ export async function collectSubagentRun(jsonlPath) {
66
+ let records;
67
+ try {
68
+ records = await collectSessionRecords(jsonlPath, { includeContent: false });
69
+ } catch {
70
+ return null;
71
+ }
72
+ if (records.length === 0) return null;
73
+
74
+ const run = {
75
+ path: jsonlPath,
76
+ agentId: basename(jsonlPath, '.jsonl').replace(/^agent-/, ''),
77
+ agentType: null,
78
+ toolUseId: null,
79
+ spawnDepth: null,
80
+ model: null,
81
+ calls: records.length,
82
+ out: 0,
83
+ input: 0,
84
+ cacheCreation: 0,
85
+ cacheRead: 0,
86
+ ephemeral5m: 0,
87
+ ephemeral1h: 0,
88
+ toolErrors: 0,
89
+ startedAt: null,
90
+ endedAt: null,
91
+ bytes: 0,
92
+ };
93
+ const byModelOut = new Map();
94
+ for (const r of records) {
95
+ run.out += r.completion_tokens || 0;
96
+ run.input += r.input_tokens || 0;
97
+ run.cacheCreation += r.cache_creation_tokens || 0;
98
+ run.cacheRead += r.cache_read_tokens || 0;
99
+ run.ephemeral5m += r.ephemeral5m || 0;
100
+ run.ephemeral1h += r.ephemeral1h || 0;
101
+ run.toolErrors += r.toolErrors || 0;
102
+ const model = normalizeModelId(r.model);
103
+ byModelOut.set(model, (byModelOut.get(model) || 0) + (r.completion_tokens || 0));
104
+ if (r.timestamp) {
105
+ const t = Date.parse(r.timestamp);
106
+ if (Number.isFinite(t)) {
107
+ if (run.startedAt === null || t < run.startedAt) run.startedAt = t;
108
+ if (run.endedAt === null || t > run.endedAt) run.endedAt = t;
109
+ }
110
+ }
111
+ }
112
+ run.model = primaryModel(byModelOut);
113
+
114
+ const meta = await readMeta(jsonlPath.replace(/\.jsonl$/, '.meta.json'));
115
+ if (meta) {
116
+ run.agentType = meta.agentType ?? null;
117
+ run.toolUseId = meta.toolUseId ?? null;
118
+ run.spawnDepth = meta.spawnDepth ?? null;
119
+ }
120
+ try {
121
+ run.bytes = (await stat(jsonlPath)).size;
122
+ } catch { /* size only feeds the rescan gate — 0 is a safe under-estimate */ }
123
+
124
+ return run;
125
+ }
126
+
127
+ /**
128
+ * All subagent runs a session spawned. Empty array when the session never
129
+ * delegated (the common case) or the directory is unreadable.
130
+ */
131
+ export async function collectSubagentRuns(sessionPath) {
132
+ const dir = subagentDirFor(sessionPath);
133
+ let entries;
134
+ try {
135
+ entries = await readdir(dir);
136
+ } catch {
137
+ return [];
138
+ }
139
+ const runs = [];
140
+ for (const e of entries) {
141
+ if (!e.endsWith('.jsonl')) continue;
142
+ const run = await collectSubagentRun(join(dir, e));
143
+ if (run) runs.push(run);
144
+ }
145
+ return runs;
146
+ }
147
+
148
+ /**
149
+ * Index a session's runs for attribution: exact join by tool_use id first,
150
+ * with the un-joinable ones kept aside for the timestamp fallback (a run
151
+ * whose .meta.json is missing or predates toolUseId still happened, and
152
+ * dropping it would silently under-count a rule's real error rate).
153
+ *
154
+ * `unjoined` is kept for callers that want only the no-id runs; the fallback
155
+ * itself works off `all` minus whatever the exact pass claimed, because a run
156
+ * CAN carry an id that no parent episode holds (see fallbackRunsForEpisode).
157
+ */
158
+ export function indexRuns(runs) {
159
+ const byToolUse = new Map();
160
+ const unjoined = [];
161
+ for (const r of runs) {
162
+ if (r.toolUseId) byToolUse.set(r.toolUseId, r);
163
+ else unjoined.push(r);
164
+ }
165
+ return { byToolUse, unjoined, all: runs };
166
+ }
167
+
168
+ /**
169
+ * Runs attributable to one episode. Exact tool_use ids win; anything left
170
+ * unjoined is matched by start time falling inside the episode's span. `used`
171
+ * is a shared Set of run paths across the session so no run is counted twice
172
+ * (an episode's span can overlap a neighbouring episode's runs).
173
+ */
174
+ export function runsForEpisode(index, ep, used) {
175
+ return [...exactRunsForEpisode(index, ep, used), ...fallbackRunsForEpisode(index, ep, used)];
176
+ }
177
+
178
+ /** Runs this episode's own Task calls spawned. No guessing. */
179
+ export function exactRunsForEpisode(index, ep, used) {
180
+ const out = [];
181
+ for (const id of ep.delegationToolUseIds || []) {
182
+ const r = index.byToolUse.get(id);
183
+ if (r && !used.has(r.path)) { used.add(r.path); out.push(r); }
184
+ }
185
+ return out;
186
+ }
187
+
188
+ /**
189
+ * Runs that no episode's tool_use ids claimed, matched by start time falling
190
+ * inside this episode's span.
191
+ *
192
+ * The pool is every unclaimed run, not just the ones without a toolUseId. A
193
+ * nested delegation — a subagent spawning its own subagent — records the
194
+ * SIBLING's tool_use id, which appears in no parent transcript and therefore
195
+ * matches nothing. Gating the fallback on "has no toolUseId" made having one
196
+ * disqualify the run from the only path that could still attribute it, so
197
+ * every spawnDepth >= 2 run was dropped forever (measured: 3 of 3 over 14
198
+ * days, ~$3.42 of savings and three runs of rule-health evidence).
199
+ *
200
+ * Run session-wide AFTER every episode's exact join, so a timestamp guess
201
+ * cannot take a run that another episode can prove is its own.
202
+ */
203
+ export function fallbackRunsForEpisode(index, ep, used) {
204
+ const out = [];
205
+ if (ep.startedAt === null || ep.endedAt === null) return out;
206
+ for (const r of index.all) {
207
+ if (used.has(r.path) || r.startedAt === null) continue;
208
+ if (r.startedAt >= ep.startedAt && r.startedAt <= ep.endedAt) {
209
+ used.add(r.path);
210
+ out.push(r);
211
+ }
212
+ }
213
+ return out;
214
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * update-check — "is there a newer claude-token-saver?", answered without ever
3
+ * blocking a render.
4
+ *
5
+ * The statusline command runs every ~300ms, so a network call on that path is
6
+ * out of the question. We use the same shape sindresorhus/update-notifier
7
+ * settled on: the foreground only ever READS a cached answer, and when that
8
+ * answer is older than the check interval it spawns a detached, unref'd child
9
+ * that refreshes the cache for the *next* render. Nothing awaits the network.
10
+ *
11
+ * State lives next to the other user-data files:
12
+ * { checkedAt: <ms>, latest: "3.25.0", current: "3.24.0",
13
+ * dismissedVersion: "3.25.0"|undefined }
14
+ *
15
+ * Opt out with CTS_NO_UPDATE_CHECK=1 or NO_UPDATE_NOTIFIER (the de-facto
16
+ * standard env var — anyone who set it for other CLIs meant us too).
17
+ */
18
+
19
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
20
+ import { spawn } from 'node:child_process';
21
+ import { join, dirname } from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
23
+ import { userDataDir } from './paths.js';
24
+ import { debug } from './debug.js';
25
+
26
+ const PKG_NAME = 'claude-token-saver';
27
+ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24h — the registry is not a health endpoint
28
+ const FETCH_TIMEOUT_MS = 5000;
29
+
30
+ export function updateStatePath() {
31
+ return join(userDataDir(), 'update-check.json');
32
+ }
33
+
34
+ export function updateCheckDisabled() {
35
+ return process.env.CTS_NO_UPDATE_CHECK === '1' || !!process.env.NO_UPDATE_NOTIFIER;
36
+ }
37
+
38
+ export function readUpdateState() {
39
+ try {
40
+ const s = JSON.parse(readFileSync(updateStatePath(), 'utf8'));
41
+ return s && typeof s === 'object' ? s : {};
42
+ } catch {
43
+ return {};
44
+ }
45
+ }
46
+
47
+ function writeUpdateState(next) {
48
+ const dir = userDataDir();
49
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
50
+ writeFileSync(updateStatePath(), JSON.stringify(next, null, 2) + '\n');
51
+ }
52
+
53
+ /**
54
+ * Compare two semver-ish strings. Returns true when `a` is strictly newer than
55
+ * `b`. Pre-release tags (`3.25.0-beta.1`) are treated as older than the plain
56
+ * release, which is what we want: we never nudge anyone onto a pre-release.
57
+ */
58
+ export function isNewer(a, b) {
59
+ const parse = (v) => {
60
+ const m = String(v || '').trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/);
61
+ if (!m) return null;
62
+ return { nums: [+m[1], +m[2], +m[3]], pre: m[4] || null };
63
+ };
64
+ const pa = parse(a);
65
+ const pb = parse(b);
66
+ if (!pa || !pb) return false;
67
+ for (let i = 0; i < 3; i++) {
68
+ if (pa.nums[i] !== pb.nums[i]) return pa.nums[i] > pb.nums[i];
69
+ }
70
+ if (pa.pre && !pb.pre) return false; // 3.25.0-beta < 3.25.0
71
+ if (!pa.pre && pb.pre) return true;
72
+ return false;
73
+ }
74
+
75
+ /**
76
+ * The read-only accessor every render path uses.
77
+ *
78
+ * @returns {{current: string, latest: string|null, available: boolean, dismissed: boolean, stale: boolean}}
79
+ */
80
+ export function updateStatus(currentVersion) {
81
+ if (updateCheckDisabled()) {
82
+ return { current: currentVersion, latest: null, available: false, dismissed: false, stale: false };
83
+ }
84
+ const s = readUpdateState();
85
+ const latest = typeof s.latest === 'string' ? s.latest : null;
86
+ const available = !!latest && isNewer(latest, currentVersion);
87
+ const age = Date.now() - (Number(s.checkedAt) || 0);
88
+ return {
89
+ current: currentVersion,
90
+ latest,
91
+ available,
92
+ // A version the user already declined stays out of the statusline and out
93
+ // of the session briefing until a newer one ships — otherwise "no thanks"
94
+ // means "ask me again in five minutes", forever.
95
+ dismissed: available && s.dismissedVersion === latest,
96
+ stale: age >= CHECK_INTERVAL_MS,
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Fire the background refresh when the cached answer has aged out. Returns
102
+ * immediately in every case; the child is detached and unref'd so it cannot
103
+ * hold the statusline process open.
104
+ */
105
+ export function maybeSpawnUpdateCheck(currentVersion) {
106
+ if (updateCheckDisabled()) return false;
107
+ const { stale } = updateStatus(currentVersion);
108
+ if (!stale) return false;
109
+ // Stamp the attempt before spawning. Without this, an offline machine
110
+ // re-spawns a doomed child on every single statusline render — several per
111
+ // second — because the cache never gets a fresh timestamp.
112
+ try {
113
+ writeUpdateState({ ...readUpdateState(), checkedAt: Date.now(), current: currentVersion });
114
+ } catch (e) {
115
+ debug('update-check:stamp', e);
116
+ return false;
117
+ }
118
+ try {
119
+ spawn(process.execPath, [cliEntryPath(), 'update-check', '--refresh', '--quiet'], {
120
+ detached: true,
121
+ stdio: 'ignore',
122
+ // Without this Windows flashes a console window, and this one
123
+ // re-spawns from the statusline — several times a minute.
124
+ windowsHide: true,
125
+ }).unref();
126
+ return true;
127
+ } catch (e) {
128
+ debug('update-check:spawn', e);
129
+ return false;
130
+ }
131
+ }
132
+
133
+ /** Path to this package's CLI entry point (bin/cli.js). */
134
+ export function cliEntryPath() {
135
+ return join(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'cli.js');
136
+ }
137
+
138
+ /**
139
+ * Actually hit the registry and persist the answer. Only the detached child
140
+ * and the explicit `update-check --refresh` command call this.
141
+ */
142
+ export async function refreshUpdateState(currentVersion) {
143
+ const controller = new AbortController();
144
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
145
+ try {
146
+ // The `latest` dist-tag endpoint returns a few hundred bytes, unlike the
147
+ // full packument which is megabytes for a package with this many releases.
148
+ const res = await fetch(`https://registry.npmjs.org/${PKG_NAME}/latest`, {
149
+ signal: controller.signal,
150
+ headers: { accept: 'application/json' },
151
+ });
152
+ if (!res.ok) throw new Error(`registry responded ${res.status}`);
153
+ const body = await res.json();
154
+ const latest = typeof body.version === 'string' ? body.version : null;
155
+ if (!latest) throw new Error('registry response carried no version');
156
+ const prev = readUpdateState();
157
+ const next = { ...prev, checkedAt: Date.now(), latest, current: currentVersion };
158
+ // A newly published version clears an older dismissal: the user declined
159
+ // 3.25.0, not "all future upgrades".
160
+ if (prev.dismissedVersion && isNewer(latest, prev.dismissedVersion)) {
161
+ delete next.dismissedVersion;
162
+ }
163
+ writeUpdateState(next);
164
+ return { ok: true, latest };
165
+ } catch (e) {
166
+ debug('update-check:refresh', e);
167
+ // Keep the timestamp fresh even on failure so an offline machine backs off
168
+ // for the full interval instead of retrying on every render.
169
+ try {
170
+ writeUpdateState({ ...readUpdateState(), checkedAt: Date.now(), current: currentVersion });
171
+ } catch (e2) {
172
+ debug('update-check:refresh-stamp', e2);
173
+ }
174
+ return { ok: false, error: String(e && e.message ? e.message : e) };
175
+ } finally {
176
+ clearTimeout(timer);
177
+ }
178
+ }
179
+
180
+ /** Record that the user said "not now" for this exact version. */
181
+ export function dismissUpdate(version) {
182
+ const s = readUpdateState();
183
+ writeUpdateState({ ...s, dismissedVersion: version });
184
+ }
185
+
186
+ /**
187
+ * How this copy was installed, and therefore what command upgrades it.
188
+ * Best-effort: the install root is the only reliable signal we have, and when
189
+ * it tells us nothing we fall back to the npm global install, which is how the
190
+ * overwhelming majority of copies got here.
191
+ */
192
+ export function upgradeCommand() {
193
+ const here = dirname(fileURLToPath(import.meta.url));
194
+ if (here.includes('/pnpm/')) return `pnpm add -g ${PKG_NAME}@latest`;
195
+ if (here.includes('/.bun/')) return `bun add -g ${PKG_NAME}@latest`;
196
+ if (here.includes('/.yarn/')) return `yarn global add ${PKG_NAME}@latest`;
197
+ return `npm install -g ${PKG_NAME}@latest`;
198
+ }
199
+
200
+ export const UPDATE_CHECK_INTERVAL_MS = CHECK_INTERVAL_MS;
201
+ export const PACKAGE_NAME = PKG_NAME;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Friendly labels + icons for `rate_limits.*` keys from Claude Code's stdin
3
+ * payload. Known keys (`five_hour`, `seven_day`) get curated short/long labels
4
+ * and dedicated icons. Unknown keys are passed through with a derived label so
5
+ * any future window Anthropic adds (e.g. `seven_day_sonnet`) renders without
6
+ * a code change.
7
+ */
8
+
9
+ // `short` is used by the cap-warn chip and history records (stable shape so
10
+ // parsers/dedup keep working). `usageLabel` is the friendlier word that the
11
+ // always-on usage segment renders next to the icon — empty string means the
12
+ // icon alone carries the meaning (5H 'session/now' is the implicit default).
13
+ const KNOWN = {
14
+ // ✦ reads as "AI token unit" (Anthropic/OpenAI/Gemini sparkle motif), more
15
+ // on-theme than the 🪙 coin which suggested in-app currency. Same width as
16
+ // 📅 in monospace terminals so the chip alignment stays stable.
17
+ // 'current' mirrors how `weekly` reads next to 7D — names the window in
18
+ // plain English so a glance at `✦ current ████▒░ 72%` tells the eye what's
19
+ // being measured without parsing the icon's meaning.
20
+ five_hour: { short: '5H', long: 'Current session', icon: '✦', usageLabel: 'current' },
21
+ seven_day: { short: '7D', long: 'Current week', icon: '📅', usageLabel: 'weekly' },
22
+ // Speculative — `/usage` shows a Sonnet-only weekly bucket, so if Anthropic
23
+ // ever surfaces it on stdin we render with a sensible default already.
24
+ seven_day_sonnet: { short: '7D-S', long: 'Current week (Sonnet)', icon: '🅂', usageLabel: 'weekly (Sonnet)' },
25
+ seven_day_opus: { short: '7D-O', long: 'Current week (Opus)', icon: '🅾', usageLabel: 'weekly (Opus)' },
26
+ // LiteLLM 게이트웨이 키 예산. rate_limits 가 없는 환경에서 cap 게이지를
27
+ // 대신하는 합성 윈도우라서, 여기 라벨만 있으면 나머지 렌더는 공용 경로를 탄다.
28
+ litellm_budget: { short: 'BUDGET', long: 'LiteLLM key budget', icon: '🔑', usageLabel: 'budget' },
29
+ };
30
+
31
+ function deriveShort(key) {
32
+ // "five_hour" → "5H"; "seven_day_sonnet" → "7DS"; arbitrary key → uppercase initials
33
+ const m = key.match(/^(\d+)_?([a-z]+)/);
34
+ if (m) {
35
+ const num = m[1];
36
+ const word = m[2];
37
+ const letter = word.charAt(0).toUpperCase();
38
+ const tail = key.slice(m[0].length);
39
+ const suffix = tail
40
+ .split('_')
41
+ .filter(Boolean)
42
+ .map((s) => s.charAt(0).toUpperCase())
43
+ .join('');
44
+ return `${num}${letter}${suffix}`;
45
+ }
46
+ return key
47
+ .split('_')
48
+ .filter(Boolean)
49
+ .map((s) => s.charAt(0).toUpperCase())
50
+ .join('') || key;
51
+ }
52
+
53
+ function deriveLong(key) {
54
+ return key
55
+ .split('_')
56
+ .map((w) => (w ? w[0].toUpperCase() + w.slice(1) : w))
57
+ .join(' ');
58
+ }
59
+
60
+ export function labelForKey(key) {
61
+ if (KNOWN[key]) return KNOWN[key];
62
+ const short = deriveShort(key);
63
+ return { short, long: deriveLong(key), icon: '⏱', usageLabel: short };
64
+ }