sprag-cli 3.54.0 → 3.56.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/README.ko.md +17 -5
- package/README.md +23 -6
- package/bin/cli.js +6 -6
- package/docs/CODEX.ko.md +57 -10
- package/docs/CODEX.md +89 -20
- package/package.json +6 -3
- package/src/claude-price.js +28 -0
- package/src/codex-brief.js +20 -0
- package/src/codex-cache-policy.js +26 -41
- package/src/codex-delegation.js +45 -5
- package/src/codex-doctor.js +1 -1
- package/src/codex-harness.js +20 -9
- package/src/codex-hooks.js +114 -19
- package/src/codex-ledger.js +94 -16
- package/src/codex-panel-setup.js +98 -0
- package/src/codex-panel.js +12 -7
- package/src/codex-parser.js +41 -4
- package/src/codex-route-scan.js +14 -7
- package/src/codex-rule-health.js +18 -0
- package/src/commands/codex-delegate.js +12 -3
- package/src/commands/codex.js +41 -11
- package/src/commands/harness.js +13 -2
- package/src/commands/install.js +106 -10
- package/src/commands/route-scan.js +8 -3
- package/src/commands/seed.js +7 -4
- package/src/commands/uninstall.js +23 -0
- package/src/commands/upgrade.js +13 -0
- package/src/cost.js +57 -3
- package/src/formatters/statusline.js +18 -7
- package/src/formatters/table.js +1 -0
- package/src/gateway-prices.js +39 -0
- package/src/harness-templates.js +33 -1
- package/src/harness.js +26 -1
- package/src/litellm-models.js +72 -10
- package/src/model-rules.js +2 -1
- package/src/money.js +9 -0
- package/src/month-spend.js +10 -5
- package/src/openai-prices.js +33 -0
- package/src/preset-ratchet.js +133 -0
- package/src/route-scan.js +16 -8
- package/src/savings-ledger.js +10 -6
- package/src/seed-rules.js +32 -7
package/src/commands/install.js
CHANGED
|
@@ -5,8 +5,12 @@
|
|
|
5
5
|
* a legacy command file is removed automatically. Cross-platform.
|
|
6
6
|
* sprag install # install/update the skill
|
|
7
7
|
* sprag install --force # overwrite existing skill file
|
|
8
|
-
* sprag install --
|
|
9
|
-
*
|
|
8
|
+
* sprag install --manual # ask before each optional step instead
|
|
9
|
+
*
|
|
10
|
+
* The default is automatic: nothing is asked, the bundled presets are
|
|
11
|
+
* registered globally, and Codex is set up too when ~/.codex exists. `--manual`
|
|
12
|
+
* restores the step-by-step questions (they still need a terminal attached).
|
|
13
|
+
* `--yes` and `--no-input` are accepted as the explicit spelling of the default.
|
|
10
14
|
*/
|
|
11
15
|
|
|
12
16
|
import { debug } from '../debug.js';
|
|
@@ -19,12 +23,13 @@ export async function run({ hasFlag }) {
|
|
|
19
23
|
const { userLanguage, languageDecided, setUserLanguage } = await import('../config.js');
|
|
20
24
|
const { canPrompt, confirm } = await import('../prompt.js');
|
|
21
25
|
const force = hasFlag('--force');
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
const
|
|
26
|
+
// Automatic unless asked otherwise. A question per step turned the install
|
|
27
|
+
// into a dozen answers before any work, and every answer has a sensible
|
|
28
|
+
// default: the harness and presets are what make the first session save
|
|
29
|
+
// tokens. `--manual` brings the questions back, and only with a terminal
|
|
30
|
+
// attached, so postinstall, CI and pipes never wait on input.
|
|
31
|
+
const manual = hasFlag('--manual') && !hasFlag('--yes') && !hasFlag('--no-input');
|
|
32
|
+
const interactive = manual && canPrompt();
|
|
28
33
|
|
|
29
34
|
// Output language, decided first because every line below it — and every
|
|
30
35
|
// briefing the hooks inject from here on — is written in it. Until now it
|
|
@@ -342,10 +347,37 @@ export async function run({ hasFlag }) {
|
|
|
342
347
|
// postinstall cannot hold a conversation, and a prompt here would be
|
|
343
348
|
// answered by whoever happens to be at the terminal for a set of rules
|
|
344
349
|
// they have not read. All the install does is say what is waiting.
|
|
350
|
+
// Presets now live in ratchet-preset.md. Moving the ones older versions
|
|
351
|
+
// appended to ratchet.md comes first, so they are not offered again below.
|
|
345
352
|
try {
|
|
346
|
-
const {
|
|
353
|
+
const { preparePresetRatchet } = await import('../preset-ratchet.js');
|
|
354
|
+
const p = preparePresetRatchet();
|
|
355
|
+
if (p.moved) {
|
|
356
|
+
console.log('');
|
|
357
|
+
console.log(lang === 'ko'
|
|
358
|
+
? ` preset: ratchet.md 에 있던 추천 룰 ${p.moved}건을 ${p.file} 로 옮겼습니다 (원본은 ratchet.md.bak-preset-migration).`
|
|
359
|
+
: ` preset: moved ${p.moved} bundled rule(s) from ratchet.md to ${p.file} (original kept as ratchet.md.bak-preset-migration).`);
|
|
360
|
+
}
|
|
361
|
+
} catch (e) {
|
|
362
|
+
debug('install:preset-ratchet', e); // the next session start retries
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
try {
|
|
366
|
+
const { pendingSeeds, acceptSeed } = await import('../seed-rules.js');
|
|
347
367
|
const pending = pendingSeeds();
|
|
348
|
-
if (pending.length > 0) {
|
|
368
|
+
if (pending.length > 0 && !manual) {
|
|
369
|
+
// Automatic install registers every preset globally. Global because a
|
|
370
|
+
// preset is a tool-wide rule, not a project's; `sprag seed` lists them
|
|
371
|
+
// and `sprag harness rm` removes any one afterwards.
|
|
372
|
+
let n = 0;
|
|
373
|
+
for (const p of pending) {
|
|
374
|
+
try { if (await acceptSeed(p.id, { scope: 'global' })) n += 1; } catch (e) { debug('install:seed-accept', e); }
|
|
375
|
+
}
|
|
376
|
+
console.log('');
|
|
377
|
+
console.log(lang === 'ko'
|
|
378
|
+
? ` seed: 추천 룰 ${n}건을 전체 프로젝트에 등록했습니다 (모델 피팅 + 래칫 프리셋). 목록: sprag seed`
|
|
379
|
+
: ` seed: registered ${n} recommended rule(s) for all projects (model-fitting + ratchet presets). List: sprag seed`);
|
|
380
|
+
} else if (pending.length > 0) {
|
|
349
381
|
console.log('');
|
|
350
382
|
console.log(lang === 'ko'
|
|
351
383
|
? ` seed: 추천 룰 ${pending.length}건이 대기 중입니다 (모델 피팅 + 래칫 프리셋).`
|
|
@@ -358,6 +390,70 @@ export async function run({ hasFlag }) {
|
|
|
358
390
|
debug('install:seed-offer', e); // optional feature; never fail install
|
|
359
391
|
}
|
|
360
392
|
|
|
393
|
+
// Codex, when it is installed on this machine. Without this a Codex user
|
|
394
|
+
// had to know to run `sprag install --agent codex` after the package
|
|
395
|
+
// install. Registration is all we can do: Codex still asks the user to
|
|
396
|
+
// trust the hooks in /hooks before they run, and that step stays theirs.
|
|
397
|
+
try {
|
|
398
|
+
const { codexUserDir } = await import('../agent.js');
|
|
399
|
+
const { existsSync } = await import('node:fs');
|
|
400
|
+
if (process.env.CTS_NO_CODEX !== '1' && existsSync(codexUserDir())) {
|
|
401
|
+
let proceed = true;
|
|
402
|
+
if (interactive) {
|
|
403
|
+
console.log('');
|
|
404
|
+
proceed = await confirm(lang === 'ko'
|
|
405
|
+
? ' codex: Codex 가 설치되어 있습니다. Codex 훅과 하네스도 함께 설정할까요?'
|
|
406
|
+
: ' codex: Codex is installed here. Set up its hooks and harness too?', { defaultValue: true });
|
|
407
|
+
}
|
|
408
|
+
if (proceed) {
|
|
409
|
+
const { configureCodexHooks } = await import('../codex-installer.js');
|
|
410
|
+
const { initCodexHarness } = await import('../codex-harness.js');
|
|
411
|
+
const { loadConfig } = await import('../config.js');
|
|
412
|
+
const { setupCodexPanel, describePanelSetup } = await import('../codex-panel-setup.js');
|
|
413
|
+
const config = loadConfig() || {};
|
|
414
|
+
// A manual install asks before touching the system package manager.
|
|
415
|
+
let env = process.env;
|
|
416
|
+
if (interactive && process.platform === 'darwin') {
|
|
417
|
+
const { tmuxAvailable } = await import('../codex-panel-setup.js');
|
|
418
|
+
if (!tmuxAvailable() && !await confirm(lang === 'ko'
|
|
419
|
+
? ' codex: tmux 가 없습니다. Homebrew 로 설치해 같은 창에 패널을 붙일까요?'
|
|
420
|
+
: ' codex: tmux is missing. Install it with Homebrew so the panel shares the Codex window?', { defaultValue: true })) {
|
|
421
|
+
env = { ...process.env, CTS_NO_TMUX: '1' };
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
let panel;
|
|
425
|
+
try { panel = setupCodexPanel({ config, env }); }
|
|
426
|
+
catch (e) {
|
|
427
|
+
debug('install:codex-panel', e);
|
|
428
|
+
const auto = process.platform === 'darwin' && config.codex?.panelAuto !== false;
|
|
429
|
+
panel = { panelAuto: auto, mode: auto ? 'window' : 'off' };
|
|
430
|
+
}
|
|
431
|
+
const hook = configureCodexHooks({ panelAuto: panel.panelAuto });
|
|
432
|
+
console.log('');
|
|
433
|
+
console.log(` codex: hooks ${hook.action} (${hook.file})`);
|
|
434
|
+
for (const line of describePanelSetup(panel, lang)) console.log(` codex: ${line}`);
|
|
435
|
+
if (process.env.CTS_NO_HARNESS !== '1') {
|
|
436
|
+
const h = initCodexHarness({ scope: 'global' });
|
|
437
|
+
console.log(` codex: harness ${h.file}`);
|
|
438
|
+
}
|
|
439
|
+
try { (await import('../preset-ratchet.js')).preparePresetRatchet({ agent: 'codex' }); } catch (e) { debug('install:codex-preset', e); }
|
|
440
|
+
if (!manual) {
|
|
441
|
+
const { pendingSeeds, acceptSeed } = await import('../seed-rules.js');
|
|
442
|
+
let n = 0;
|
|
443
|
+
for (const p of pendingSeeds({ agent: 'codex' })) {
|
|
444
|
+
try { if (await acceptSeed(p.id, { scope: 'global', agent: 'codex' })) n += 1; } catch (e) { debug('install:codex-seed', e); }
|
|
445
|
+
}
|
|
446
|
+
if (n) console.log(lang === 'ko' ? ` codex: 추천 룰 ${n}건 등록` : ` codex: registered ${n} recommended rule(s)`);
|
|
447
|
+
}
|
|
448
|
+
console.log(lang === 'ko'
|
|
449
|
+
? ' codex: Codex 에서 /hooks 를 열어 Sprag 훅을 신뢰해야 실행됩니다 (Codex 보안 정책이라 자동화할 수 없습니다).'
|
|
450
|
+
: " codex: open /hooks in Codex and trust the Sprag hooks; Codex requires that step and it cannot be automated.");
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
} catch (e) {
|
|
454
|
+
debug('install:codex', e); // optional; `sprag install --agent codex` still works
|
|
455
|
+
}
|
|
456
|
+
|
|
361
457
|
console.log('');
|
|
362
458
|
console.log('Open Claude Code in any directory and just mention:');
|
|
363
459
|
console.log(' "cache hit rate" / "1M context" / "5H cap" — the skill auto-activates.');
|
|
@@ -10,6 +10,7 @@ import { createRequire } from 'node:module';
|
|
|
10
10
|
const require = createRequire(import.meta.url);
|
|
11
11
|
import { readStdinJson } from '../stdin-payload.js';
|
|
12
12
|
import { debug } from '../debug.js';
|
|
13
|
+
import { signedUsd } from '../money.js';
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Whether the user turned doc2md on.
|
|
@@ -65,7 +66,7 @@ export async function run({ args, hasFlag, numArg }) {
|
|
|
65
66
|
return;
|
|
66
67
|
}
|
|
67
68
|
const t = delegationSavedTotals();
|
|
68
|
-
const money = (v) => `$${v.toFixed(2)}
|
|
69
|
+
const money = (v) => (v < 0 ? `-$${(-v).toFixed(2)}` : `$${v.toFixed(2)}`); // sign before the symbol
|
|
69
70
|
// Lifetime leads (it is what the breakdown below adds up to); the
|
|
70
71
|
// rolling windows follow as context rather than as competing headlines.
|
|
71
72
|
console.log(lang === 'ko'
|
|
@@ -154,8 +155,8 @@ export async function run({ args, hasFlag, numArg }) {
|
|
|
154
155
|
// two mean opposite things (no data vs. data showing no value).
|
|
155
156
|
const measured = r.delegatedRuns
|
|
156
157
|
? (lang === 'ko'
|
|
157
|
-
? `실제 위임 ${r.delegatedRuns}건 · 에러율 ${Math.round((r.delegatedErrRate || 0) * 100)}% · 절감
|
|
158
|
-
: `measured ×${r.delegatedRuns} · err ${Math.round((r.delegatedErrRate || 0) * 100)}% · saved
|
|
158
|
+
? `실제 위임 ${r.delegatedRuns}건 · 에러율 ${Math.round((r.delegatedErrRate || 0) * 100)}% · 절감 ~${signedUsd(r.savedUsd || 0)}`
|
|
159
|
+
: `measured ×${r.delegatedRuns} · err ${Math.round((r.delegatedErrRate || 0) * 100)}% · saved ~${signedUsd(r.savedUsd || 0)}`)
|
|
159
160
|
: (sessionRank !== null && !rs.worthDelegating(r.tier, sessionRank)
|
|
160
161
|
? (lang === 'ko'
|
|
161
162
|
? `이 규칙은 현재 기본 모델(${sessionModel}) 기준으로는 적용되지 않습니다. 세션 모델이 이미 위임 목표와 같은 급이어서 절감이 발생하지 않습니다.`
|
|
@@ -277,6 +278,10 @@ export async function run({ args, hasFlag, numArg }) {
|
|
|
277
278
|
// when the rendering differs, so the common case is a read and a string
|
|
278
279
|
// compare.
|
|
279
280
|
try { mrHook.syncAllFiles(); } catch (e) { debug('route-scan:sync-ratchet', e); }
|
|
281
|
+
// Same reason for the preset ratchet: an upgrade that rewords or drops a
|
|
282
|
+
// bundled rule reaches ratchet-preset.md here, without a reinstall. It
|
|
283
|
+
// also moves presets older versions appended to ratchet.md.
|
|
284
|
+
try { (await import('../preset-ratchet.js')).preparePresetRatchet(); } catch (e) { debug('route-scan:sync-preset', e); }
|
|
280
285
|
// Registered rules whose delegated-category error rate crossed the
|
|
281
286
|
// health threshold since promotion — the user approved these, so a
|
|
282
287
|
// status change must be briefed, not just written into the md file.
|
package/src/commands/seed.js
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* sprag seed accept <id> --global # register one rule
|
|
6
6
|
* sprag seed accept <id> --project # ... into this project only
|
|
7
7
|
* sprag seed accept all --global # when the user says "all of them"
|
|
8
|
-
* sprag seed skip <id> | skip all # never offer it again
|
|
8
|
+
* sprag seed skip <id> | skip all # never offer it again; for an
|
|
9
|
+
* # accepted preset, drop it from ratchet-preset.md
|
|
9
10
|
* sprag seed reset # make every preset pending again
|
|
10
11
|
*
|
|
11
12
|
* Scope is explicit on purpose, exactly as `harness promote` requires it: the
|
|
@@ -26,7 +27,7 @@ export async function run({ args, hasFlag, agent = 'claude', root: rootArg }) {
|
|
|
26
27
|
const scopeFlag = () => (hasFlag('--global') ? 'global' : hasFlag('--project') ? 'project' : null);
|
|
27
28
|
|
|
28
29
|
if (sub === 'reset') {
|
|
29
|
-
const n = seed.resetSeeds({ agent });
|
|
30
|
+
const n = await seed.resetSeeds({ agent });
|
|
30
31
|
console.log(ko
|
|
31
32
|
? `기록된 응답 ${n}건을 지웠습니다 — 프리셋 전체가 다시 제안 대상이 됩니다.`
|
|
32
33
|
: `Cleared ${n} recorded answer(s) — every preset is pending again.`);
|
|
@@ -46,9 +47,11 @@ export async function run({ args, hasFlag, agent = 'claude', root: rootArg }) {
|
|
|
46
47
|
|
|
47
48
|
if (sub === 'skip') {
|
|
48
49
|
for (const id of targets) {
|
|
49
|
-
const r = seed.skipSeed(id, { lang, root, agent });
|
|
50
|
+
const r = await seed.skipSeed(id, { lang, root, agent });
|
|
50
51
|
console.log(r
|
|
51
|
-
? (
|
|
52
|
+
? (r.removed
|
|
53
|
+
? (ko ? `프리셋 파일에서 뺐습니다: ${id}` : `removed from the preset file: ${id}`)
|
|
54
|
+
: (ko ? `건너뜀: ${id}` : `skipped: ${id}`))
|
|
52
55
|
: (ko ? `대기 중인 프리셋이 아닙니다: ${id}` : `not a pending preset: ${id}`));
|
|
53
56
|
}
|
|
54
57
|
return;
|
|
@@ -35,6 +35,29 @@ export async function run({ hasFlag, args = [] }) {
|
|
|
35
35
|
for (const item of r.kept) {
|
|
36
36
|
console.log(lang === 'ko' ? ` 유지: ${item}` : ` kept: ${item}`);
|
|
37
37
|
}
|
|
38
|
+
// The install now registers Codex too when it finds it, so removal has to
|
|
39
|
+
// undo that as well; otherwise Codex keeps calling a command that may no
|
|
40
|
+
// longer exist. Only our own entries go, exactly as `uninstall --agent codex`.
|
|
41
|
+
try {
|
|
42
|
+
const { codexHookStatus, configureCodexHooks } = await import('../codex-installer.js');
|
|
43
|
+
const { codexHarnessStatus, uninitCodexHarness } = await import('../codex-harness.js');
|
|
44
|
+
const hooked = codexHookStatus().some((h) => h.registered);
|
|
45
|
+
let harness = false;
|
|
46
|
+
try { harness = codexHarnessStatus({ scope: 'global' }).configured > 0; } catch { /* no AGENTS.md */ }
|
|
47
|
+
if (hooked || harness) {
|
|
48
|
+
const hook = configureCodexHooks({ remove: true });
|
|
49
|
+
const h = uninitCodexHarness({ scope: 'global' });
|
|
50
|
+
console.log(lang === 'ko'
|
|
51
|
+
? ` Codex: 훅 ${hook.action} (${hook.file}), 하네스 ${h.removed ? '제거' : '없음'}`
|
|
52
|
+
: ` Codex: hooks ${hook.action} (${hook.file}), harness ${h.removed ? 'removed' : 'absent'}`);
|
|
53
|
+
if (process.platform === 'darwin') {
|
|
54
|
+
try {
|
|
55
|
+
const { installPanelShell } = await import('../codex-panel-shell.js');
|
|
56
|
+
installPanelShell({ remove: true });
|
|
57
|
+
} catch (e) { /* a malformed or foreign block stays for the user to repair */ }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
} catch { /* Codex state is optional; the Claude removal above already succeeded */ }
|
|
38
61
|
if (!purge) {
|
|
39
62
|
console.log(lang === 'ko'
|
|
40
63
|
? ` 기록된 절감액과 설정까지 지우려면 \`${CLI_NAME} uninstall --purge\` 를 실행하십시오.`
|
package/src/commands/upgrade.js
CHANGED
|
@@ -31,6 +31,17 @@ export async function run({ hasFlag, version }) {
|
|
|
31
31
|
const lang = userLanguage();
|
|
32
32
|
const cmd = upgradeCommand();
|
|
33
33
|
|
|
34
|
+
// pnpm and bun skip lifecycle scripts by default, and npm skips them under
|
|
35
|
+
// ignore-scripts, so the new copy's setup (Codex hooks, tmux panel) runs here.
|
|
36
|
+
// The install is idempotent, so npm running it twice changes nothing.
|
|
37
|
+
async function runSetup() {
|
|
38
|
+
console.log('$ sprag install');
|
|
39
|
+
const installCode = await runCommand('sprag install');
|
|
40
|
+
if (installCode !== 0) console.error(lang === 'ko'
|
|
41
|
+
? '설치 후 설정(sprag install)이 실패했습니다. 새 셸에서 sprag install 을 직접 실행하십시오.'
|
|
42
|
+
: 'Post-upgrade setup (sprag install) failed. Run sprag install in a fresh shell.');
|
|
43
|
+
}
|
|
44
|
+
|
|
34
45
|
if (hasFlag('--print')) {
|
|
35
46
|
console.log(cmd);
|
|
36
47
|
return;
|
|
@@ -55,6 +66,7 @@ export async function run({ hasFlag, version }) {
|
|
|
55
66
|
: `Migration command failed with exit code ${migrateCode}. Check the output above.`);
|
|
56
67
|
process.exit(migrateCode);
|
|
57
68
|
}
|
|
69
|
+
await runSetup();
|
|
58
70
|
console.log(lang === 'ko'
|
|
59
71
|
? '이전이 끝났습니다. 새 셸에서 sprag --version 으로 확인하십시오.'
|
|
60
72
|
: 'Migration done. Confirm with sprag --version in a fresh shell.');
|
|
@@ -86,6 +98,7 @@ export async function run({ hasFlag, version }) {
|
|
|
86
98
|
// still reports the old version. Refresh the cache against the new latest so
|
|
87
99
|
// the statusline chip clears on the next render instead of lingering.
|
|
88
100
|
await refreshUpdateState(s.latest);
|
|
101
|
+
await runSetup();
|
|
89
102
|
console.log(lang === 'ko'
|
|
90
103
|
? `설치가 끝났습니다. 새 셸에서 sprag --version 으로 ${s.latest} 인지 확인하십시오.`
|
|
91
104
|
: `Done. In a fresh shell, run sprag --version to confirm ${s.latest}.`);
|
package/src/cost.js
CHANGED
|
@@ -197,7 +197,8 @@ function tokensToMillions(n) {
|
|
|
197
197
|
* ephemeral1h — portion of cacheCreation billed at 1h rate (2x input)
|
|
198
198
|
* output — output tokens
|
|
199
199
|
*/
|
|
200
|
-
export function estimateCost(totals, model) {
|
|
200
|
+
export function estimateCost(totals, model, { price } = {}) {
|
|
201
|
+
if (price) return estimateCostAtPrice(totals, price);
|
|
201
202
|
const tier = detectPricingTier(model);
|
|
202
203
|
const p = PRICING[tier];
|
|
203
204
|
|
|
@@ -248,20 +249,73 @@ export function estimateCost(totals, model) {
|
|
|
248
249
|
};
|
|
249
250
|
}
|
|
250
251
|
|
|
252
|
+
/**
|
|
253
|
+
* Same shape as the list-price estimate, but priced at a gateway's per-token
|
|
254
|
+
* rates (LiteLLM /model/info). A token class the gateway does not price makes
|
|
255
|
+
* the whole estimate unknown (null) rather than free or list-priced.
|
|
256
|
+
*/
|
|
257
|
+
function estimateCostAtPrice(totals, price) {
|
|
258
|
+
const write5m = totals.ephemeral5m ?? 0;
|
|
259
|
+
const write1h = totals.ephemeral1h ?? 0;
|
|
260
|
+
const untracked = Math.max(0, (totals.cacheCreation ?? 0) - write5m - write1h);
|
|
261
|
+
const read = totals.cacheRead ?? 0;
|
|
262
|
+
if (read > 0 && price.cacheRead == null) return null;
|
|
263
|
+
if (write5m + untracked > 0 && price.cacheWrite == null) return null;
|
|
264
|
+
if (write1h > 0 && price.cacheWrite1h == null) return null;
|
|
265
|
+
if (price.input == null || price.output == null) return null;
|
|
266
|
+
const perM = (rate) => (rate ?? 0) * 1e6;
|
|
267
|
+
const input = perM(price.input), output = perM(price.output), cacheRead = perM(price.cacheRead);
|
|
268
|
+
const cacheWrite5m = perM(price.cacheWrite), cacheWrite1h = perM(price.cacheWrite1h);
|
|
269
|
+
|
|
270
|
+
const actual =
|
|
271
|
+
tokensToMillions(totals.input) * input +
|
|
272
|
+
tokensToMillions(write5m + untracked) * cacheWrite5m +
|
|
273
|
+
tokensToMillions(write1h) * cacheWrite1h +
|
|
274
|
+
tokensToMillions(read) * cacheRead +
|
|
275
|
+
tokensToMillions(totals.output) * output;
|
|
276
|
+
const totalInput = totals.input + (totals.cacheCreation ?? 0) + read;
|
|
277
|
+
const noCacheCost = tokensToMillions(totalInput) * input + tokensToMillions(totals.output) * output;
|
|
278
|
+
|
|
279
|
+
// The 5m scenario needs a 5m write rate; without one it is not computable, so
|
|
280
|
+
// it collapses to the actual cost and the "extra cost" row stays hidden.
|
|
281
|
+
let scenario5mCost = actual;
|
|
282
|
+
if (price.cacheWrite != null) {
|
|
283
|
+
const extra5mCreation = write1h * 2;
|
|
284
|
+
scenario5mCost =
|
|
285
|
+
tokensToMillions(totals.input) * input +
|
|
286
|
+
tokensToMillions(write5m + write1h + untracked + extra5mCreation) * cacheWrite5m +
|
|
287
|
+
tokensToMillions(Math.max(0, read - extra5mCreation)) * cacheRead +
|
|
288
|
+
tokensToMillions(totals.output) * output;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
tier: 'gateway',
|
|
293
|
+
actual: round(actual),
|
|
294
|
+
noCacheCost: round(noCacheCost),
|
|
295
|
+
savings: round(noCacheCost - actual),
|
|
296
|
+
savingsRate: noCacheCost > 0 ? (noCacheCost - actual) / noCacheCost : 0,
|
|
297
|
+
scenario5mCost: round(scenario5mCost),
|
|
298
|
+
extraCostIf5m: round(scenario5mCost - actual),
|
|
299
|
+
extraCostIf5mApplicable: price.cacheWrite != null && write1h > 0,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
251
303
|
/**
|
|
252
304
|
* Price a set of sessions each at its own model and sum. One estimateCost over
|
|
253
305
|
* the summed totals priced every token at whichever session came first in the
|
|
254
306
|
* list — sorted by mtime, so a haiku scratch session touched earliest set the
|
|
255
307
|
* rate for a day of Fable work and understated the saving about tenfold.
|
|
256
308
|
* Sessions whose model has no known rate are skipped, as month-spend does.
|
|
309
|
+
* `estimate` lets a caller price by path (see claude-price.js); null skips a session.
|
|
257
310
|
*/
|
|
258
|
-
export function estimateCostAcross(sessions) {
|
|
311
|
+
export function estimateCostAcross(sessions, estimate = estimateCost) {
|
|
259
312
|
const acc = { actual: 0, noCacheCost: 0, scenario5mCost: 0, extraCostIf5mApplicable: false };
|
|
260
313
|
const tiers = new Map();
|
|
261
314
|
for (const s of sessions || []) {
|
|
262
315
|
if (!s?.totals) continue;
|
|
263
316
|
let c;
|
|
264
|
-
try { c =
|
|
317
|
+
try { c = estimate(s.totals, s.model); } catch { continue; }
|
|
318
|
+
if (!c) continue;
|
|
265
319
|
acc.actual += c.actual;
|
|
266
320
|
acc.noCacheCost += c.noCacheCost;
|
|
267
321
|
acc.scenario5mCost += c.scenario5mCost;
|
|
@@ -54,6 +54,12 @@ const GRAY = fg(100, 116, 139, '\x1b[90m');
|
|
|
54
54
|
const BOLD = '\x1b[1m';
|
|
55
55
|
|
|
56
56
|
export function formatMoney(usd) {
|
|
57
|
+
// The sign leads the currency symbol ("-$0.12"); a loss that rounds to zero
|
|
58
|
+
// prints as plain "$0.00" rather than "-$0.00".
|
|
59
|
+
if (usd < 0) {
|
|
60
|
+
const abs = formatMoney(-usd);
|
|
61
|
+
return abs === '$0.00' ? abs : `-${abs}`;
|
|
62
|
+
}
|
|
57
63
|
// Boundaries sit at 999.5/999500 so a value that would round up into the
|
|
58
64
|
// next band's width ($999.9 → "$1000") jumps to that band's unit instead —
|
|
59
65
|
// otherwise "$1000" renders visually larger than "$1.0K".
|
|
@@ -512,8 +518,8 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
512
518
|
// Tied to MIN_VOTES rather than a literal so the two cannot drift: the chip
|
|
513
519
|
// appears exactly when learning has had its chance and still came up short.
|
|
514
520
|
const unresolvedRuns = Number(data.unresolvedRuns) || 0;
|
|
515
|
-
const delegateSeg = delegationSaved
|
|
516
|
-
? `${c(GREEN)}${delegateLabel}${c(RESET)} ${formatMoney(delegationSaved)}`
|
|
521
|
+
const delegateSeg = delegationSaved !== 0
|
|
522
|
+
? `${c(delegationSaved < 0 ? RED : GREEN)}${delegateLabel}${c(RESET)} ${formatMoney(delegationSaved)}`
|
|
517
523
|
: (unresolvedRuns >= MIN_VOTES
|
|
518
524
|
? `${c(YELLOW)}${g.routing} ${unresolvedRuns} unresolved${c(RESET)}`
|
|
519
525
|
: null);
|
|
@@ -552,8 +558,12 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
552
558
|
// verbose: "Sep spend $42.1 (since Sep 1)"
|
|
553
559
|
const month = data.monthSpend;
|
|
554
560
|
let monthSeg = null;
|
|
555
|
-
|
|
556
|
-
|
|
561
|
+
// Gateway sessions whose model the gateway does not price are left out of the
|
|
562
|
+
// sum and counted, so a partial total says so and an all-unpriced month is n/a.
|
|
563
|
+
const monthUnpriced = Number(month?.unpriced) || 0;
|
|
564
|
+
if (month && (Number(month.usd) > 0 || monthUnpriced > 0)) {
|
|
565
|
+
const amt = (Number(month.usd) > 0 ? formatMoney(Number(month.usd)) : 'n/a') +
|
|
566
|
+
(monthUnpriced > 0 ? ` +${monthUnpriced} unpriced` : '');
|
|
557
567
|
if (isIcon) {
|
|
558
568
|
monthSeg = `${c(GRAY)}${g.month} ${month.label} ${amt}${c(RESET)}`;
|
|
559
569
|
} else if (verbose) {
|
|
@@ -576,7 +586,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
576
586
|
// text: "Routing saved $9.8 | opus→haiku 2× $6.4 · fable→sonnet 1× $3.4"
|
|
577
587
|
const totals = data.delegationTotals;
|
|
578
588
|
let totalsLine = null;
|
|
579
|
-
if (!singleLine && totals && Number(totals.total)
|
|
589
|
+
if (!singleLine && totals && Number(totals.total) !== 0 && Number.isFinite(Number(totals.total))) {
|
|
580
590
|
const head = isIcon ? `${g.routing} Routing saved` : 'Routing saved';
|
|
581
591
|
// Model changes behind the total, family-level and version-free: `opus →
|
|
582
592
|
// haiku 2× $0.6`. Versions bump constantly and add nothing here — the
|
|
@@ -595,9 +605,10 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
595
605
|
const pairText = pairs
|
|
596
606
|
.map((p) => `${c(GRAY)}${p.from}→${p.to} ${p.runs}× ${formatMoney(p.usd)}${c(RESET)}`)
|
|
597
607
|
.join(` ${c(GRAY)}·${c(RESET)} `);
|
|
608
|
+
const tone = Number(totals.total) < 0 ? RED : GREEN; // a net loss must not read as a win
|
|
598
609
|
totalsLine =
|
|
599
|
-
`${c(
|
|
600
|
-
`${c(
|
|
610
|
+
`${c(tone)}${c(BOLD)}${head}${c(RESET)} ` +
|
|
611
|
+
`${c(tone)}${formatMoney(Number(totals.total) || 0)}${c(RESET)}` +
|
|
601
612
|
(pairText ? ` ${c(GRAY)}|${c(RESET)} ${pairText}` : '');
|
|
602
613
|
}
|
|
603
614
|
|
package/src/formatters/table.js
CHANGED
|
@@ -253,6 +253,7 @@ export function formatReport({ summary: sum, trend, ttl, anomalies, cost, option
|
|
|
253
253
|
lines.push(' ' + tableRow(['Extra cost if 5m-only', `+$${cost.extraCostIf5m}`], costW, costA));
|
|
254
254
|
}
|
|
255
255
|
lines.push(' ' + tableBot(costW));
|
|
256
|
+
if (cost.unpriced > 0) lines.push(` ${cost.unpriced} session(s) not priced by the gateway are left out.`);
|
|
256
257
|
lines.push('');
|
|
257
258
|
|
|
258
259
|
// Daily trend
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// LiteLLM /model/info prices, shared by the Codex and Claude sides. Per-token USD; both sides
|
|
2
|
+
// price a call by the path it took, so these are only ever the gateway's own numbers.
|
|
3
|
+
export const PRICE_MAX_AGE_MS = 7 * 86400000;
|
|
4
|
+
export const rate = (value) => Number.isFinite(value) && value >= 0 && value < 1 ? value : null;
|
|
5
|
+
|
|
6
|
+
export function deploymentPrice(row) {
|
|
7
|
+
const info = row?.model_info || {};
|
|
8
|
+
const mode = typeof info.mode === 'string' ? info.mode : null;
|
|
9
|
+
if (mode && !['chat', 'responses', 'completion'].includes(mode)) return null;
|
|
10
|
+
const input = rate(info.input_cost_per_token), output = rate(info.output_cost_per_token);
|
|
11
|
+
if (input === null || output === null) return null;
|
|
12
|
+
return { input, output, cacheRead: rate(info.cache_read_input_token_cost), cacheWrite: rate(info.cache_creation_input_token_cost),
|
|
13
|
+
cacheWrite1h: rate(info.cache_creation_input_token_cost_above_1hr) };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Per-token prices by alias. Load-balanced deployments must agree, like cache policy. */
|
|
17
|
+
export function gatewayPrices(payload) {
|
|
18
|
+
if (!Array.isArray(payload?.data)) throw new Error('LiteLLM model/info returned no model list');
|
|
19
|
+
const groups = new Map();
|
|
20
|
+
for (const row of payload.data) {
|
|
21
|
+
if (typeof row?.model_name !== 'string' || row.model_name.length > 256) continue;
|
|
22
|
+
groups.set(row.model_name, [...(groups.get(row.model_name) || []), deploymentPrice(row)]);
|
|
23
|
+
}
|
|
24
|
+
return Object.fromEntries([...groups].map(([name, prices]) => [name,
|
|
25
|
+
prices[0] && prices.every((p) => JSON.stringify(p) === JSON.stringify(prices[0])) ? prices[0] : null]));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* USD for a token mix at one model's prices, or null when a needed rate is
|
|
30
|
+
* missing. Cached input without a cache-read rate is unknown, not free.
|
|
31
|
+
*/
|
|
32
|
+
export function codexRunCost({ input = 0, cached = 0, cacheWrite = 0, output = 0 }, price) {
|
|
33
|
+
if (!price || price.input === null || price.output === null) return null;
|
|
34
|
+
if (cached > 0 && price.cacheRead === null) return null;
|
|
35
|
+
// cache_write_input_tokens is billed at the creation rate when one is published, else as input.
|
|
36
|
+
const uncached = Math.max(0, input - cached - cacheWrite);
|
|
37
|
+
return uncached * price.input + cached * (price.cacheRead ?? 0) + cacheWrite * (price.cacheWrite ?? price.input) + output * price.output;
|
|
38
|
+
}
|
|
39
|
+
|
package/src/harness-templates.js
CHANGED
|
@@ -40,6 +40,38 @@ export function modelRatchetImportLine(scope = 'project') {
|
|
|
40
40
|
return scope === 'global' ? '@~/.claude/ratchet-model.md' : '@.claude/ratchet-model.md';
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* The preset ratchet: the bundled rules the user accepted globally. It is a
|
|
45
|
+
* tool-owned file like ratchet-model.md, regenerated whole from the recorded
|
|
46
|
+
* answers, so an upgrade can reword, add or drop presets without touching the
|
|
47
|
+
* rules the user wrote into ratchet.md. Presets are global-only, so only the
|
|
48
|
+
* global block imports it.
|
|
49
|
+
*/
|
|
50
|
+
export function presetRatchetImportLine() {
|
|
51
|
+
return '@~/.claude/ratchet-preset.md';
|
|
52
|
+
}
|
|
53
|
+
export const PRESET_RATCHET_IMPORT_RE = /^@~\/\.claude\/ratchet-preset\.md\s*$/m;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Render ratchet-preset.md. `rules` is [{ id, text }] in display order. The
|
|
57
|
+
* file is loaded into every session, so the header stays short.
|
|
58
|
+
*/
|
|
59
|
+
export function renderPresetRatchet(rules, { lang = 'en', agent = 'claude' } = {}) {
|
|
60
|
+
const ko = lang === 'ko' && agent !== 'codex';
|
|
61
|
+
const suffix = agent === 'codex' ? ' --agent codex' : '';
|
|
62
|
+
const head = ko
|
|
63
|
+
? `# Preset Ratchet Rules (sprag 관리)
|
|
64
|
+
|
|
65
|
+
sprag 에 동봉된 추천 룰 가운데 전역으로 등록한 것입니다. sprag 가 이 파일을 통째로 다시 쓰므로
|
|
66
|
+
직접 고치지 마십시오. 하나를 빼려면 \`sprag seed skip <id>${suffix}\`, 직접 쓰는 룰은 ratchet.md 에 둡니다.`
|
|
67
|
+
: `# Preset Ratchet Rules (managed by sprag)
|
|
68
|
+
|
|
69
|
+
Bundled rules you registered globally. Sprag rewrites this file whole, so do not
|
|
70
|
+
edit it. Drop one with \`sprag seed skip <id>${suffix}\`; your own rules belong in ratchet.md.`;
|
|
71
|
+
const body = rules.map((r) => `- [${r.id}] ${r.text}`).join('\n');
|
|
72
|
+
return `${head}\n\n## Rules\n\n${body}${body ? '\n' : ''}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
43
75
|
// Matches either scope's import line, so completeness checks don't need to know
|
|
44
76
|
// which scope wrote the block. `(?!-)` keeps the ratchet-model import from
|
|
45
77
|
// counting as the ratchet.md one.
|
|
@@ -118,7 +150,7 @@ ${HARNESS_SECTIONS[4].heading}
|
|
|
118
150
|
📥 ratchet 룰 로드 (이 줄들을 지우면 룰이 적용되지 않습니다):
|
|
119
151
|
|
|
120
152
|
${ratchetImportLine(scope)}
|
|
121
|
-
${modelRatchetImportLine(scope)}
|
|
153
|
+
${modelRatchetImportLine(scope)}${scope === 'global' ? `\n${presetRatchetImportLine()}` : ''}
|
|
122
154
|
${HARNESS_BLOCK_END}
|
|
123
155
|
`;
|
|
124
156
|
}
|
package/src/harness.js
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
appendRatchetRule,
|
|
22
22
|
RATCHET_IMPORT_RE,
|
|
23
23
|
MODEL_RATCHET_IMPORT_RE,
|
|
24
|
+
renderPresetRatchet,
|
|
24
25
|
} from './harness-templates.js';
|
|
25
26
|
import { routeWarningForStatusline } from './route-scan.js';
|
|
26
27
|
import { ruleHealthWarningForStatusline, modelRatchetPathFor, renderModelRatchet } from './model-rules.js';
|
|
@@ -265,6 +266,18 @@ export function harnessInit({ root = findProjectRoot(), force = false, scope = '
|
|
|
265
266
|
result.skipped.push(mrPath + ' (already exists)');
|
|
266
267
|
}
|
|
267
268
|
|
|
269
|
+
// ratchet-preset.md — tool-owned and imported by the global block only, so
|
|
270
|
+
// it is seeded here for the same reason as ratchet-model.md above.
|
|
271
|
+
if (scope === 'global') {
|
|
272
|
+
const prPath = join(homedir(), '.claude', 'ratchet-preset.md');
|
|
273
|
+
if (!existsSync(prPath)) {
|
|
274
|
+
try {
|
|
275
|
+
writeFileSync(prPath, renderPresetRatchet([], { lang: userLanguage() }));
|
|
276
|
+
result.wrote.push(prPath);
|
|
277
|
+
} catch { /* unwritable — the next session start renders it */ }
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
268
281
|
return result;
|
|
269
282
|
}
|
|
270
283
|
|
|
@@ -347,7 +360,19 @@ export function harnessPromote(ruleText, { root = findProjectRoot(), scope = 'pr
|
|
|
347
360
|
* Deduped by rule text (ignoring the YYYY-MM-DD stamp) — idempotent.
|
|
348
361
|
* Returns { path, scope, added, skippedRules, presets }.
|
|
349
362
|
*/
|
|
350
|
-
export function harnessPull({ root = findProjectRoot(), scope = 'global', agent = 'claude' } = {}) {
|
|
363
|
+
export async function harnessPull({ root = findProjectRoot(), scope = 'global', agent = 'claude' } = {}) {
|
|
364
|
+
// Global presets live in ratchet-preset.md now, rendered from recorded
|
|
365
|
+
// answers, so pulling them globally is accepting every one of them.
|
|
366
|
+
if (scope === 'global') {
|
|
367
|
+
const { acceptedGlobalPresets, presetRatchetPath } = await import('./preset-ratchet.js');
|
|
368
|
+
const { pendingSeeds, acceptSeed } = await import('./seed-rules.js');
|
|
369
|
+
const added = [];
|
|
370
|
+
for (const s of pendingSeeds({ root, agent }).filter((x) => x.kind === 'ratchet')) {
|
|
371
|
+
if (await acceptSeed(s.id, { scope: 'global', root, agent })) added.push(s.ruleText);
|
|
372
|
+
}
|
|
373
|
+
const all = acceptedGlobalPresets({ agent }).length;
|
|
374
|
+
return { path: presetRatchetPath({ agent }), scope, added, skippedRules: all - added.length, presets: presetRuleEntries().length };
|
|
375
|
+
}
|
|
351
376
|
const presets = presetRules();
|
|
352
377
|
const rmPath = resolveRatchetPath(scope, root, agent);
|
|
353
378
|
const result = { path: rmPath, scope, added: [], skippedRules: 0, presets: presets.length };
|