sprag-cli 3.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.ko.md +637 -0
  3. package/README.md +758 -0
  4. package/bin/cli.js +801 -0
  5. package/examples/statusline-command.ps1 +43 -0
  6. package/examples/statusline-command.sh +36 -0
  7. package/package.json +62 -0
  8. package/presets/cohesion/cohesion-en.md +26 -0
  9. package/presets/doc2md/convert.py +363 -0
  10. package/presets/korean-style/LICENSE-fluent-korean +21 -0
  11. package/presets/korean-style/fluent-korean.md +52 -0
  12. package/presets/korean-style/supplement.md +93 -0
  13. package/presets/model-rules.json +115 -0
  14. package/presets/ratchet-rules.json +38 -0
  15. package/src/advice.js +564 -0
  16. package/src/agents.js +52 -0
  17. package/src/brief.js +264 -0
  18. package/src/caps-cache.js +84 -0
  19. package/src/cli-args.js +51 -0
  20. package/src/cohesion.js +70 -0
  21. package/src/commands/brief.js +31 -0
  22. package/src/commands/cohesion.js +59 -0
  23. package/src/commands/compact-window.js +93 -0
  24. package/src/commands/doc2md.js +166 -0
  25. package/src/commands/feedback.js +132 -0
  26. package/src/commands/handoff.js +33 -0
  27. package/src/commands/harness.js +459 -0
  28. package/src/commands/history.js +46 -0
  29. package/src/commands/install.js +358 -0
  30. package/src/commands/korean.js +220 -0
  31. package/src/commands/last.js +151 -0
  32. package/src/commands/mode.js +46 -0
  33. package/src/commands/route-scan.js +454 -0
  34. package/src/commands/seed.js +105 -0
  35. package/src/commands/uninstall.js +42 -0
  36. package/src/commands/update-check.js +77 -0
  37. package/src/commands/upgrade.js +68 -0
  38. package/src/compact-window.js +205 -0
  39. package/src/config.js +232 -0
  40. package/src/cost.js +253 -0
  41. package/src/debug.js +29 -0
  42. package/src/demo.js +331 -0
  43. package/src/doc2md-ledger.cjs +227 -0
  44. package/src/doc2md.cjs +997 -0
  45. package/src/fig2md-runner.cjs +21 -0
  46. package/src/fig2md.cjs +191 -0
  47. package/src/first-run-note.js +63 -0
  48. package/src/format-time.js +44 -0
  49. package/src/formatters/csv.js +8 -0
  50. package/src/formatters/json.js +3 -0
  51. package/src/formatters/statusline.js +750 -0
  52. package/src/formatters/table.js +299 -0
  53. package/src/handoff.js +161 -0
  54. package/src/harness-analyzer.cjs +264 -0
  55. package/src/harness-templates.js +153 -0
  56. package/src/harness.js +613 -0
  57. package/src/history.js +383 -0
  58. package/src/hook-manager.js +96 -0
  59. package/src/hook.cjs +196 -0
  60. package/src/installer.js +614 -0
  61. package/src/korean-lint.cjs +303 -0
  62. package/src/korean-style.js +187 -0
  63. package/src/litellm-budget.js +223 -0
  64. package/src/model-alias.js +484 -0
  65. package/src/model-rules.js +527 -0
  66. package/src/month-spend.js +47 -0
  67. package/src/parser.js +330 -0
  68. package/src/paths.js +41 -0
  69. package/src/prompt.js +52 -0
  70. package/src/route-scan.js +832 -0
  71. package/src/savings-ledger.js +137 -0
  72. package/src/seed-rules.js +280 -0
  73. package/src/session-cache.js +160 -0
  74. package/src/session-records.js +188 -0
  75. package/src/stats.js +380 -0
  76. package/src/stdin-payload.js +122 -0
  77. package/src/subagent-records.js +214 -0
  78. package/src/update-check.js +201 -0
  79. package/src/window-labels.js +64 -0
package/src/demo.js ADDED
@@ -0,0 +1,331 @@
1
+ /**
2
+ * Demo scenarios for the statusline — used for screencasts/marketing GIFs
3
+ * embedded in the GitHub README and npm page.
4
+ *
5
+ * Activated via:
6
+ * claude-token-saver --statusline --demo healthy
7
+ * claude-token-saver --statusline --demo cycle # rotates every 3s
8
+ *
9
+ * Each scenario builds the same data shape the real pipeline produces, so
10
+ * it flows through formatReport() unchanged.
11
+ */
12
+
13
+ // Default cap usage attached to every scenario so the always-on ✦ / 📅
14
+ // gauge segments render in the demo line. Individual scenarios override `caps`
15
+ // when they're meant to surface a `🚨 5H/7D` cap-warn chip.
16
+ const HEALTHY_CAPS = { fiveHour: 31, sevenDay: 10 };
17
+ const DEFAULT_MODEL = 'Opus 4.7';
18
+
19
+ const SCENARIOS = [
20
+ {
21
+ name: 'healthy',
22
+ label: '✅ Healthy baseline',
23
+ data: {
24
+ hitRate: 0.983,
25
+ pct1h: 0.95,
26
+ savings: 2123,
27
+ elapsedSec: 30,
28
+ contextSize: '200k',
29
+ ctxUsedPct: 34,
30
+ spikeChip: null,
31
+ caps: HEALTHY_CAPS,
32
+ },
33
+ },
34
+ {
35
+ name: 'low-hit',
36
+ label: '⚠ Cache miss (low hit rate)',
37
+ data: {
38
+ hitRate: 0.55,
39
+ pct1h: 0.95,
40
+ savings: 240,
41
+ elapsedSec: 30,
42
+ contextSize: '200k',
43
+ spikeChip: '⚠ Cache miss',
44
+ caps: HEALTHY_CAPS,
45
+ },
46
+ },
47
+ {
48
+ name: 'ttl-warning',
49
+ label: '⚠ TTL nearly out (yellow)',
50
+ data: {
51
+ hitRate: 0.983,
52
+ pct1h: 0.95,
53
+ savings: 2123,
54
+ elapsedSec: 3000, // ~10min remaining of 1h
55
+ contextSize: '200k',
56
+ spikeChip: null,
57
+ caps: HEALTHY_CAPS,
58
+ },
59
+ },
60
+ {
61
+ name: 'ttl-expiring',
62
+ label: '⚠ TTL almost expired (red)',
63
+ data: {
64
+ hitRate: 0.983,
65
+ pct1h: 0.95,
66
+ savings: 2123,
67
+ elapsedSec: 3360, // ~4min remaining of 1h
68
+ contextSize: '200k',
69
+ spikeChip: null,
70
+ caps: HEALTHY_CAPS,
71
+ },
72
+ },
73
+ {
74
+ name: 'ttl-expired',
75
+ label: '⚠ TTL EXPIRED',
76
+ data: {
77
+ hitRate: 0.983,
78
+ pct1h: 0.95,
79
+ savings: 2123,
80
+ elapsedSec: 4000, // past 1h
81
+ contextSize: '200k',
82
+ spikeChip: null,
83
+ caps: HEALTHY_CAPS,
84
+ },
85
+ },
86
+ {
87
+ name: '5m-bucket',
88
+ label: '⚠ 5m TTL dominant (Pro plan)',
89
+ data: {
90
+ hitRate: 0.92,
91
+ pct1h: 0.15,
92
+ pct5m: 0.85,
93
+ savings: 410,
94
+ elapsedSec: 60,
95
+ contextSize: '200k',
96
+ spikeChip: '⚠ 5m TTL',
97
+ caps: HEALTHY_CAPS,
98
+ },
99
+ },
100
+ {
101
+ name: 'ctx-1m',
102
+ label: '⚠ Context past 500k',
103
+ data: {
104
+ hitRate: 0.78,
105
+ pct1h: 0.92,
106
+ savings: 1340,
107
+ elapsedSec: 30,
108
+ contextSize: '1M',
109
+ ctxOverWarn: true,
110
+ ctxUsedPct: 62, // 62% of 1M ≈ 620k actually in context
111
+ spikeChip: '⚠ Ctx 500k+',
112
+ caps: HEALTHY_CAPS,
113
+ },
114
+ },
115
+ {
116
+ name: 'spike-input',
117
+ label: '⚠ Input spike',
118
+ data: {
119
+ hitRate: 0.91,
120
+ pct1h: 0.95,
121
+ savings: 1820,
122
+ elapsedSec: 30,
123
+ contextSize: '200k',
124
+ spikeChip: '⚠ Input spike',
125
+ caps: HEALTHY_CAPS,
126
+ },
127
+ },
128
+ {
129
+ name: 'spike-rebuild',
130
+ label: '⚠ Cache rebuild churn',
131
+ data: {
132
+ hitRate: 0.62,
133
+ pct1h: 0.85,
134
+ savings: 220,
135
+ elapsedSec: 30,
136
+ contextSize: '200k',
137
+ spikeChip: '⚠ Rebuild churn',
138
+ caps: HEALTHY_CAPS,
139
+ },
140
+ },
141
+ {
142
+ name: 'spike-output',
143
+ label: '⚠ Output ratio high',
144
+ data: {
145
+ hitRate: 0.94,
146
+ pct1h: 0.95,
147
+ savings: 1450,
148
+ elapsedSec: 30,
149
+ contextSize: '200k',
150
+ spikeChip: '⚠ Output heavy',
151
+ caps: HEALTHY_CAPS,
152
+ },
153
+ },
154
+ {
155
+ name: 'spike-calls',
156
+ label: '⚠ Request count surge',
157
+ data: {
158
+ hitRate: 0.93,
159
+ pct1h: 0.92,
160
+ savings: 980,
161
+ elapsedSec: 30,
162
+ contextSize: '200k',
163
+ spikeChip: '⚠ Call surge',
164
+ caps: HEALTHY_CAPS,
165
+ },
166
+ },
167
+ {
168
+ name: '5h-cap-warn',
169
+ label: '🚨 5H cap 94% (imminent block)',
170
+ data: {
171
+ hitRate: 0.93,
172
+ pct1h: 0.95,
173
+ savings: 1840,
174
+ elapsedSec: 30,
175
+ contextSize: '200k',
176
+ spikeChip: null,
177
+ caps: { fiveHour: 94, sevenDay: 12 },
178
+ },
179
+ },
180
+ {
181
+ name: '7d-cap-warn',
182
+ label: '🚨 7D cap 92% (week pacing)',
183
+ data: {
184
+ hitRate: 0.91,
185
+ pct1h: 0.92,
186
+ savings: 4200,
187
+ elapsedSec: 30,
188
+ contextSize: '200k',
189
+ spikeChip: null,
190
+ caps: { fiveHour: 35, sevenDay: 92 },
191
+ },
192
+ },
193
+ {
194
+ name: 'caps-yellow',
195
+ label: '⚠ 5H 78% / 7D 70% (heads-up)',
196
+ data: {
197
+ hitRate: 0.95,
198
+ pct1h: 0.95,
199
+ savings: 2680,
200
+ elapsedSec: 30,
201
+ contextSize: '200k',
202
+ spikeChip: null,
203
+ caps: { fiveHour: 78, sevenDay: 70 },
204
+ },
205
+ },
206
+ ];
207
+
208
+ export function listScenarios() {
209
+ return SCENARIOS.map((s) => ({ name: s.name, label: s.label }));
210
+ }
211
+
212
+ /**
213
+ * Synthetic spike-report data for `--demo table` — exercises every issue
214
+ * code so the drill-down section renders all six advice blocks. Used for
215
+ * marketing recordings of the table view.
216
+ */
217
+ export function buildTableDemoData(options = {}) {
218
+ const issueCodes = [
219
+ 'LARGE_INPUT_PER_REQUEST',
220
+ 'LOW_HIT_RATE',
221
+ 'BUCKET_5M_DOMINANT',
222
+ 'HIGH_OUTPUT_RATIO',
223
+ 'HIGH_REQUEST_COUNT',
224
+ 'FREQUENT_CACHE_REBUILD',
225
+ ];
226
+
227
+ const spikes = issueCodes.map((code, i) => ({
228
+ metrics: {
229
+ sessionId: `demo${String(i).padStart(4, '0')}-aaaa-bbbb`,
230
+ projectDir: ['ai-pipeline', 'frontend', 'data-eng', 'infra', 'docs-site', 'scratch'][i],
231
+ totalInput: [3_200_000, 850_000, 1_100_000, 620_000, 2_400_000, 740_000][i],
232
+ requestCount: [42, 128, 91, 67, 310, 58][i],
233
+ maxContextPerRequest: [280_000, 175_000, 195_000, 90_000, 145_000, 130_000][i],
234
+ },
235
+ ratio: [3.4, 2.1, 2.6, 1.9, 4.8, 2.3][i],
236
+ issues: [{ code }],
237
+ }));
238
+
239
+ return {
240
+ summary: {
241
+ sessions: 12,
242
+ apiCalls: 1247,
243
+ hitRate: 0.812,
244
+ totalInput: 9_540_000_000,
245
+ },
246
+ trend: [
247
+ { date: '2026-04-23', hitRate: 0.94, calls: 312, totalRead: 2.1e8, totalWrite: 8.2e6, pct5m: 0.08 },
248
+ { date: '2026-04-24', hitRate: 0.78, calls: 488, totalRead: 1.4e8, totalWrite: 1.5e7, pct5m: 0.42 },
249
+ { date: '2026-04-25', hitRate: 0.71, calls: 447, totalRead: 9.8e7, totalWrite: 2.1e7, pct5m: 0.61 },
250
+ ],
251
+ ttl: {
252
+ ephemeral5m: 1.5e7,
253
+ ephemeral1h: 2.4e7,
254
+ total: 3.9e7,
255
+ pct5m: 0.38,
256
+ pct1h: 0.62,
257
+ },
258
+ anomalies: [],
259
+ cost: {
260
+ tier: 'claude-opus-new',
261
+ actual: 487.32,
262
+ noCacheCost: 2143.91,
263
+ savings: 1656.59,
264
+ savingsRate: 0.773,
265
+ scenario5mCost: 612.04,
266
+ extraCostIf5m: 124.72,
267
+ },
268
+ options: {
269
+ days: options.days ?? 7,
270
+ windowHours: options.windowHours ?? 168,
271
+ windowLabel: options.windowLabel ?? '7d',
272
+ version: options.version ?? '',
273
+ },
274
+ spikeReport: { spikes, baseline: { p95: 940_000 } },
275
+ contextWindow: { size: '1M', maxContext: 620_000, overWarn: true },
276
+ lastActivity: Date.now() - 60 * 1000,
277
+ spikeChip: '⚠ Ctx 500k+',
278
+ };
279
+ }
280
+
281
+
282
+ // Build the rate-limit caps shape that statusline.js consumes. Reset times
283
+ // are anchored to "wall-clock-ish" offsets so the cycle reads stable: 5H
284
+ // resets ~2h out, 7D resets a few days out — matches what real /usage shows.
285
+ function buildCapsShape(caps) {
286
+ if (!caps) return null;
287
+ const nowSec = Math.floor(Date.now() / 1000);
288
+ return {
289
+ windows: [
290
+ { key: 'five_hour', usedPct: caps.fiveHour, resetsAt: nowSec + 60 * 130 }, // ~2h10m
291
+ { key: 'seven_day', usedPct: caps.sevenDay, resetsAt: nowSec + 60 * 60 * 76 }, // ~3d4h
292
+ ],
293
+ };
294
+ }
295
+
296
+ export function buildScenarioData(scenarioName, options) {
297
+ let scenario;
298
+ if (scenarioName === 'cycle') {
299
+ // Bucket Date.now() into N-second slots, rotate through scenarios.
300
+ const slot = Math.floor(Date.now() / (options.cycleSeconds * 1000)) % SCENARIOS.length;
301
+ scenario = SCENARIOS[slot];
302
+ } else {
303
+ scenario = SCENARIOS.find((s) => s.name === scenarioName);
304
+ if (!scenario) return null;
305
+ }
306
+
307
+ const { hitRate, pct1h, pct5m, savings, elapsedSec, contextSize, ctxOverWarn, ctxUsedPct, spikeChip, caps } = scenario.data;
308
+ return {
309
+ summary: { hitRate },
310
+ ttl: { pct1h, pct5m: pct5m ?? (1 - pct1h) },
311
+ cost: { savings },
312
+ options: {
313
+ days: options.days ?? 1,
314
+ windowHours: options.windowHours ?? 24,
315
+ windowLabel: options.windowLabel ?? '1d',
316
+ version: options.version ?? '',
317
+ },
318
+ lastActivity: Date.now() - elapsedSec * 1000,
319
+ contextWindow: { size: contextSize, overWarn: Boolean(ctxOverWarn) },
320
+ // Live fill level (`📦 68%`) — scenarios that set ctxUsedPct exercise the
321
+ // stdin-driven segment; the rest fall back to the size-based chip.
322
+ ctxLive: ctxUsedPct != null
323
+ ? { usedPct: ctxUsedPct, size: contextSize === '1M' ? 1_000_000 : 200_000 }
324
+ : undefined,
325
+ spikeChip,
326
+ caps: buildCapsShape(caps),
327
+ model: DEFAULT_MODEL,
328
+ _demoLabel: scenario.label,
329
+ _demoName: scenario.name,
330
+ };
331
+ }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * doc2md-ledger — one event per document conversion, with what it saved.
3
+ *
4
+ * Separate from delegation-ledger.json on purpose. Routing savings and
5
+ * conversion savings answer different questions ("work ran on a cheaper
6
+ * model" vs "a document was read as text instead of as an attachment"), and
7
+ * a statusline that folds them into one figure cannot tell the reader which
8
+ * habit earned the money.
9
+ *
10
+ * File: <userDataDir>/doc2md-ledger.json
11
+ * { "version": 1,
12
+ * "events": { "<source path>": { "ts", "usd", "ext", "tokens", "baseline" } } }
13
+ *
14
+ * Keyed by the source path so re-converting the same document after an edit
15
+ * updates its event instead of counting the file twice.
16
+ *
17
+ * # What "saved" means here, and what it deliberately does not
18
+ *
19
+ * The counterfactual is what the reader would have done without a converter,
20
+ * and it differs by format. Both were measured on 2026-09-06.
21
+ *
22
+ * PDF: attaching the file. The same one-line prompt was sent through
23
+ * `claude --print --input-format stream-json` with and without the file as a
24
+ * document block. The control turn cost 42,204 tokens, twice, to the token.
25
+ *
26
+ * kohjuho_resume_kr.pdf 7 pages +20,537 tokens 2,934 per page
27
+ * xeoyoung_resume.pdf 5 pages +12,709 tokens 2,542 per page
28
+ *
29
+ * A PDF is read whole: the model answered from its contents. Converting one
30
+ * to text is worth three to four times its own size.
31
+ *
32
+ * pptx/xlsx/docx: unpacking the container. These never reach the model as
33
+ * attachments at all — the same probe on a docx added 78 tokens and the model
34
+ * replied that it had no file. Read refuses them too, measured the same way:
35
+ * a Read of the 31.8MB deck cost +317 tokens and of the 185KB docx +185, which
36
+ * is a refusal message and nothing more. What a reader does instead is unzip
37
+ * the archive and wade through its XML, where tags and style attributes
38
+ * outweigh the text many times over:
39
+ *
40
+ * aws-summit-seoul.pptx 2.1MB of slide XML ~540,429 tokens 23.8× the conversion
41
+ * 우리은행이력서.docx 312KB of body XML ~78,113 tokens ~46× the conversion
42
+ *
43
+ * So the baseline for these formats is the body markup the converter read,
44
+ * measured per file rather than assumed from a ratio. It is a real number for
45
+ * a real fallback — this very session unzipped a pptx to verify a conversion
46
+ * before this ledger existed.
47
+ *
48
+ * .fig: the file itself. Unlike the Office formats, Read does not refuse a
49
+ * .fig — the extension means nothing to it, so it pulls the binary in as text
50
+ * and the context window fills with tokenised noise. Measured against the same
51
+ * 42,760-token control:
52
+ *
53
+ * plan.fig 26KB +44,195 tokens conversion: 100
54
+ * bootstrap-kit.fig 8.1MB +43,994 tokens conversion: 18,397
55
+ *
56
+ * Two files three hundred times apart in size cost the same, because Read
57
+ * truncates at a cap long before the file ends — which also means the reader
58
+ * gets a fraction of a document for the price of a whole one. The baseline is
59
+ * therefore a flat 44,000 tokens rather than anything per-byte. An earlier
60
+ * version of this file claimed .fig had no measurable baseline at all; that
61
+ * was an assumption about Read's behaviour that turned out to be wrong.
62
+ *
63
+ * Erring low is deliberate throughout. A savings figure that flatters the
64
+ * tool is worth less than one the user can trust.
65
+ *
66
+ * `scripts/doc2md-baseline.mjs` re-measures the attachment side if the
67
+ * client's handling changes.
68
+ */
69
+
70
+ const fs = require('node:fs');
71
+ const path = require('node:path');
72
+
73
+ const WEEK_MS = 7 * 24 * 3600 * 1000;
74
+ const MONTH_MS = 30 * 24 * 3600 * 1000;
75
+
76
+ const LEDGER_VERSION = 1;
77
+
78
+ /**
79
+ * Tokens an attached PDF costs per page, from the two measurements in the
80
+ * header: 2,934 and 2,542 per page. 2,500 sits below both, so the saving is
81
+ * understated for a dense document rather than overstated for a sparse one.
82
+ */
83
+ const PDF_TOKENS_PER_PAGE = 2500;
84
+
85
+ /**
86
+ * How each format's alternative is priced. `perPage` values an attached
87
+ * page-image document; `markup` values the body XML a reader would have had
88
+ * to wade through instead.
89
+ *
90
+ * `.xls` is the pre-2007 binary format, which is not a zip container and so
91
+ * has no markup to measure. It falls back to parity, recording no saving.
92
+ */
93
+ const ATTACHMENT_BASELINE = {
94
+ '.pdf': { perPage: PDF_TOKENS_PER_PAGE },
95
+ '.pptx': { markup: true },
96
+ '.docx': { markup: true },
97
+ '.xlsx': { markup: true },
98
+ '.xls': { ratio: 1 },
99
+ // Read swallows a .fig instead of refusing it, at a flat ~44,000 tokens
100
+ // whatever the file's size (see the header). Rounded down from the two
101
+ // measurements, both of which landed just under 44,200.
102
+ '.fig': { fixed: 44_000 },
103
+ };
104
+
105
+ /**
106
+ * Input price per token used to value the difference, in USD. Sonnet's input
107
+ * rate, chosen as the mid tier: crediting the conversion at Opus rates would
108
+ * quietly triple every figure for anyone who never runs Opus.
109
+ */
110
+ const INPUT_USD_PER_TOKEN = 3 / 1_000_000;
111
+
112
+ /** Rough token count for text. Four bytes per token, the usual approximation. */
113
+ function estimateTokens(text) {
114
+ return Math.ceil(Buffer.byteLength(String(text || ''), 'utf8') / 4);
115
+ }
116
+
117
+ /**
118
+ * What the conversion saved, in USD, and the two token figures behind it.
119
+ * `meta` is the conversion metadata: `pages` for PDFs, plus the markdown that
120
+ * was written.
121
+ */
122
+ function estimateSaving({ ext, pages = 0, markupBytes = 0, markdown = '' }) {
123
+ const tokens = estimateTokens(markdown);
124
+ const rule = ATTACHMENT_BASELINE[String(ext).toLowerCase()] || { ratio: 1 };
125
+ let baseline;
126
+ if (rule.fixed) {
127
+ baseline = rule.fixed;
128
+ } else if (rule.perPage && pages > 0) {
129
+ baseline = pages * rule.perPage;
130
+ } else if (rule.markup && markupBytes > 0) {
131
+ baseline = Math.ceil(markupBytes / 4);
132
+ } else {
133
+ baseline = Math.round(tokens * (rule.ratio || 1));
134
+ }
135
+ // Never below what the conversion actually produced. A dense PDF can cost
136
+ // more as text than its page count suggests, and a baseline under the real
137
+ // figure would show as a zero saving while understating the document.
138
+ baseline = Math.max(baseline, tokens);
139
+ const usd = Math.max(0, baseline - tokens) * INPUT_USD_PER_TOKEN;
140
+ return { tokens, baseline, usd: Math.round(usd * 10000) / 10000 };
141
+ }
142
+
143
+ function ledgerPath(userDataDir) {
144
+ return path.join(userDataDir, 'doc2md-ledger.json');
145
+ }
146
+
147
+ function loadLedger(userDataDir) {
148
+ try {
149
+ const data = JSON.parse(fs.readFileSync(ledgerPath(userDataDir), 'utf8'));
150
+ if (!data || typeof data.events !== 'object' || data.events === null) {
151
+ return { version: LEDGER_VERSION, events: {} };
152
+ }
153
+ if (data.version !== LEDGER_VERSION) return { version: LEDGER_VERSION, events: {} };
154
+ return data;
155
+ } catch {
156
+ return { version: LEDGER_VERSION, events: {} };
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Record one conversion. Never throws: an unwritable ledger costs a
162
+ * statusline figure, which is not worth failing a conversion over.
163
+ */
164
+ function recordConversion(userDataDir, event) {
165
+ if (!event || !event.key) return;
166
+ const data = loadLedger(userDataDir);
167
+ data.version = LEDGER_VERSION;
168
+ data.events[event.key] = {
169
+ ts: Number.isFinite(event.ts) ? event.ts : Date.now(),
170
+ usd: Math.max(0, Math.round((Number(event.usd) || 0) * 10000) / 10000),
171
+ ext: event.ext || '',
172
+ tokens: Number(event.tokens) || 0,
173
+ baseline: Number(event.baseline) || 0,
174
+ };
175
+ try {
176
+ fs.mkdirSync(userDataDir, { recursive: true });
177
+ fs.writeFileSync(ledgerPath(userDataDir), JSON.stringify(data) + '\n', { mode: 0o600 });
178
+ } catch {
179
+ /* best effort, like every other state file here */
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Rolling totals plus `docs` (documents converted, lifetime) and `byExt` —
185
+ * the lifetime rollup per format, priciest first, then most-converted. Never
186
+ * throws; an unreadable ledger yields zeros, and the statusline hides the
187
+ * chip on a zero.
188
+ */
189
+ function doc2mdSavedTotals(userDataDir, now = Date.now()) {
190
+ const empty = () => ({ week: 0, month: 0, total: 0, docs: 0, tokens: 0, byExt: [] });
191
+ const totals = empty();
192
+ const byExt = new Map();
193
+ try {
194
+ for (const e of Object.values(loadLedger(userDataDir).events)) {
195
+ const usd = Number(e.usd) || 0;
196
+ totals.total += usd;
197
+ totals.docs += 1;
198
+ totals.tokens += Number(e.tokens) || 0;
199
+ if (Number.isFinite(e.ts)) {
200
+ if (now - e.ts <= WEEK_MS) totals.week += usd;
201
+ if (now - e.ts <= MONTH_MS) totals.month += usd;
202
+ }
203
+ const key = String(e.ext || '?').replace(/^\./, '') || '?';
204
+ const row = byExt.get(key) || { ext: key, docs: 0, usd: 0 };
205
+ row.docs += 1;
206
+ row.usd += usd;
207
+ byExt.set(key, row);
208
+ }
209
+ } catch {
210
+ return empty();
211
+ }
212
+ totals.byExt = [...byExt.values()].sort((a, b) => b.usd - a.usd || b.docs - a.docs);
213
+ return totals;
214
+ }
215
+
216
+ module.exports = {
217
+ LEDGER_VERSION,
218
+ PDF_TOKENS_PER_PAGE,
219
+ ATTACHMENT_BASELINE,
220
+ INPUT_USD_PER_TOKEN,
221
+ estimateTokens,
222
+ estimateSaving,
223
+ ledgerPath,
224
+ loadLedger,
225
+ recordConversion,
226
+ doc2mdSavedTotals,
227
+ };