sprag-cli 3.58.0 → 3.59.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 CHANGED
@@ -46,7 +46,7 @@ npm i -g sprag-cli # 예전 이름 claude-token-saver 와 같은 패키지입
46
46
  사용량 한도에 문제가 생기면 경고 칩이 줄 맨 앞에 나타납니다.
47
47
 
48
48
  ```
49
- 🔀 Routing saved $44.67 | opus→sonnet 59× $39.75 · opus→haiku 10× $4.92
49
+ 🔀 Routing saved $188 | opus→sonnet 67× $93.4 · fable→sonnet 26× $83.7 · opus→haiku 25× $7.41 · fable→haiku 6× $3.73
50
50
  🧠 Cache hit 98.8% · ⏳ Cache expires 59:46 · 🅷 5/5 · 📦 Ctx 47% of 1M
51
51
  ```
52
52
 
@@ -74,7 +74,7 @@ Sprag 는 사용자가 지정한 모델을 바꾸지 않습니다. 이미 실행
74
74
  | 공개 벤치마크 정확도 | **59.1%** · 단일 최고 모델 57.9% | `▰▰▰▰▰▰▰▰▰▰▰▰` | [벤치마크](https://github.com/rootstudioyaml/sprag/blob/main/docs/BENCHMARK.md) |
75
75
  | 같은 문항 총비용 | **$268** · gpt-5 $388 · gemini-2.5-pro $734 | `▰▰▰▰▰▱▱▱▱▱▱▱` | [벤치마크](https://github.com/rootstudioyaml/sprag/blob/main/docs/BENCHMARK.md) |
76
76
  | 같은 문서들의 토큰 | **−95.9%** · 2,011,178 → 82,209 (12건) | `▰▱▱▱▱▱▱▱▱▱▱▱` | [doc2md](https://github.com/rootstudioyaml/sprag/blob/main/docs/DOC2MD.md) |
77
- | 위임 절감액 (원장) | **$44.67** · 위임 69건 | `▰▰▰▰▰▰▰▰▰▰▰▱` | `sprag route-scan savings` |
77
+ | 위임 절감액 (원장) | **$188.27** · 위임 124건 | `▰▰▰▰▰▰▰▰▰▰▰▱` | `sprag route-scan savings` |
78
78
  | 같은 프롬프트를 저가 등급에 | **비용 24%** · 여덟 건 중 한 건이 더 틀림 | `▰▰▰▱▱▱▱▱▱▱▱▱` | [A/B 실측](#같은-프롬프트를-저가-등급에-돌리면) |
79
79
 
80
80
  앞의 두 줄은 다음 반박에 답하려고 있습니다. 저가 모델에 일을 내려보내면 정확도가 떨어질 것이라는 반박입니다. 공개 데이터에서는 떨어지지 않았습니다. 같은 판정 기준을 라우터로 만들어 11,696건에 적용했더니 정확도가 단일 최고 모델보다 1.2%p 높고 비용은 3분의 1이었습니다. 정확도가 오른 까닭은 간단합니다. 모든 유형에서 1등인 모델은 없기 때문입니다.
@@ -83,15 +83,20 @@ LLMRouterBench(Findings of ACL 2026)에서 오프라인으로 측정했습니다
83
83
 
84
84
  다만 이렇게 확인한 것은 판정 기준이고, sprag 가 실제로 쓰는 모델 조합은 아닙니다. 데이터셋에 Claude 계열이 없어서 deepseek-v3 와 qwen3-235b, gpt-5 가 각각 저가·중간·상위 등급을 대신했습니다.
85
85
 
86
- 뒤의 세 줄은 이 머신에서 실제로 쌓인 기록이라 시간이 지나면 값이 커집니다. 아래 수치는 2026-09-18 에 뽑았습니다.
86
+ 뒤의 세 줄은 이 머신에서 실제로 쌓인 기록이라 시간이 지나면 값이 커집니다. 위임 수치는 2026-10-09 에, 문서 변환 수치는 2026-09-18 에 뽑았습니다. 모델 이름은 각 실행이 실제로 쓴 모델입니다.
87
87
 
88
88
  ### 지금 이 머신의 최근 30일
89
89
 
90
90
  ```
91
- 🔀 라우팅 절감 누적 $44.67 (최근 7일 $23.21 · 30일 $42.73)
92
-
93
- opus-5 → sonnet-5 59회 $39.75 ▰▰▰▰▰▰▰▰▰▰▱▱
94
- opus-5 → haiku-4.5 10회 $4.92 ▰▱▱▱▱▱▱▱▱▱▱▱
91
+ 🔀 라우팅 절감 누적 $188.27 (최근 7일 $13.31 · 30일 $150.64)
92
+
93
+ fable-5 → sonnet-5 24회 $81.19 ▰▰▰▰▰▰▰▰▰▰▰▰
94
+ opus-5 → sonnet-5 54회 $68.85 ▰▰▰▰▰▰▰▰▰▰▱▱
95
+ opus-5 → sonnet-5.5 13회 $24.58 ▰▰▰▰▱▱▱▱▱▱▱▱
96
+ opus-5 → haiku-4.5 25회 $7.41 ▰▱▱▱▱▱▱▱▱▱▱▱
97
+ fable-5 → haiku-4.5 5회 $3.22 ▰▱▱▱▱▱▱▱▱▱▱▱
98
+ fable-5 → sonnet-5.5 2회 $2.51 ▰▱▱▱▱▱▱▱▱▱▱▱
99
+ fable-5 → haiku-5.5 1회 $0.51 ▰▱▱▱▱▱▱▱▱▱▱▱
95
100
  ```
96
101
 
97
102
  | 함께 측정한 값 | 30일 기준 | |
@@ -122,8 +127,8 @@ LLMRouterBench(Findings of ACL 2026)에서 오프라인으로 측정했습니다
122
127
  ## statusline 한 줄로 보는 상태
123
128
 
124
129
  ```
125
- 🔀 라우팅 절감 $44.67 | opus→sonnet 59회 $39.75 · opus→haiku 10회 $4.92
126
- 🚨 5H ▰▰▰▰▰▰▰▰▰▰▰▱ 94% 🔄 12:36 · 🅷 5/5 · 🤖 Opus 5 · 🧠 Cache hit 98.8% · ⏳ Cache expires 59:46 · 📅 weekly ▰▰▰▰▰▱▱▱▱▱▱▱ 38% · 💵 Sep $42 · 📦 Ctx 47% of 1M
130
+ 🔀 라우팅 절감 $188 | opus→sonnet 67회 $93.4 · fable→sonnet 26회 $83.7 · opus→haiku 25회 $7.41 · fable→haiku 6회 $3.73
131
+ 🚨 5H ▰▰▰▰▰▰▰▰▰▰▰▱ 94% 🔄 12:36 · 🅷 5/5 · 🤖 Opus 5.5 · 🧠 Cache hit 98.8% · ⏳ Cache expires 59:46 · 📅 weekly ▰▰▰▰▰▱▱▱▱▱▱▱ 38% · 💵 Sep $42 · 📦 Ctx 47% of 1M
127
132
  ```
128
133
 
129
134
  문제가 생기면 경고 칩이 줄 맨 앞으로 옵니다. `🚨 5H/7D NN%`, `⚠ Ctx 500k+`, `⚠ Cache miss`, `⚠ Input spike`, `⚠ Output heavy`, `⚠ Call surge`, `⚠ Rebuild churn`, `⚠ 5m TTL` 여덟 가지입니다. 칩 문구를 Claude 에게 그대로 붙여 넣으면 Skill 이 원인 코드와 조치 순서를 꺼내 줍니다. 세그먼트마다 무엇을 뜻하는지는 [statusline 문서](https://github.com/rootstudioyaml/sprag/blob/main/docs/STATUSLINE.ko.md)에 정리했습니다.
@@ -151,7 +156,7 @@ JetBrains IDE 내장 터미널에서는 이모지 대신 한 칸 글리프로
151
156
  | 측정 항목 | 값 | 표본 | 기간 |
152
157
  |---|---|---|---|
153
158
  | 문서 변환 | 2,011,178 → 82,209 토큰 | 12건 (xlsx·pptx·pdf) | 2026-09-18 까지 누적 |
154
- | 위임 실행 | $44.67 절감 · 건당 $0.65 | 69건 | 2026-09-18 까지 누적 |
159
+ | 위임 실행 | $188.27 절감 · 건당 $1.52 | 124건 | 2026-10-09 까지 누적 |
155
160
  | 래칫 규칙 누적 | 37건 (글로벌 30 · 프로젝트 7) | 저장소 4곳 | 2026-05-08 ~ 09-16 |
156
161
 
157
162
  래칫 규칙과 위임 규칙은 늘어나는 방식이 다릅니다. 래칫 규칙은 사람이 저지른 실수에서 나오므로 계속 늘어납니다. 위임 규칙은 멈춥니다. 기준이 알아보는 작업 유형이 정해져 있어서, 유형마다 규칙이 하나 생기면 더 승격할 것이 없습니다.
package/README.md CHANGED
@@ -60,7 +60,7 @@ npm i -g sprag-cli # Claude Code, plus Codex when it is installed
60
60
  needs attention:
61
61
 
62
62
  ```
63
- 🔀 Routing saved $44.67 | opus→sonnet 59× $39.75 · opus→haiku 10× $4.92
63
+ 🔀 Routing saved $188 | opus→sonnet 67× $93.4 · fable→sonnet 26× $83.7 · opus→haiku 25× $7.41 · fable→haiku 6× $3.73
64
64
  🧠 Cache hit 98.8% · ⏳ Cache expires 59:46 · 🅷 5/5 · 📦 Ctx 47% of 1M
65
65
  ```
66
66
 
@@ -95,7 +95,7 @@ holding.
95
95
  | Public benchmark accuracy | **59.1%** vs 57.9% best single model | `▰▰▰▰▰▰▰▰▰▰▰▰` | [benchmark](https://github.com/rootstudioyaml/sprag/blob/main/docs/BENCHMARK.md) |
96
96
  | Cost for the same queries | **$268** vs gpt-5 $388, gemini-2.5-pro $734 | `▰▰▰▰▰▱▱▱▱▱▱▱` | [benchmark](https://github.com/rootstudioyaml/sprag/blob/main/docs/BENCHMARK.md) |
97
97
  | Tokens for the same documents | **−95.9%**, 2,011,178 → 82,209 over 12 files | `▰▱▱▱▱▱▱▱▱▱▱▱` | [doc2md](https://github.com/rootstudioyaml/sprag/blob/main/docs/DOC2MD.md) |
98
- | Delegation savings, from the ledger | **$44.67** over 69 delegated runs | `▰▰▰▰▰▰▰▰▰▰▰▱` | `sprag route-scan savings` |
98
+ | Delegation savings, from the ledger | **$188.27** over 124 delegated runs | `▰▰▰▰▰▰▰▰▰▰▰▱` | `sprag route-scan savings` |
99
99
  | Cheap tier on the same prompts | **24% of the cost**, one answer worse out of eight | `▰▰▰▱▱▱▱▱▱▱▱▱` | [A/B run](#the-cheap-tier-on-the-same-prompts) |
100
100
 
101
101
  The first two rows answer the objection that comes up first: that moving work to
@@ -111,16 +111,22 @@ it again and you get the same figures. It validates the criteria, not sprag's ow
111
111
  routing: the dataset has no Claude models, so deepseek-v3, qwen3-235b and gpt-5
112
112
  stood in for cheap, mid and flagship.
113
113
 
114
- The last three rows are ledgers on this machine. They grow as it runs. These were
115
- read on 2026-09-18.
114
+ The last three rows are ledgers on this machine. They grow as it runs. The
115
+ routing figures were read on 2026-10-09, the document figures on 2026-09-18.
116
+ Model names are the ones each run actually used.
116
117
 
117
118
  ### Current, on one machine, last 30 days
118
119
 
119
120
  ```
120
- 🔀 Routing saved $44.67 total (last 7d $23.21 · last 30d $42.73)
121
-
122
- opus-5 → sonnet-5 59 runs $39.75 ▰▰▰▰▰▰▰▰▰▰▱▱
123
- opus-5 → haiku-4.5 10 runs $4.92 ▰▱▱▱▱▱▱▱▱▱▱▱
121
+ 🔀 Routing saved $188.27 total (last 7d $13.31 · last 30d $150.64)
122
+
123
+ fable-5 → sonnet-5 24 runs $81.19 ▰▰▰▰▰▰▰▰▰▰▰▰
124
+ opus-5 → sonnet-5 54 runs $68.85 ▰▰▰▰▰▰▰▰▰▰▱▱
125
+ opus-5 → sonnet-5.5 13 runs $24.58 ▰▰▰▰▱▱▱▱▱▱▱▱
126
+ opus-5 → haiku-4.5 25 runs $7.41 ▰▱▱▱▱▱▱▱▱▱▱▱
127
+ fable-5 → haiku-4.5 5 runs $3.22 ▰▱▱▱▱▱▱▱▱▱▱▱
128
+ fable-5 → sonnet-5.5 2 runs $2.51 ▰▱▱▱▱▱▱▱▱▱▱▱
129
+ fable-5 → haiku-5.5 1 run $0.51 ▰▱▱▱▱▱▱▱▱▱▱▱
124
130
  ```
125
131
 
126
132
  | Also measured | Over 30 days | |
@@ -155,8 +161,8 @@ arrives as fewer round-trips rather than as cheaper ones.
155
161
  ## The statusline in one line
156
162
 
157
163
  ```
158
- 🔀 Routing saved $44.67 | opus→sonnet 59× $39.75 · opus→haiku 10× $4.92
159
- 🚨 5H ▰▰▰▰▰▰▰▰▰▰▰▱ 94% 🔄 12:36 · 🅷 5/5 · 🤖 Opus 5 · 🧠 Cache hit 98.8% · ⏳ Cache expires 59:46 · 📅 weekly ▰▰▰▰▰▱▱▱▱▱▱▱ 38% · 💵 Sep $42 · 📦 Ctx 47% of 1M
164
+ 🔀 Routing saved $188 | opus→sonnet 67× $93.4 · fable→sonnet 26× $83.7 · opus→haiku 25× $7.41 · fable→haiku 6× $3.73
165
+ 🚨 5H ▰▰▰▰▰▰▰▰▰▰▰▱ 94% 🔄 12:36 · 🅷 5/5 · 🤖 Opus 5.5 · 🧠 Cache hit 98.8% · ⏳ Cache expires 59:46 · 📅 weekly ▰▰▰▰▰▱▱▱▱▱▱▱ 38% · 💵 Sep $42 · 📦 Ctx 47% of 1M
160
166
  ```
161
167
 
162
168
  When something is wrong the warning chip leads the line: `🚨 5H/7D NN%`, `⚠ Ctx 500k+`, `⚠ Cache miss`, `⚠ Input spike`, `⚠ Output heavy`, `⚠ Call surge`, `⚠ Rebuild churn`, `⚠ 5m TTL`. Paste the chip text into Claude and the Skill names the root-cause code and the fix. Every segment is explained in [the statusline reference](https://github.com/rootstudioyaml/sprag/blob/main/docs/STATUSLINE.md).
@@ -194,7 +200,7 @@ surfaces, and never send design or diagnosis.
194
200
  | Measured | Value | Sample | Window |
195
201
  |---|---|---|---|
196
202
  | Documents converted | 2,011,178 → 82,209 tokens | 12 files (xlsx, pptx, pdf) | to 2026-09-18 |
197
- | Delegated runs | $44.67 saved, $0.65 per run | 69 runs | to 2026-09-18 |
203
+ | Delegated runs | $188.27 saved, $1.52 per run | 124 runs | to 2026-10-09 |
198
204
  | Ratchet rules accumulated | 37 (30 global, 7 per-project) | 4 repositories | 2026-05-08 to 09-16 |
199
205
 
200
206
  The two counts grow differently. Ratchet rules come from mistakes a person made,
package/bin/cli.js CHANGED
@@ -106,7 +106,7 @@ function tableColor() {
106
106
  const KNOWN_SUBCOMMANDS = new Set([
107
107
  'last', 'brief', 'history', 'handoff', 'install', 'uninstall', 'mode', 'korean', 'cohesion',
108
108
  'doc2md', 'delegate', 'harness', 'route-scan', 'compact-window', 'update-check', 'upgrade',
109
- 'seed', 'litellm-budget', 'profile-map', 'feedback',
109
+ 'seed', 'litellm-budget', 'profile-map', 'feedback', 'saved',
110
110
  ]);
111
111
 
112
112
  const USAGE = `sprag — Claude Code and Codex agent harness
@@ -148,6 +148,7 @@ Usage:
148
148
  sprag doc2md on|off|<file> convert documents to Markdown before the model reads them
149
149
  sprag delegate on|off|status add bounds and context to Task/Agent delegation prompts
150
150
  sprag compact-window audit or pin autoCompactWindow for 1M sessions
151
+ sprag saved [reset|undo] show, reset or restore the saved-money counters (--agent codex for Codex)
151
152
  sprag litellm-budget LiteLLM gateway key budget and spend
152
153
  sprag update-check is a newer version out?
153
154
  sprag brief --hook internal: the UserPromptSubmit hook entry point
@@ -305,6 +306,12 @@ async function main() {
305
306
  return (await import('../src/commands/seed.js')).run({ args, hasFlag });
306
307
  }
307
308
 
309
+ // Subcommand: saved — stopwatch reset for the lifetime "saved" counters.
310
+ // sprag saved | saved reset [routing|doc2md|all] | saved undo
311
+ if (args[0] === 'saved') {
312
+ return (await import('../src/commands/saved.js')).run({ args, hasFlag });
313
+ }
314
+
308
315
  // Subcommand: harness — manage the project's CLAUDE.md harness rules.
309
316
  // sprag harness init # write CLAUDE.md (5 sections) + ratchet.md
310
317
  // sprag harness uninit # remove harness block from CLAUDE.md (backup kept)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sprag-cli",
3
- "version": "3.58.0",
3
+ "version": "3.59.0",
4
4
  "description": "Lower your Claude Code bill without changing how you work: simple tasks go to cheaper models, documents go in as compact Markdown, and the statusline shows spend and savings live. Works with Codex too.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agents.js CHANGED
@@ -45,22 +45,29 @@ export function agentExists(name, root = process.cwd()) {
45
45
 
46
46
  /**
47
47
  * Korean phrase for a delegation target, e.g.
48
- * agent present → `haiku-explore(model: haiku)`
48
+ * agent present → `haiku-explore(model 인자 생략)`
49
49
  * agent absent → `model: haiku`
50
+ *
51
+ * A named agent carries its model in its own definition, and the Agent tool's
52
+ * `model` argument overrides that definition. The old phrase
53
+ * `haiku-explore(model: haiku)` read as "pass model: haiku", and Claude Code
54
+ * resolves the bare `haiku` alias to an older Haiku than a definition pinned to
55
+ * a full id (claude-haiku-5-5), so following the rule undid the pin. The phrase
56
+ * now says to leave the argument out.
50
57
  */
51
58
  export function agentPhrase(name, { model, root } = {}) {
52
59
  const tier = model || AGENT_MODEL[name] || 'haiku';
53
- return agentExists(name, root) ? `${name}(model: ${tier})` : `model: ${tier}`;
60
+ return agentExists(name, root) ? `${name}(model 인자 생략)` : `model: ${tier}`;
54
61
  }
55
62
 
56
63
  /**
57
64
  * English phrase, including the article, e.g.
58
- * agent present → `the haiku-explore (model: haiku) subagent`
65
+ * agent present → `the haiku-explore subagent (omit the model argument)`
59
66
  * agent absent → `a model: haiku subagent`
60
67
  */
61
68
  export function agentPhraseEn(name, { model, root } = {}) {
62
69
  const tier = model || AGENT_MODEL[name] || 'haiku';
63
70
  return agentExists(name, root)
64
- ? `the ${name} (model: ${tier}) subagent`
71
+ ? `the ${name} subagent (omit the model argument)`
65
72
  : `a model: ${tier} subagent`;
66
73
  }
@@ -1,8 +1,9 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
- import { mkdirSync, readdirSync, readFileSync, writeFileSync, renameSync, realpathSync } from 'node:fs';
2
+ import { mkdirSync, readdirSync, readFileSync, writeFileSync, renameSync, realpathSync, statSync } from 'node:fs';
3
3
  import { extname, join, resolve } from 'node:path';
4
4
  import { codexUserDir } from './agent.js';
5
5
  import { userDataDir } from './paths.js';
6
+ import { readResetMarks, isBeforeReset } from './saved-reset.cjs';
6
7
 
7
8
  const hash = (value) => createHash('sha256').update(value).digest('hex');
8
9
  const ledgerDir = ({ home = codexUserDir(), dir = userDataDir() } = {}) => join(dir, 'codex-doc2md', hash(resolve(home)));
@@ -26,13 +27,17 @@ export function recordCodexDocument(source, result, opts) {
26
27
  } catch { /* Accounting must not prevent document conversion. */ }
27
28
  }
28
29
 
30
+ // Records carry no timestamp field, so the reset mark is compared with the
31
+ // file's mtime. A source converted again rewrites its record and counts as new.
29
32
  export function codexDocumentTotals(opts) {
30
- const total = { docs: 0, byExt: [], scope: 'codex-total' };
33
+ const since = readResetMarks(opts?.dir ?? userDataDir())['codex-docs'];
34
+ const total = { docs: 0, byExt: [], scope: 'codex-total', since };
31
35
  const byExt = new Map();
32
36
  try {
33
37
  const dir = ledgerDir(opts);
34
38
  for (const file of readdirSync(dir).filter((name) => /^[a-f0-9]{64}\.json$/.test(name))) {
35
39
  try {
40
+ if (since !== null && isBeforeReset(statSync(join(dir, file)).mtimeMs, since)) continue;
36
41
  const event = JSON.parse(readFileSync(join(dir, file), 'utf8'));
37
42
  if (event?.version !== 1 || !['pdf', 'pptx', 'xlsx', 'xls', 'docx', 'fig'].includes(event.ext)) continue;
38
43
  total.docs++;
@@ -3,6 +3,7 @@ import { join } from 'node:path';
3
3
  import { randomBytes, randomUUID } from 'node:crypto';
4
4
  import { userDataDir } from './paths.js';
5
5
  import { roundUsd } from './savings-ledger.js';
6
+ import { readResetMarks, isBeforeReset } from './saved-reset.cjs';
6
7
  import { discoverCodexSessionFiles, parseCodexTurns } from './codex-parser.js';
7
8
  import { codexRunCost, codexPriceBook, readCodexPrices } from './codex-cache-policy.js';
8
9
 
@@ -262,10 +263,15 @@ export async function refreshCodexLedger({ dir = userDataDir(), home, now = Date
262
263
  return ledger;
263
264
  }
264
265
 
265
- /** Signed totals over priced runs; unpriced runs are counted, never summed as zero. */
266
+ /**
267
+ * Signed totals over priced runs; unpriced runs are counted, never summed as zero.
268
+ * Runs before the `codex-routing` reset mark are left out of every figure,
269
+ * `runs` and `priced` included; `since` reports the mark.
270
+ */
266
271
  export function codexRoutingSavedTotals({ dir = userDataDir(), now = Date.now() } = {}) {
267
- const events = Object.values(loadCodexLedger({ dir }).events);
268
- const totals = { week: 0, month: 0, total: 0, runs: events.length, priced: 0 };
272
+ const since = readResetMarks(dir)['codex-routing'];
273
+ const events = Object.values(loadCodexLedger({ dir }).events).filter((e) => !isBeforeReset(e.ts, since));
274
+ const totals = { week: 0, month: 0, total: 0, runs: events.length, priced: 0, since };
269
275
  for (const e of events) {
270
276
  if (!Number.isFinite(e.usd)) continue;
271
277
  totals.priced++;
@@ -23,6 +23,7 @@ Usage: sprag <command> --agent codex [options]
23
23
  brief [on|off|status] Current warnings or prompt-briefing preference
24
24
  last / history Context/rate-limit warnings and handoffs; --days, --session, --project
25
25
  handoff [--cwd path] Write Git state and Codex usage to HANDOFF-*.md
26
+ saved [reset|undo] Show, reset or restore the saved-money counters
26
27
  delegate on|off|status Native model routing and SubagentStart guidance
27
28
  delegate model <model> Default target for spawns without an explicit model
28
29
  delegate rules Codex-only category rules; add/rm with explicit scope
@@ -144,6 +145,7 @@ export async function run({ args, version }) {
144
145
  return;
145
146
  }
146
147
  if (cmd === 'delegate') return (await import('./codex-delegate.js')).run({ args, getArg, hasFlag, root });
148
+ if (cmd === 'saved') return (await import('./saved.js')).run({ args, hasFlag, agent: 'codex' });
147
149
  if (cmd === 'seed') return (await import('./seed.js')).run({ args, hasFlag, agent: 'codex', root });
148
150
  if (cmd === 'route-scan') {
149
151
  const rs = await import('../codex-route-scan.js');
@@ -56,9 +56,22 @@ export async function run({ args, hasFlag, numArg }) {
56
56
  // which model actually ran it.
57
57
  if (args[1] === 'savings') {
58
58
  const { loadLedger, delegationSavedTotals } = await import('../savings-ledger.js');
59
+ // Runs from before `sprag saved reset` are kept in the ledger but not listed.
60
+ const { readResetMarks, isBeforeReset } = await import('../saved-reset.cjs');
61
+ const { userDataDir } = await import('../paths.js');
62
+ const since = readResetMarks(userDataDir()).routing;
59
63
  const events = Object.entries(loadLedger().events)
60
64
  .map(([key, e]) => ({ key, ...e }))
65
+ .filter((e) => !isBeforeReset(e.ts, since))
61
66
  .sort((a, b) => b.ts - a.ts);
67
+ if (events.length === 0 && since !== null) {
68
+ // After a reset an empty list is the expected state, not a setup gap.
69
+ const at = new Date(since).toLocaleString(lang === 'ko' ? 'ko-KR' : 'en-US');
70
+ console.log(lang === 'ko'
71
+ ? `${at}에 카운터를 초기화한 뒤로 기록된 라우팅 절감이 아직 없습니다. 되돌리려면 \`sprag saved undo\` 를 실행하십시오.`
72
+ : `No routing savings recorded since the counter was reset at ${at}. Run \`sprag saved undo\` to restore it.`);
73
+ return;
74
+ }
62
75
  if (events.length === 0) {
63
76
  console.log(lang === 'ko'
64
77
  ? '기록된 라우팅 절감 없음. 승격된 룰이 실제로 위임을 일으킨 뒤 `route-scan --refresh` 를 돌리면 채워집니다.'
@@ -72,6 +85,12 @@ export async function run({ args, hasFlag, numArg }) {
72
85
  console.log(lang === 'ko'
73
86
  ? `🔀 라우팅 절감 누적 ${money(t.total)} (최근 7일 ${money(t.week)} · 30일 ${money(t.month)})`
74
87
  : `🔀 Routing saved, lifetime ${money(t.total)} (last 7d ${money(t.week)} · 30d ${money(t.month)})`);
88
+ if (since !== null) {
89
+ const at = new Date(since).toLocaleString(lang === 'ko' ? 'ko-KR' : 'en-US');
90
+ console.log(lang === 'ko'
91
+ ? ` ${at}에 카운터를 초기화했습니다. 이후 기록만 집계합니다.`
92
+ : ` Counter reset at ${at}; only later runs are counted.`);
93
+ }
75
94
 
76
95
  // Per model-pair rollup first: the "what moved where" question is what
77
96
  // this view exists to answer, and it is easier to read than the log.
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Subcommand: saved — a stopwatch reset for the lifetime "saved" counters.
3
+ *
4
+ * sprag saved # totals since the last reset
5
+ * sprag saved reset [routing|doc2md|all] # start counting from now
6
+ * sprag saved undo # put the previous counters back
7
+ * (add --agent codex for the Codex counters: codex-routing, codex-docs)
8
+ *
9
+ * Nothing is deleted. A reset only records a timestamp, and the totals count
10
+ * events from that moment on; see src/saved-reset.cjs.
11
+ */
12
+
13
+ const SCOPE_SETS = {
14
+ claude: ['routing', 'doc2md'],
15
+ codex: ['codex-routing', 'codex-docs'],
16
+ };
17
+
18
+ /** Totals since each scope's mark: { usd } for money counters, { docs } for the Codex document counter. */
19
+ async function currentTotals(scope, dir) {
20
+ if (scope === 'routing') {
21
+ const { delegationSavedTotals } = await import('../savings-ledger.js');
22
+ return { usd: delegationSavedTotals(Date.now(), dir).total };
23
+ }
24
+ if (scope === 'doc2md') {
25
+ const { doc2mdSavedTotals } = await import('../doc2md-ledger.cjs');
26
+ return { usd: doc2mdSavedTotals(dir).total };
27
+ }
28
+ if (scope === 'codex-routing') {
29
+ const { codexRoutingSavedTotals } = await import('../codex-ledger.js');
30
+ return { usd: codexRoutingSavedTotals({ dir }).total };
31
+ }
32
+ const { codexDocumentTotals } = await import('../codex-doc2md-ledger.js');
33
+ return { docs: codexDocumentTotals({ dir }).docs };
34
+ }
35
+
36
+ export async function run({ args, hasFlag, agent = 'claude', dir: dirArg }) {
37
+ const { userLanguage } = await import('../config.js');
38
+ const { userDataDir } = await import('../paths.js');
39
+ const { readResetMarks, resetSaved, undoReset } = await import('../saved-reset.cjs');
40
+ const ko = userLanguage() === 'ko';
41
+ const dir = dirArg || userDataDir();
42
+ const scopes = SCOPE_SETS[agent === 'codex' ? 'codex' : 'claude'];
43
+ const suffix = agent === 'codex' ? ' --agent codex' : '';
44
+
45
+ const money = (v) => (v < 0 ? `-$${(-v).toFixed(2)}` : `$${v.toFixed(2)}`);
46
+ const fmtTotal = (t) => (t.docs !== undefined ? (ko ? `${t.docs}건` : `${t.docs} doc(s)`) : money(t.usd));
47
+ const fmtTime = (ms) => new Date(ms).toLocaleString(ko ? 'ko-KR' : 'en-US');
48
+ const label = (scope) => ({
49
+ routing: ko ? '라우팅 절감' : 'Routing saved',
50
+ doc2md: ko ? '문서 변환 절감' : 'Document conversion saved',
51
+ 'codex-routing': ko ? 'Codex 라우팅 절감' : 'Codex routing saved',
52
+ 'codex-docs': ko ? 'Codex 문서 변환' : 'Codex documents converted',
53
+ })[scope];
54
+
55
+ const sub = args[1];
56
+
57
+ if (sub === undefined) {
58
+ const marks = readResetMarks(dir);
59
+ for (const s of scopes) {
60
+ const t = await currentTotals(s, dir);
61
+ const since = marks[s] === null
62
+ ? (ko ? '초기화한 적 없음' : 'not reset')
63
+ : (ko ? `${fmtTime(marks[s])} 이후` : `since ${fmtTime(marks[s])}`);
64
+ console.log(`${label(s)}: ${fmtTotal(t)} (${since})`);
65
+ }
66
+ return;
67
+ }
68
+
69
+ if (sub === 'reset') {
70
+ const arg = args[2] || 'all';
71
+ const base = agent === 'codex' ? { routing: 'codex-routing', doc2md: 'codex-docs' } : { routing: 'routing', doc2md: 'doc2md' };
72
+ let targets;
73
+ if (arg === 'all') targets = scopes;
74
+ else if (scopes.includes(arg)) targets = [arg];
75
+ else if (base[arg]) targets = [base[arg]];
76
+ else {
77
+ console.error(ko
78
+ ? `알 수 없는 범위입니다: ${arg}. 사용할 수 있는 범위는 ${scopes.join(', ')}, all 입니다.`
79
+ : `Unknown scope: ${arg}. Valid scopes are ${scopes.join(', ')}, all.`);
80
+ process.exit(1);
81
+ }
82
+ const before = {};
83
+ for (const s of targets) before[s] = await currentTotals(s, dir);
84
+ resetSaved(targets, { dir });
85
+ for (const s of targets) {
86
+ console.log(ko
87
+ ? `${label(s)} 카운터를 0으로 초기화했습니다. 이전 합계는 ${fmtTotal(before[s])}입니다.`
88
+ : `Reset the ${label(s)} counter to zero. The previous total was ${fmtTotal(before[s])}.`);
89
+ }
90
+ console.log(ko
91
+ ? `원장 기록은 지우지 않았습니다. \`sprag saved undo${suffix}\`로 되돌릴 수 있습니다.`
92
+ : `No ledger data was deleted. Run \`sprag saved undo${suffix}\` to restore the previous counters.`);
93
+ return;
94
+ }
95
+
96
+ if (sub === 'undo') {
97
+ const undone = undoReset({ dir });
98
+ if (!undone) {
99
+ console.log(ko ? '되돌릴 초기화 기록이 없습니다.' : 'There is no reset to undo.');
100
+ return;
101
+ }
102
+ for (const s of undone.scopes) {
103
+ const t = await currentTotals(s, dir);
104
+ console.log(ko
105
+ ? `${label(s)} 카운터를 되돌렸습니다. 현재 합계는 ${fmtTotal(t)}입니다.`
106
+ : `Restored the ${label(s)} counter. The total is now ${fmtTotal(t)}.`);
107
+ }
108
+ return;
109
+ }
110
+
111
+ console.error(ko
112
+ ? `알 수 없는 하위 명령입니다: ${sub}. 사용법은 sprag saved [reset|undo] 입니다.`
113
+ : `Unknown subcommand: ${sub}. Usage: sprag saved [reset|undo].`);
114
+ process.exit(1);
115
+ }
package/src/cost.js CHANGED
@@ -1,13 +1,29 @@
1
1
  /**
2
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.
3
+ * Source: https://platform.claude.com/docs/en/about-claude/pricing
4
+ * Prices per million tokens (USD). Updated 2026-10 for Fable 5.1, Opus 5.5,
5
+ * Sonnet 5 / 5.5 and Haiku 5.5 (checked against the pricing page 2026-10-09).
5
6
  *
6
7
  * Note: Opus 4.5/4.6/4.7 use reduced pricing ($5/$25) vs. older Opus 4/4.1 ($15/$75).
7
8
  * Cache writes are now tracked separately for 5m and 1h TTLs, each with their own rate.
8
9
  */
9
10
 
11
+ // Haiku 5.5 charges a request whose prompt (input + cache writes + cache
12
+ // reads) exceeds this many tokens at the higher 'claude-haiku-5-5-long' rates.
13
+ // Each request is priced on its own, so callers accumulate such requests into
14
+ // `totals.long` (see parser.js) rather than relying on session-wide sums.
15
+ export const LONG_PROMPT_THRESHOLD = 100_000;
16
+
10
17
  const PRICING = {
18
+ // Fable 5.1 / Mythos 5.1 — same prices as Fable 5 except cache reads, which
19
+ // drop to $0.25.
20
+ 'claude-fable-5-1': {
21
+ input: 10.0,
22
+ cacheWrite5m: 12.5,
23
+ cacheWrite1h: 20.0,
24
+ cacheRead: 0.25,
25
+ output: 50.0,
26
+ },
11
27
  // Fable 5 / Mythos 5 — premium tier above Opus ($10/$50). Cache write
12
28
  // rates follow the standard multipliers (1.25x input for 5m, 2x for 1h),
13
29
  // cache read is 0.1x input.
@@ -18,6 +34,14 @@ const PRICING = {
18
34
  cacheRead: 1.0,
19
35
  output: 50.0,
20
36
  },
37
+ // Opus 5.5 — cheaper than the Opus 4.5-5 tier below.
38
+ 'claude-opus-5-5': {
39
+ input: 4.0,
40
+ cacheWrite5m: 5.0,
41
+ cacheWrite1h: 8.0,
42
+ cacheRead: 0.2,
43
+ output: 20.0,
44
+ },
21
45
  // Opus 4.5+ (new pricing tier — includes 4.5, 4.6, 4.7, 4.8, and future)
22
46
  'claude-opus-new': {
23
47
  input: 5.0,
@@ -34,6 +58,23 @@ const PRICING = {
34
58
  cacheRead: 1.5,
35
59
  output: 75.0,
36
60
  },
61
+ // Sonnet 5 — standard price is $2/$10. The rise to $3/$15 planned for
62
+ // 2026-09-01 was cancelled, so this is not an introductory rate.
63
+ 'claude-sonnet-5': {
64
+ input: 2.0,
65
+ cacheWrite5m: 2.5,
66
+ cacheWrite1h: 4.0,
67
+ cacheRead: 0.2,
68
+ output: 10.0,
69
+ },
70
+ // Sonnet 5.5 — same as Sonnet 5 except cache reads at $0.10.
71
+ 'claude-sonnet-5-5': {
72
+ input: 2.0,
73
+ cacheWrite5m: 2.5,
74
+ cacheWrite1h: 4.0,
75
+ cacheRead: 0.1,
76
+ output: 10.0,
77
+ },
37
78
  // Sonnet 4 / 4.5 / 4.6 / 3.7
38
79
  'claude-sonnet': {
39
80
  input: 3.0,
@@ -42,6 +83,23 @@ const PRICING = {
42
83
  cacheRead: 0.3,
43
84
  output: 15.0,
44
85
  },
86
+ // Haiku 5.5, prompt <= LONG_PROMPT_THRESHOLD tokens.
87
+ 'claude-haiku-5-5': {
88
+ input: 0.1,
89
+ cacheWrite5m: 0.125,
90
+ cacheWrite1h: 0.2,
91
+ cacheRead: 0.01,
92
+ output: 0.5,
93
+ },
94
+ // Haiku 5.5, prompt > LONG_PROMPT_THRESHOLD tokens. Not returned by
95
+ // detectPricingTier: estimateCost applies it to `totals.long` only.
96
+ 'claude-haiku-5-5-long': {
97
+ input: 0.5,
98
+ cacheWrite5m: 0.625,
99
+ cacheWrite1h: 1.0,
100
+ cacheRead: 0.05,
101
+ output: 2.5,
102
+ },
45
103
  // Haiku 4.5
46
104
  'claude-haiku-4-5': {
47
105
  input: 1.0,
@@ -72,16 +130,23 @@ const PRICING = {
72
130
  * Detect pricing tier from Claude model identifier.
73
131
  * Examples: 'claude-opus-4-7', 'claude-sonnet-4-5', 'claude-haiku-4-5'.
74
132
  */
75
- function detectPricingTier(model) {
133
+ export function detectPricingTier(model) {
76
134
  if (!model) return 'claude-sonnet';
77
135
  const m = model.toLowerCase();
78
136
 
79
137
  // Fable 5 / Mythos 5 — must be checked before the generic fallback:
80
138
  // without this, 'claude-fable-5' fell through to the Sonnet tier and
81
139
  // under-estimated costs ~3x ($3/$15 vs the real $10/$50).
82
- if (m.includes('fable') || m.includes('mythos')) return 'claude-fable-5';
140
+ if (m.includes('fable') || m.includes('mythos')) {
141
+ // `(?!\d)` keeps a date suffix (`fable-5-20261001`) from reading as 5.1.
142
+ if (/(fable|mythos)[-_.]?5[-_.]1(?!\d)/.test(m)) return 'claude-fable-5-1';
143
+ return 'claude-fable-5';
144
+ }
83
145
 
84
146
  if (m.includes('opus')) {
147
+ // 5.5 first: `opus-5-5` would otherwise match the generic 5+ pattern, and
148
+ // `(?!\d)` keeps `opus-5-20260101` (Opus 5 with a date) out of 5.5.
149
+ if (/opus[-_.]?5[-_.]5(?!\d)/.test(m)) return 'claude-opus-5-5';
85
150
  // Opus 4.5, 4.6, 4.7, and future 5+ use the new reduced pricing.
86
151
  if (/opus[-_.]?4[-_.]?[5-9]\b/.test(m)) return 'claude-opus-new';
87
152
  if (/opus[-_.]?[5-9]/.test(m)) return 'claude-opus-new';
@@ -90,6 +155,7 @@ function detectPricingTier(model) {
90
155
  }
91
156
 
92
157
  if (m.includes('haiku')) {
158
+ if (/haiku[-_.]?5[-_.]5(?!\d)/.test(m)) return 'claude-haiku-5-5';
93
159
  if (/haiku[-_.]?4[-_.]?5/.test(m)) return 'claude-haiku-4-5';
94
160
  // The 3.x ids put the version first (`claude-3-5-haiku-20241022`,
95
161
  // `claude-3-haiku-20240307`), so both orders are matched. With only the
@@ -99,6 +165,13 @@ function detectPricingTier(model) {
99
165
  return 'claude-haiku-4-5';
100
166
  }
101
167
 
168
+ if (m.includes('sonnet')) {
169
+ if (/sonnet[-_.]?5[-_.]5(?!\d)/.test(m)) return 'claude-sonnet-5-5';
170
+ // Sonnet 5 incl. date-suffixed ids; the 3.x/4.x ids never put a 5 right
171
+ // after the family name (`claude-3-5-sonnet-2024…` has it before).
172
+ if (/sonnet[-_.]?5(?!\d)/.test(m)) return 'claude-sonnet-5';
173
+ }
174
+
102
175
  // Sonnet (default fallback): 3.7, 4, 4.5, 4.6 all share the same pricing.
103
176
  return 'claude-sonnet';
104
177
  }
@@ -112,9 +185,14 @@ function detectPricingTier(model) {
112
185
  */
113
186
  const TIER_RANK = {
114
187
  'claude-fable-5': 3,
188
+ 'claude-fable-5-1': 3,
189
+ 'claude-opus-5-5': 2,
115
190
  'claude-opus-legacy': 2,
116
191
  'claude-opus-new': 2,
117
192
  'claude-sonnet': 1,
193
+ 'claude-sonnet-5': 1,
194
+ 'claude-sonnet-5-5': 1,
195
+ 'claude-haiku-5-5': 0,
118
196
  'claude-haiku-4-5': 0,
119
197
  'claude-haiku-3-5': 0,
120
198
  'claude-haiku-3': 0,
@@ -203,8 +281,54 @@ function tokensToMillions(n) {
203
281
  export function estimateCost(totals, model, { price } = {}) {
204
282
  if (price) return estimateCostAtPrice(totals, price);
205
283
  const tier = detectPricingTier(model);
206
- const p = PRICING[tier];
207
284
 
285
+ // Haiku 5.5 prices each request by its own prompt length, so the long-prompt
286
+ // part (`totals.long`, same shape) is priced at the long rates and the rest
287
+ // at the short rates. Every other tier ignores `long`.
288
+ let c;
289
+ if (tier === 'claude-haiku-5-5' && totals.long) {
290
+ const short = subtractTotals(totals, totals.long);
291
+ const a = priceTotals(short, PRICING[tier]);
292
+ const b = priceTotals(totals.long, PRICING['claude-haiku-5-5-long']);
293
+ c = {
294
+ actual: a.actual + b.actual,
295
+ noCacheCost: a.noCacheCost + b.noCacheCost,
296
+ scenario5mCost: a.scenario5mCost + b.scenario5mCost,
297
+ write1h: a.write1h + b.write1h,
298
+ };
299
+ } else {
300
+ c = priceTotals(totals, PRICING[tier]);
301
+ }
302
+ const { actual, noCacheCost, scenario5mCost, write1h } = c;
303
+
304
+ return {
305
+ tier,
306
+ actual: round(actual),
307
+ noCacheCost: round(noCacheCost),
308
+ savings: round(noCacheCost - actual),
309
+ savingsRate: noCacheCost > 0 ? (noCacheCost - actual) / noCacheCost : 0,
310
+ scenario5mCost: round(scenario5mCost),
311
+ extraCostIf5m: round(scenario5mCost - actual),
312
+ // The row asks a counterfactual: what would dropping to 5m-only cost you?
313
+ // With no 1h writes there is nothing to lose, and the arithmetically
314
+ // honest `+$0` it printed read as "5m-only is free" — the opposite of the
315
+ // truth for a gateway user already confined to 5m. The display layer has
316
+ // to change the question rather than the number.
317
+ extraCostIf5mApplicable: write1h > 0,
318
+ };
319
+ }
320
+
321
+ const TOTAL_FIELDS = ['input', 'cacheCreation', 'cacheRead', 'ephemeral5m', 'ephemeral1h', 'output'];
322
+
323
+ /** totals - part, field by field (clamped at 0), dropping the `long` key. */
324
+ function subtractTotals(totals, part) {
325
+ const out = {};
326
+ for (const f of TOTAL_FIELDS) out[f] = Math.max(0, (totals[f] ?? 0) - (part[f] ?? 0));
327
+ return out;
328
+ }
329
+
330
+ /** Unrounded cost figures for one set of totals at one rate card. */
331
+ function priceTotals(totals, p) {
208
332
  // Prefer explicit 5m/1h split when available; fall back to cacheCreation at 5m rate
209
333
  // (conservative — 5m is cheaper than 1h).
210
334
  const write5m = totals.ephemeral5m ?? 0;
@@ -235,21 +359,7 @@ export function estimateCost(totals, model, { price } = {}) {
235
359
  tokensToMillions(Math.max(0, totals.cacheRead - extra5mCreation)) * p.cacheRead +
236
360
  tokensToMillions(totals.output) * p.output;
237
361
 
238
- return {
239
- tier,
240
- actual: round(actual),
241
- noCacheCost: round(noCacheCost),
242
- savings: round(noCacheCost - actual),
243
- savingsRate: noCacheCost > 0 ? (noCacheCost - actual) / noCacheCost : 0,
244
- scenario5mCost: round(scenario5mCost),
245
- extraCostIf5m: round(scenario5mCost - actual),
246
- // The row asks a counterfactual: what would dropping to 5m-only cost you?
247
- // With no 1h writes there is nothing to lose, and the arithmetically
248
- // honest `+$0` it printed read as "5m-only is free" — the opposite of the
249
- // truth for a gateway user already confined to 5m. The display layer has
250
- // to change the question rather than the number.
251
- extraCostIf5mApplicable: write1h > 0,
252
- };
362
+ return { actual, noCacheCost, scenario5mCost, write1h };
253
363
  }
254
364
 
255
365
  /**
@@ -69,6 +69,7 @@
69
69
 
70
70
  const fs = require('node:fs');
71
71
  const path = require('node:path');
72
+ const { readResetMarks, isBeforeReset } = require('./saved-reset.cjs');
72
73
 
73
74
  const WEEK_MS = 7 * 24 * 3600 * 1000;
74
75
  const MONTH_MS = 30 * 24 * 3600 * 1000;
@@ -185,13 +186,18 @@ function recordConversion(userDataDir, event) {
185
186
  * the lifetime rollup per format, priciest first, then most-converted. Never
186
187
  * throws; an unreadable ledger yields zeros, and the statusline hides the
187
188
  * chip on a zero.
189
+ *
190
+ * Events before the `doc2md` reset mark (`sprag saved reset`) are skipped in
191
+ * every figure, and `since` reports the mark (null when never reset).
188
192
  */
189
193
  function doc2mdSavedTotals(userDataDir, now = Date.now()) {
190
- const empty = () => ({ week: 0, month: 0, total: 0, docs: 0, tokens: 0, byExt: [] });
194
+ const since = readResetMarks(userDataDir).doc2md;
195
+ const empty = () => ({ week: 0, month: 0, total: 0, docs: 0, tokens: 0, byExt: [], since });
191
196
  const totals = empty();
192
197
  const byExt = new Map();
193
198
  try {
194
199
  for (const e of Object.values(loadLedger(userDataDir).events)) {
200
+ if (isBeforeReset(e.ts, since)) continue;
195
201
  const usd = Number(e.usd) || 0;
196
202
  totals.total += usd;
197
203
  totals.docs += 1;
package/src/parser.js CHANGED
@@ -5,6 +5,7 @@ import { join, isAbsolute, basename } from 'node:path';
5
5
  import { homedir } from 'node:os';
6
6
  import { loadCache, getCached, putCached, saveCache, getCachedSince, putCachedSince } from './session-cache.js';
7
7
  import { resolveModelAlias, isGatewayModelId } from './model-alias.js';
8
+ import { LONG_PROMPT_THRESHOLD } from './cost.js';
8
9
 
9
10
  const CLAUDE_DIR = join(homedir(), '.claude', 'projects');
10
11
 
@@ -135,6 +136,20 @@ function addRequest(acc, r) {
135
136
  acc.ephemeral5m += r.ephemeral5mTokens;
136
137
  acc.ephemeral1h += r.ephemeral1hTokens;
137
138
  acc.output += r.outputTokens;
139
+ // Haiku 5.5 bills a request whose prompt exceeds LONG_PROMPT_THRESHOLD at
140
+ // higher rates, per request. The sums above lose that, so such requests are
141
+ // also accumulated into `long` (same shape). The key only appears once a long
142
+ // request exists, which keeps the usual totals shape unchanged.
143
+ if (r.inputTokens + r.cacheCreationTokens + r.cacheReadTokens > LONG_PROMPT_THRESHOLD) {
144
+ if (!acc.long) acc.long = emptyTotals();
145
+ const l = acc.long;
146
+ l.input += r.inputTokens;
147
+ l.cacheCreation += r.cacheCreationTokens;
148
+ l.cacheRead += r.cacheReadTokens;
149
+ l.ephemeral5m += r.ephemeral5mTokens;
150
+ l.ephemeral1h += r.ephemeral1hTokens;
151
+ l.output += r.outputTokens;
152
+ }
138
153
  }
139
154
 
140
155
  /**
package/src/route-scan.js CHANGED
@@ -366,6 +366,7 @@ export function runSaving(run, mainModel) {
366
366
  ephemeral5m: run.ephemeral5m,
367
367
  ephemeral1h: run.ephemeral1h,
368
368
  output: run.out,
369
+ ...(run.long ? { long: run.long } : {}),
369
370
  };
370
371
  const actual = sessionCost(totals, run.model);
371
372
  const counterfactual = sessionCost(totals, mainModel);
@@ -0,0 +1,118 @@
1
+ /**
2
+ * saved-reset — a stopwatch reset for the lifetime "saved" counters.
3
+ *
4
+ * The ledgers are append-only evidence, so a reset must not delete anything.
5
+ * Instead this file records one timestamp per counter ("mark"), and every
6
+ * totals function counts only events at or after its mark. Undo pops the
7
+ * latest reset and puts the previous marks back, so a reset is always
8
+ * reversible and the underlying ledger data is never touched.
9
+ *
10
+ * File: <userDataDir>/saved-reset.json
11
+ * { "version": 1,
12
+ * "marks": { "routing", "doc2md", "codex-routing", "codex-docs" }, // ms | null
13
+ * "history": [ { "at", "scopes": [...], "prev": { "<scope>": ms | null } } ] }
14
+ *
15
+ * CJS because doc2md-ledger.cjs reads it; ESM code imports it the same way.
16
+ * Every caller passes the data directory, so tests can point at a temp dir.
17
+ *
18
+ * Best-effort like every other state file here: a missing or unreadable file
19
+ * reads as "never reset", so a broken marker can only over-count, never hide
20
+ * events.
21
+ */
22
+
23
+ const fs = require('node:fs');
24
+ const path = require('node:path');
25
+
26
+ const VERSION = 1;
27
+ const SCOPES = ['routing', 'doc2md', 'codex-routing', 'codex-docs'];
28
+ /** Cap on remembered resets, so the file cannot grow without bound. */
29
+ const MAX_HISTORY = 50;
30
+
31
+ const emptyMarks = () => Object.fromEntries(SCOPES.map((s) => [s, null]));
32
+
33
+ function resetPath(dir) {
34
+ return path.join(dir, 'saved-reset.json');
35
+ }
36
+
37
+ const markOrNull = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
38
+
39
+ function readState(dir) {
40
+ try {
41
+ const data = JSON.parse(fs.readFileSync(resetPath(dir), 'utf8'));
42
+ if (!data || typeof data !== 'object' || data.version !== VERSION) return { marks: emptyMarks(), history: [] };
43
+ const marks = emptyMarks();
44
+ for (const s of SCOPES) marks[s] = markOrNull(data.marks && data.marks[s]);
45
+ const history = Array.isArray(data.history)
46
+ ? data.history.filter((h) => h && Array.isArray(h.scopes) && h.prev && typeof h.prev === 'object')
47
+ : [];
48
+ return { marks, history };
49
+ } catch {
50
+ return { marks: emptyMarks(), history: [] };
51
+ }
52
+ }
53
+
54
+ /** Atomic write: a temp file renamed over the target, so a reader never sees half a file. */
55
+ function writeState(dir, state) {
56
+ const file = resetPath(dir);
57
+ fs.mkdirSync(dir, { recursive: true });
58
+ const tmp = `${file}.${process.pid}.tmp`;
59
+ const text = JSON.stringify({ version: VERSION, marks: state.marks, history: state.history }) + '\n';
60
+ try {
61
+ fs.writeFileSync(tmp, text);
62
+ fs.renameSync(tmp, file);
63
+ } catch {
64
+ try { fs.rmSync(tmp, { force: true }); } catch { /* nothing to clean */ }
65
+ fs.writeFileSync(file, text);
66
+ }
67
+ }
68
+
69
+ /** Current marks. Never throws; a missing or corrupt file means no reset. */
70
+ function readResetMarks(dir) {
71
+ return readState(dir).marks;
72
+ }
73
+
74
+ /**
75
+ * Reset the given scopes to `now`. Returns the new marks. Unknown scopes are
76
+ * ignored; the caller validates user input.
77
+ */
78
+ function resetSaved(scopes, { dir, now = Date.now() } = {}) {
79
+ const state = readState(dir);
80
+ const valid = [...new Set(scopes)].filter((s) => SCOPES.includes(s));
81
+ if (valid.length === 0) return state.marks;
82
+ const prev = {};
83
+ for (const s of valid) {
84
+ prev[s] = state.marks[s];
85
+ state.marks[s] = now;
86
+ }
87
+ state.history.push({ at: now, scopes: valid, prev });
88
+ if (state.history.length > MAX_HISTORY) state.history.splice(0, state.history.length - MAX_HISTORY);
89
+ writeState(dir, state);
90
+ return state.marks;
91
+ }
92
+
93
+ /**
94
+ * Undo the latest reset. Returns { scopes, marks } with the restored marks,
95
+ * or null when there is nothing to undo.
96
+ */
97
+ function undoReset({ dir } = {}) {
98
+ const state = readState(dir);
99
+ const last = state.history.pop();
100
+ if (!last) return null;
101
+ for (const s of last.scopes) {
102
+ if (SCOPES.includes(s)) state.marks[s] = markOrNull(last.prev[s]);
103
+ }
104
+ writeState(dir, state);
105
+ return { scopes: last.scopes, marks: state.marks };
106
+ }
107
+
108
+ /**
109
+ * True when an event at `ts` is excluded by `mark`: a mark exists and the
110
+ * event is older than it. An event with no usable timestamp cannot be shown
111
+ * to postdate the reset, so it is excluded too.
112
+ */
113
+ function isBeforeReset(ts, mark) {
114
+ if (mark === null || mark === undefined) return false;
115
+ return !Number.isFinite(ts) || ts < mark;
116
+ }
117
+
118
+ module.exports = { SCOPES, resetPath, readResetMarks, resetSaved, undoReset, isBeforeReset };
@@ -26,6 +26,7 @@ import { readFileSync } from 'node:fs';
26
26
  import { join } from 'node:path';
27
27
  import { userDataDir } from './paths.js';
28
28
  import { writeStateFile } from './state-file.js';
29
+ import { readResetMarks, isBeforeReset } from './saved-reset.cjs';
29
30
 
30
31
  const WEEK_MS = 7 * 24 * 3600 * 1000;
31
32
  const MONTH_MS = 30 * 24 * 3600 * 1000;
@@ -140,13 +141,19 @@ export function modelFamily(model) {
140
141
  * Rolling totals: last 7 days, last 30 days, and lifetime, plus `pairs` — the
141
142
  * lifetime rollup by family-level model change, priciest first. `now` is
142
143
  * injectable for tests. Never throws — an unreadable ledger yields zeros.
144
+ *
145
+ * Events before the `routing` reset mark (`sprag saved reset`) are skipped
146
+ * everywhere, and `since` reports the mark (null when never reset). `dir` is
147
+ * the user data directory the mark is read from.
143
148
  */
144
- export function delegationSavedTotals(now = Date.now()) {
145
- const empty = () => ({ week: 0, month: 0, total: 0, pairs: [] });
149
+ export function delegationSavedTotals(now = Date.now(), dir = userDataDir()) {
150
+ const since = readResetMarks(dir).routing;
151
+ const empty = () => ({ week: 0, month: 0, total: 0, pairs: [], since });
146
152
  const totals = empty();
147
153
  const byPair = new Map();
148
154
  try {
149
155
  for (const e of Object.values(loadLedger().events)) {
156
+ if (isBeforeReset(e.ts, since)) continue;
150
157
  const usd = Number(e.usd);
151
158
  if (!Number.isFinite(usd)) continue;
152
159
  totals.total += usd;
@@ -35,7 +35,11 @@ const CACHE_PATH = join(userDataDir(), 'session-cache.json');
35
35
  // zero-token `<synthetic>` stubs no longer count as requests. A v3 entry has
36
36
  // neither, so a mixed-model session cached before the bump would go on being
37
37
  // priced at one rate until its transcript changed.
38
- const CACHE_VERSION = 4;
38
+ // 5: totals (and each modelTotals entry) may carry `long`, the part of the
39
+ // tokens from requests whose prompt exceeds 100k, which Haiku 5.5 prices at a
40
+ // higher rate. A v4 entry lacks it, so a Haiku 5.5 session cached before the
41
+ // bump would keep being priced at the short rate until its transcript changed.
42
+ const CACHE_VERSION = 5;
39
43
  // Entries for transcripts this old are pruned on write. Keeps the file
40
44
  // bounded without an existence check per entry (which would cost the syscalls
41
45
  // the cache exists to avoid).
package/src/stats.js CHANGED
@@ -10,6 +10,15 @@ function dateKey(date) {
10
10
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
11
11
  }
12
12
 
13
+ // Sum a session's optional `long` sub-totals (requests whose prompt exceeds
14
+ // 100k tokens; see parser.js) into an aggregate, creating it on first use.
15
+ function addLong(acc, totals) {
16
+ const l = totals.long;
17
+ if (!l) return;
18
+ const a = (acc.long ||= { input: 0, cacheCreation: 0, cacheRead: 0, ephemeral5m: 0, ephemeral1h: 0, output: 0 });
19
+ for (const k of Object.keys(a)) a[k] += l[k] || 0;
20
+ }
21
+
13
22
  function hitRate(read, creation, input) {
14
23
  const total = read + creation + input;
15
24
  return total > 0 ? read / total : 0;
@@ -43,6 +52,7 @@ export function dailyTrend(sessions) {
43
52
  d.cacheRead += s.totals.cacheRead;
44
53
  d.ephemeral5m += s.totals.ephemeral5m;
45
54
  d.ephemeral1h += s.totals.ephemeral1h;
55
+ addLong(d, s.totals);
46
56
  d.output += s.totals.output;
47
57
  d.apiCalls += s.requestCount;
48
58
  d.sessions += 1;
@@ -377,6 +387,7 @@ export function summary(sessions) {
377
387
  acc.cacheRead += s.totals.cacheRead;
378
388
  acc.ephemeral5m += s.totals.ephemeral5m;
379
389
  acc.ephemeral1h += s.totals.ephemeral1h;
390
+ addLong(acc, s.totals);
380
391
  acc.output += s.totals.output;
381
392
  acc.apiCalls += s.requestCount;
382
393
  acc.sessions += 1;
@@ -30,6 +30,7 @@
30
30
  import { readdir, readFile, stat } from 'node:fs/promises';
31
31
  import { join, dirname, basename } from 'node:path';
32
32
  import { collectSessionRecords, normalizeModelId } from './session-records.js';
33
+ import { LONG_PROMPT_THRESHOLD } from './cost.js';
33
34
 
34
35
  /** Directory holding a session's subagent transcripts (may not exist). */
35
36
  export function subagentDirFor(sessionPath) {
@@ -98,6 +99,17 @@ export async function collectSubagentRun(jsonlPath) {
98
99
  run.cacheRead += r.cache_read_tokens || 0;
99
100
  run.ephemeral5m += r.ephemeral5m || 0;
100
101
  run.ephemeral1h += r.ephemeral1h || 0;
102
+ // Haiku 5.5 long-prompt split (see cost.js LONG_PROMPT_THRESHOLD); only
103
+ // present once a request this large exists.
104
+ if ((r.prompt_tokens || 0) > LONG_PROMPT_THRESHOLD) {
105
+ const l = (run.long ||= { input: 0, cacheCreation: 0, cacheRead: 0, ephemeral5m: 0, ephemeral1h: 0, output: 0 });
106
+ l.input += r.input_tokens || 0;
107
+ l.cacheCreation += r.cache_creation_tokens || 0;
108
+ l.cacheRead += r.cache_read_tokens || 0;
109
+ l.ephemeral5m += r.ephemeral5m || 0;
110
+ l.ephemeral1h += r.ephemeral1h || 0;
111
+ l.output += r.completion_tokens || 0;
112
+ }
101
113
  run.toolErrors += r.toolErrors || 0;
102
114
  const model = normalizeModelId(r.model);
103
115
  byModelOut.set(model, (byModelOut.get(model) || 0) + (r.completion_tokens || 0));