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.
- package/LICENSE +21 -0
- package/README.ko.md +637 -0
- package/README.md +758 -0
- package/bin/cli.js +801 -0
- package/examples/statusline-command.ps1 +43 -0
- package/examples/statusline-command.sh +36 -0
- package/package.json +62 -0
- package/presets/cohesion/cohesion-en.md +26 -0
- package/presets/doc2md/convert.py +363 -0
- package/presets/korean-style/LICENSE-fluent-korean +21 -0
- package/presets/korean-style/fluent-korean.md +52 -0
- package/presets/korean-style/supplement.md +93 -0
- package/presets/model-rules.json +115 -0
- package/presets/ratchet-rules.json +38 -0
- package/src/advice.js +564 -0
- package/src/agents.js +52 -0
- package/src/brief.js +264 -0
- package/src/caps-cache.js +84 -0
- package/src/cli-args.js +51 -0
- package/src/cohesion.js +70 -0
- package/src/commands/brief.js +31 -0
- package/src/commands/cohesion.js +59 -0
- package/src/commands/compact-window.js +93 -0
- package/src/commands/doc2md.js +166 -0
- package/src/commands/feedback.js +132 -0
- package/src/commands/handoff.js +33 -0
- package/src/commands/harness.js +459 -0
- package/src/commands/history.js +46 -0
- package/src/commands/install.js +358 -0
- package/src/commands/korean.js +220 -0
- package/src/commands/last.js +151 -0
- package/src/commands/mode.js +46 -0
- package/src/commands/route-scan.js +454 -0
- package/src/commands/seed.js +105 -0
- package/src/commands/uninstall.js +42 -0
- package/src/commands/update-check.js +77 -0
- package/src/commands/upgrade.js +68 -0
- package/src/compact-window.js +205 -0
- package/src/config.js +232 -0
- package/src/cost.js +253 -0
- package/src/debug.js +29 -0
- package/src/demo.js +331 -0
- package/src/doc2md-ledger.cjs +227 -0
- package/src/doc2md.cjs +997 -0
- package/src/fig2md-runner.cjs +21 -0
- package/src/fig2md.cjs +191 -0
- package/src/first-run-note.js +63 -0
- package/src/format-time.js +44 -0
- package/src/formatters/csv.js +8 -0
- package/src/formatters/json.js +3 -0
- package/src/formatters/statusline.js +750 -0
- package/src/formatters/table.js +299 -0
- package/src/handoff.js +161 -0
- package/src/harness-analyzer.cjs +264 -0
- package/src/harness-templates.js +153 -0
- package/src/harness.js +613 -0
- package/src/history.js +383 -0
- package/src/hook-manager.js +96 -0
- package/src/hook.cjs +196 -0
- package/src/installer.js +614 -0
- package/src/korean-lint.cjs +303 -0
- package/src/korean-style.js +187 -0
- package/src/litellm-budget.js +223 -0
- package/src/model-alias.js +484 -0
- package/src/model-rules.js +527 -0
- package/src/month-spend.js +47 -0
- package/src/parser.js +330 -0
- package/src/paths.js +41 -0
- package/src/prompt.js +52 -0
- package/src/route-scan.js +832 -0
- package/src/savings-ledger.js +137 -0
- package/src/seed-rules.js +280 -0
- package/src/session-cache.js +160 -0
- package/src/session-records.js +188 -0
- package/src/stats.js +380 -0
- package/src/stdin-payload.js +122 -0
- package/src/subagent-records.js +214 -0
- package/src/update-check.js +201 -0
- 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
|
+
};
|