sigrank 0.19.0 → 1.0.1

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 (80) hide show
  1. package/CHANGELOG.md +158 -0
  2. package/MCP_SERVER_DESCRIPTION.md +172 -0
  3. package/README.md +89 -41
  4. package/adapters/index.mjs +1100 -0
  5. package/adapters/tokenpull.mjs +568 -0
  6. package/adapters.mjs +1 -865
  7. package/analytics/cascade.mjs +338 -0
  8. package/cascade.mjs +1 -274
  9. package/cli.mjs +1 -1853
  10. package/connect.mjs +1 -13
  11. package/identity/connect.mjs +13 -0
  12. package/identity/keystore.mjs +231 -0
  13. package/identity/sign.mjs +106 -0
  14. package/index.mjs +47 -162
  15. package/keystore.mjs +1 -242
  16. package/lib/constants.mjs +137 -0
  17. package/lib/pkg-version.mjs +59 -0
  18. package/manifest.json +46 -0
  19. package/narrate.mjs +1 -77
  20. package/package.json +41 -16
  21. package/preflight.mjs +1 -191
  22. package/presentation/cli.mjs +1926 -0
  23. package/presentation/narrate.mjs +219 -0
  24. package/presentation/tui-themes.mjs +189 -0
  25. package/presentation/tui.mjs +3316 -0
  26. package/prompts/check-my-efficiency.md +1 -0
  27. package/prompts/compare-with-leader.md +1 -0
  28. package/prompts/simulate-improvement.md +1 -0
  29. package/proxy.mjs +492 -0
  30. package/resources/class-tiers.md +81 -0
  31. package/resources/data-policy.md +31 -0
  32. package/resources/install-guide.md +59 -0
  33. package/resources/privacy-model.md +44 -0
  34. package/resources/scoring-formula.md +24 -0
  35. package/review.mjs +263 -0
  36. package/sign.mjs +1 -106
  37. package/submit/index.mjs +297 -0
  38. package/submit/preflight.mjs +191 -0
  39. package/submit.mjs +1 -303
  40. package/tokenpull.mjs +1 -576
  41. package/tokscale_analytics.mjs +864 -0
  42. package/tools/_framing.mjs +217 -0
  43. package/tools/_helpers.mjs +253 -0
  44. package/tools/_peer-matching.mjs +361 -0
  45. package/tools/_report.mjs +262 -0
  46. package/tools/_schemas.mjs +492 -0
  47. package/tools/_verifiers.mjs +148 -0
  48. package/tools/compare-operators.mjs +80 -0
  49. package/tools/compare-self.mjs +124 -0
  50. package/tools/describe-power-user.mjs +78 -0
  51. package/tools/diagnose-cascade.mjs +192 -0
  52. package/tools/discover-peers.mjs +198 -0
  53. package/tools/enroll.mjs +119 -0
  54. package/tools/get-best-operator.mjs +59 -0
  55. package/tools/get-leaderboard.mjs +27 -0
  56. package/tools/get-operator.mjs +33 -0
  57. package/tools/index.mjs +229 -0
  58. package/tools/optimize-efficiency.mjs +170 -0
  59. package/tools/rank-paste.mjs +51 -0
  60. package/tools/rank-windows.mjs +120 -0
  61. package/tools/self-improve.mjs +423 -0
  62. package/tools/simulate-change.mjs +207 -0
  63. package/tools/submit-paste.mjs +121 -0
  64. package/tools/submit-verified.mjs +153 -0
  65. package/tools/suggest-improvements.mjs +168 -0
  66. package/tools/tokenpull-compare.mjs +123 -0
  67. package/tools/tokenpull-submit.mjs +235 -0
  68. package/tools/tokenpull.mjs +113 -0
  69. package/tools/tokscale-breakdown.mjs +119 -0
  70. package/tools/tokscale-competitive-intel.mjs +45 -0
  71. package/tools/tokscale-cost-analysis.mjs +31 -0
  72. package/tools/tokscale-developer-profile.mjs +36 -0
  73. package/tools/tokscale-device-profile.mjs +35 -0
  74. package/tools/tokscale-market-share.mjs +38 -0
  75. package/tools/tokscale-mcp-usage.mjs +33 -0
  76. package/tools/tokscale-model-trends.mjs +32 -0
  77. package/tools/watch-tokenpull.mjs +134 -0
  78. package/tools.mjs +1 -3897
  79. package/tui.mjs +4 -2650
  80. package/banner.svg +0 -7
package/tokenpull.mjs CHANGED
@@ -1,576 +1 @@
1
- /**
2
- * tokenpull.mjs — SigRank's in-house local usage reader.
3
- *
4
- * Reads token telemetry straight from the platform's local session logs (Claude Code
5
- * first: ~/.claude/projects/<project>/<session>.jsonl) and slices it into the four
6
- * windows (7d / 30d / 90d / all) of raw pillars — the WINDOWED_PROFILES payload, pulled
7
- * with zero paste. No ccusage, no tokscale: our own reader, our own numbers.
8
- *
9
- * Token-only: we read usage counts (input/output/cache), message id, and timestamp —
10
- * never message text. Billing-accurate: messages are deduped by `message.id` so the
11
- * tally matches what the API actually billed (the one trick token-dashboard gets right
12
- * and sigrank-agent's line-index keying does not).
13
- *
14
- * Adapter-shaped for multi-system: add a Codex / Cursor / Gemini reader by implementing
15
- * the same { platform, defaultRoot(), messages(root) } contract — Claude is just the first.
16
- */
17
-
18
- import { readdir, readFile, lstat } from "node:fs/promises";
19
- import { existsSync } from "node:fs";
20
- import { execFile } from "node:child_process";
21
- import { join, dirname } from "node:path";
22
- import { homedir } from "node:os";
23
- import { fileURLToPath } from "node:url";
24
- import { ADAPTERS } from "./adapters.mjs";
25
-
26
- const DAY_MS = 86_400_000;
27
-
28
- // Resolve the package root for finding bundled binaries (ccusage, tokscale, etc.)
29
- const _pkgRoot = join(dirname(fileURLToPath(import.meta.url)));
30
- const _localBin = join(_pkgRoot, "node_modules", ".bin");
31
- // Prepend local node_modules/.bin to PATH so bundled deps are found even when
32
- // not globally installed (e.g., npx sigrank, local dev).
33
- const _envPath = `${_localBin}${process.env.PATH ? ":" + process.env.PATH : ""}`;
34
-
35
- // ASYNC FIX (2026-06-27): execFile wrapped in a Promise — replaces execSync in the
36
- // fresh verifier readers. execSync blocks the entire Node event loop (no key handling,
37
- // no screen repaint for up to 90s during the tokendash scan). execFile is async: the
38
- // event loop keeps running, so the TUI stays responsive while external commands run.
39
- // Returns stdout as a string. Rejects on error or timeout (caller catches → null).
40
- // BIN FIX (2026-06-27): PATH includes local node_modules/.bin so bundled deps
41
- // (ccusage, tokscale) are found even when sigrank isn't globally installed.
42
- function execFileAsync(cmd, args, timeoutMs) {
43
- return new Promise((resolve, reject) => {
44
- // NOTE: execFile does not accept a `stdio` option (it always pipes + buffers
45
- // stdout/stderr against maxBuffer) — a previous `stdio` key here was silently ignored.
46
- execFile(
47
- cmd,
48
- args,
49
- {
50
- timeout: timeoutMs,
51
- maxBuffer: 10 * 1024 * 1024,
52
- env: { ...process.env, PATH: _envPath },
53
- },
54
- (err, stdout) => {
55
- if (err) reject(err);
56
- else resolve(stdout.toString());
57
- },
58
- );
59
- });
60
- }
61
-
62
- // Background tooling that runs under your account but is NOT your work — memory plugins,
63
- // observers, summarizers, sub-agent fleets. They pad output/Υ (claude-mem did ~27%).
64
- // Excluded so pillars reflect the operator, not their tools. EXTENSIBLE — add tool
65
- // dir-name patterns here. Strategic note: others' tools inflate the PUBLIC
66
- // token-dashboard / tokscale boards; SigRank filters them, so SigRank stays honest.
67
- // subagents/ are KEPT (real work). Future-robust signal: also drop entrypoint=sdk-cli.
68
- export const EXCLUDE_TOOLING =
69
- /(^|[/-])(claude-mem|mem0|claude-self-reflect|basic-memory|memento|cipher-mem|memory-keeper)\b|observer-(sessions|archive)/i;
70
-
71
- /** The four windows, in days. `all` = unbounded. */
72
- export const WINDOWS = [
73
- { key: "7d", days: 7 },
74
- { key: "30d", days: 30 },
75
- { key: "90d", days: 90 },
76
- { key: "all", days: Infinity },
77
- ];
78
-
79
- /** Hard cap: stop walking after this many .jsonl files to prevent OOM on
80
- * accidentally huge or circularly-symlinked directory trees.
81
- * Override via SIGRANK_MAX_JSONL_FILES env var. */
82
- const MAX_JSONL_FILES = Number(process.env.SIGRANK_MAX_JSONL_FILES) || 10_000;
83
-
84
- /** Recursively yield every *.jsonl path under dir (any depth), sorted. Must be
85
- * recursive: Claude Code stores sub-agent transcripts in `<project>/subagents/`
86
- * (and other nested logs) — a 2-level readdir silently drops them, which badly
87
- * under-counts input (sub-agent runs are input-heavy). token-dashboard's rglob.
88
- *
89
- * Hardened: skips symlinked directories (prevents circular traversal) and stops
90
- * after MAX_JSONL_FILES files (prevents OOM on pathological trees). */
91
- async function* _walkJsonl(dir, _counter = { n: 0 }) {
92
- if (_counter.n >= MAX_JSONL_FILES) return;
93
- let entries;
94
- try {
95
- entries = await readdir(dir, { withFileTypes: true });
96
- } catch {
97
- return;
98
- }
99
- for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
100
- if (_counter.n >= MAX_JSONL_FILES) return;
101
- const full = join(dir, e.name);
102
- if (e.isDirectory()) {
103
- // Skip symlinked directories — prevents circular traversal on machines where
104
- // ~/.claude/projects/ contains a symlink to a large or self-referential tree.
105
- let stat;
106
- try {
107
- stat = await lstat(full);
108
- } catch {
109
- continue;
110
- }
111
- if (stat.isSymbolicLink()) continue;
112
- yield* _walkJsonl(full, _counter);
113
- } else if (e.isFile() && e.name.endsWith(".jsonl")) {
114
- _counter.n++;
115
- yield full;
116
- }
117
- }
118
- }
119
-
120
- /**
121
- * Claude Code adapter. Yields one record per billed assistant message:
122
- * { id, sid, ts, input, output, cacheCreate, cacheRead, file }. Walks
123
- * ~/.claude/projects RECURSIVELY (incl. subagents/) and carries sessionId so the
124
- * caller can dedup by (session_id, message_id) like token-dashboard.
125
- */
126
- export const claudeAdapter = {
127
- platform: "claude",
128
- defaultRoot: () => join(homedir(), ".claude", "projects"),
129
- async *messages(root) {
130
- for await (const path of _walkJsonl(root)) {
131
- let text;
132
- try {
133
- text = await readFile(path, "utf8");
134
- } catch {
135
- continue;
136
- }
137
- const rel = path.startsWith(root) ? path.slice(root.length + 1) : path;
138
- if (EXCLUDE_TOOLING.test(rel)) continue; // skip claude-mem observer & other background tooling
139
- for (const line of text.split("\n")) {
140
- const s = line.trim();
141
- if (!s) continue;
142
- let ev;
143
- try {
144
- ev = JSON.parse(s);
145
- } catch {
146
- continue;
147
- }
148
- const m = ev && ev.message;
149
- if (!m || typeof m !== "object") continue;
150
- const u = m.usage;
151
- if (!u || typeof u !== "object") continue;
152
- yield {
153
- id: m.id || null,
154
- sid: ev.sessionId || null,
155
- ts: ev.timestamp || ev.ts || null,
156
- input: Number(u.input_tokens) || 0,
157
- output: Number(u.output_tokens) || 0,
158
- cacheCreate: Number(u.cache_creation_input_tokens) || 0,
159
- cacheRead: Number(u.cache_read_input_tokens) || 0,
160
- file: rel,
161
- };
162
- }
163
- }
164
- },
165
- };
166
-
167
- /**
168
- * Pull local usage and slice it into the four windows of raw pillars.
169
- * Deterministic given (adapter output, now). Inject `now`/`root`/`adapter` for tests.
170
- * Returns { platform, root, generatedAt, files, totalMessages, windows:[{window,pillars,messages}] }.
171
- */
172
- export async function tokenpull({ adapter = claudeAdapter, root, now } = {}) {
173
- const r = root || adapter.defaultRoot();
174
- const nowMs =
175
- now == null ? Date.now() : typeof now === "number" ? now : Date.parse(now);
176
-
177
- // Dedup by (session_id, message_id), keeping the FINAL snapshot — matches
178
- // token-dashboard: Claude Code writes 2-3 partial→final lines per response with
179
- // the same message.id; only the final tally matches billing. No-id records each
180
- // get a unique synthetic key so they always count.
181
- const seen = new Map();
182
- const files = new Set();
183
- let noId = 0;
184
- for await (const msg of adapter.messages(r)) {
185
- const key =
186
- msg.sid && msg.id ? `${msg.sid}|${msg.id}` : msg.id || `__noid_${noId++}`;
187
- seen.set(key, msg); // keep-last (final snapshot wins)
188
- if (msg.file) files.add(msg.file);
189
- }
190
- const msgs = [...seen.values()];
191
-
192
- const windows = WINDOWS.map((w) => {
193
- const cutoff = w.days === Infinity ? -Infinity : nowMs - w.days * DAY_MS;
194
- // NULL-TIMESTAMP WINDOW ASYMMETRY: the `all` window returns true unconditionally,
195
- // so records whose adapter cannot supply a timestamp (ts: null or unparseable)
196
- // appear in all-time but disappear from 7d/30d/90d. This is intentional — we do
197
- // not fabricate timestamps. Session-granularity adapters (goose/droid/hermes)
198
- // typically have created_at/updated_at, but if those columns are absent the row
199
- // is all-time-only. A file-mtime fallback was considered but rejected: mtime
200
- // reflects the log file, not the session, and would misattribute old sessions
201
- // to recent file-touch times.
202
- const inWin = msgs.filter((m) => {
203
- if (w.days === Infinity) return true;
204
- const t = m.ts ? Date.parse(m.ts) : NaN;
205
- return Number.isFinite(t) && t >= cutoff && t <= nowMs;
206
- });
207
- const pillars = inWin.reduce(
208
- (a, m) => ({
209
- input: a.input + m.input,
210
- output: a.output + m.output,
211
- cacheCreate: a.cacheCreate + m.cacheCreate,
212
- cacheRead: a.cacheRead + m.cacheRead,
213
- }),
214
- { input: 0, output: 0, cacheCreate: 0, cacheRead: 0 },
215
- );
216
- return { window: w.key, pillars, messages: inWin.length };
217
- });
218
-
219
- // Auto-detect background tooling we excluded → report it (the MCP "asks" by telling).
220
- let excludedTooling = [];
221
- try {
222
- const top = await readdir(r, { withFileTypes: true });
223
- excludedTooling = top
224
- .filter((d) => d.isDirectory() && EXCLUDE_TOOLING.test(d.name))
225
- .map((d) => d.name);
226
- } catch {
227
- /* ignore */
228
- }
229
-
230
- return {
231
- platform: adapter.platform,
232
- root: r,
233
- generatedAt: new Date(nowMs).toISOString(),
234
- files: files.size,
235
- totalMessages: msgs.length,
236
- excludedTooling,
237
- windows,
238
- };
239
- }
240
-
241
- // ── Codex ──────────────────────────────────────────────────────────────────
242
- // Logs at ~/.codex/sessions/**/rollout-*.jsonl (+ archived_sessions). Token usage is
243
- // on `payload.type=='token_count'` lines; `payload.info.last_token_usage` is the
244
- // per-turn delta. Codex `input_tokens` is INCLUSIVE of cached, so the combined input to
245
- // split = input_tokens − cached_input_tokens (= ccusage's inputTokens, verified ~1%).
246
- // The input/cacheCreate split is WINDOW-level (estInput = output × io_ratio), so Codex
247
- // has its own pull (tokenpullCodex) instead of the per-message claude pipeline.
248
- export const codexAdapter = {
249
- platform: "codex",
250
- defaultRoot: () => join(homedir(), ".codex"),
251
- async *records(root) {
252
- for (const sub of ["sessions", "archived_sessions"]) {
253
- for await (const path of _walkJsonl(join(root, sub))) {
254
- let text;
255
- try {
256
- text = await readFile(path, "utf8");
257
- } catch {
258
- continue;
259
- }
260
- const rel = path.startsWith(root) ? path.slice(root.length + 1) : path;
261
- // Apply the same background-tooling exclusion as the Claude adapter — skips
262
- // memory plugins / observers running under the Codex account.
263
- if (EXCLUDE_TOOLING.test(rel)) continue;
264
- for (const line of text.split("\n")) {
265
- if (!line.includes('"token_count"')) continue;
266
- let ev;
267
- try {
268
- ev = JSON.parse(line);
269
- } catch {
270
- continue;
271
- }
272
- const p = ev && ev.payload;
273
- if (!p || p.type !== "token_count") continue;
274
- const u = (p.info || {}).last_token_usage || {};
275
- const inputIncl = Number(u.input_tokens) || 0;
276
- const cached = Number(u.cached_input_tokens) || 0;
277
- yield {
278
- ts: ev.timestamp || null,
279
- output:
280
- (Number(u.output_tokens) || 0) +
281
- (Number(u.reasoning_output_tokens) || 0),
282
- cacheRead: cached,
283
- uncached: Math.max(0, inputIncl - cached), // (true input + cache-write), split window-level
284
- file: rel,
285
- };
286
- }
287
- }
288
- }
289
- },
290
- };
291
-
292
- /**
293
- * Pull local Codex usage → the 4 windows of CANONICAL pillars (always estimated).
294
- * Window-level conversion: input = floor(output × ioRatio) (Beta = operator's Claude
295
- * input/output ratio; Alpha = 2.0 default), cacheCreate = max(0, uncached − input).
296
- * Inject ioRatio/root/now/adapter for tests.
297
- */
298
- export async function tokenpullCodex({
299
- adapter = codexAdapter,
300
- root,
301
- now,
302
- ioRatio = 2.0,
303
- } = {}) {
304
- const r = root || adapter.defaultRoot();
305
- const nowMs =
306
- now == null ? Date.now() : typeof now === "number" ? now : Date.parse(now);
307
- const recs = [];
308
- const files = new Set();
309
- for await (const m of adapter.records(r)) {
310
- recs.push(m);
311
- if (m.file) files.add(m.file);
312
- }
313
-
314
- const windows = WINDOWS.map((w) => {
315
- const cutoff = w.days === Infinity ? -Infinity : nowMs - w.days * DAY_MS;
316
- // NULL-TIMESTAMP WINDOW ASYMMETRY: see tokenpull() above — same behavior.
317
- // Records without a parseable ts appear in `all` only, not in 7d/30d/90d.
318
- const inWin = recs.filter((m) => {
319
- if (w.days === Infinity) return true;
320
- const t = m.ts ? Date.parse(m.ts) : NaN;
321
- return Number.isFinite(t) && t >= cutoff && t <= nowMs;
322
- });
323
- const sum = inWin.reduce(
324
- (a, m) => ({
325
- output: a.output + m.output,
326
- cacheRead: a.cacheRead + m.cacheRead,
327
- uncached: a.uncached + m.uncached,
328
- }),
329
- { output: 0, cacheRead: 0, uncached: 0 },
330
- );
331
- const input = Math.floor(sum.output * ioRatio); // estimated true input
332
- const cacheCreate = Math.max(0, sum.uncached - input); // split cache-write out of the combined input
333
- return {
334
- window: w.key,
335
- messages: inWin.length,
336
- pillars: {
337
- input,
338
- output: sum.output,
339
- cacheCreate,
340
- cacheRead: sum.cacheRead,
341
- },
342
- };
343
- });
344
-
345
- return {
346
- platform: adapter.platform || "codex",
347
- root: r,
348
- generatedAt: new Date(nowMs).toISOString(),
349
- files: files.size,
350
- totalMessages: recs.length,
351
- estimated: true,
352
- ioRatio,
353
- windows,
354
- };
355
- }
356
-
357
- /**
358
- * Unified pull for ANY platform by name. Routes to the right adapter:
359
- * - 'claude' → tokenpull()
360
- * - 'codex' → tokenpullCodex() (requires ioRatio via opts; auto-derives from Claude if available)
361
- * - other → tokenpull() with the adapter from ADAPTERS registry
362
- *
363
- * Returns the same shape as tokenpull() with an added `estimated` flag when the
364
- * adapter cannot provide full cacheCreate data, and a `dataGap` string when the
365
- * source's log format doesn't expose token counts at all.
366
- */
367
- export async function tokenpullAny(platform, opts = {}) {
368
- if (!platform || platform === "claude")
369
- return tokenpull({ adapter: claudeAdapter, ...opts });
370
- // Codex + Devin: both combine input + cache write in input_tokens, so both go
371
- // through tokenpullCodex() which splits via ioRatio (Beta from Claude, Alpha 2.0).
372
- if (platform === "codex" || platform === "devin") {
373
- let ioRatio = opts.ioRatio || 2.0;
374
- if (!opts.ioRatio) {
375
- try {
376
- const c = await tokenpull({
377
- adapter: claudeAdapter,
378
- root: opts.root,
379
- now: opts.now,
380
- });
381
- const all = c.windows.find((w) => w.window === "all");
382
- if (all && all.pillars.output > 0)
383
- ioRatio = all.pillars.input / all.pillars.output;
384
- } catch {
385
- /* no Claude data → Alpha 2.0 */
386
- }
387
- }
388
- const adapter = platform === "codex" ? codexAdapter : ADAPTERS.devin;
389
- return tokenpullCodex({ adapter, ioRatio, ...opts });
390
- }
391
- const adapter = ADAPTERS[platform];
392
- if (!adapter)
393
- throw new Error(
394
- `Unknown platform "${platform}". Valid platforms: claude, codex, ${Object.keys(ADAPTERS).join(", ")}`,
395
- );
396
- const result = await tokenpull({ adapter, ...opts });
397
- // Surface adapter-level flags
398
- if (adapter.estimated) result.estimated = true;
399
- if (adapter.dataGap) result.dataGap = adapter.dataGap;
400
- if (adapter.setupNote) result.setupNote = adapter.setupNote;
401
- return result;
402
- }
403
-
404
- // ── Fresh verifier pull (#9 / C1) ─────────────────────────────────────────────
405
- // The owner-requested FRESH pull of the three external verifier sources. Unlike the
406
- // stale snapshot readers (a 3-day-old token-dashboard.db, a static tokscale_report.json),
407
- // these RUN the source tool on every call so the numbers are live. Token-only: each tool
408
- // reports usage counts, never transcript content. Every external call is wrapped so a
409
- // missing binary / parse error / timeout returns null and NEVER throws — a verifier the
410
- // machine doesn't have simply doesn't appear.
411
- //
412
- // Returns { ccusage, tokscale, tokendash } where each value is either null or an object
413
- // keyed by window: { '7d':{...}, '30d':{...}, '90d':{...}, all:{...} } (windows that can't
414
- // be derived from a source are omitted; 'all' is present whenever the source has data).
415
-
416
- // ccusage: run `ccusage <platform> daily --json` and bucket the daily rows into the four
417
- // windows by date. Logic copied from tools.mjs `_ccusagePillars` (do NOT import private fns).
418
- //
419
- // ASYNC FIX (2026-06-27): was execSync (blocked the entire event loop for up to 15s,
420
- // freezing the TUI locked frame + key handling). Now uses execFile (async) so the
421
- // event loop keeps running — the TUI stays responsive while the external command runs.
422
- async function _freshCcusage(platform = "claude") {
423
- try {
424
- const raw = await execFileAsync(
425
- "ccusage",
426
- [platform, "daily", "--json"],
427
- 15000,
428
- );
429
- const rows = JSON.parse(raw)?.daily ?? JSON.parse(raw);
430
- if (!Array.isArray(rows)) return null;
431
- const now = Date.now();
432
- const result = {};
433
- for (const [win, days] of Object.entries({
434
- "7d": 7,
435
- "30d": 30,
436
- "90d": 90,
437
- })) {
438
- const since = new Date(now - days * DAY_MS);
439
- let i = 0,
440
- o = 0,
441
- cw = 0,
442
- cr = 0;
443
- for (const r of rows) {
444
- if (new Date(r.date ?? r.day ?? "1970") >= since) {
445
- i += r.inputTokens ?? r.input_tokens ?? 0;
446
- o += r.outputTokens ?? r.output_tokens ?? 0;
447
- cw += r.cacheCreationTokens ?? r.cache_create_tokens ?? 0;
448
- cr += r.cacheReadTokens ?? r.cache_read_tokens ?? 0;
449
- }
450
- }
451
- result[win] = { input: i, output: o, cacheCreate: cw, cacheRead: cr };
452
- }
453
- let i = 0,
454
- o = 0,
455
- cw = 0,
456
- cr = 0;
457
- for (const r of rows) {
458
- i += r.inputTokens ?? r.input_tokens ?? 0;
459
- o += r.outputTokens ?? r.output_tokens ?? 0;
460
- cw += r.cacheCreationTokens ?? r.cache_create_tokens ?? 0;
461
- cr += r.cacheReadTokens ?? r.cache_read_tokens ?? 0;
462
- }
463
- result["all"] = { input: i, output: o, cacheCreate: cw, cacheRead: cr };
464
- return result;
465
- } catch {
466
- return null;
467
- }
468
- }
469
-
470
- // tokendash: REFRESH ~/.claude/token-dashboard.db by running the dashboard's scan, then read
471
- // the live db via sqlite3. Claude only (the db only holds Claude Code sessions). The scan is
472
- // best-effort: if it fails (tool missing, slow), we still read whatever the db currently has.
473
- //
474
- // ASYNC FIX (2026-06-27): was execSync with a 90s timeout — that blocked the ENTIRE event
475
- // loop for up to 90 seconds, freezing the TUI completely (no key handling, no frame repaint).
476
- // Now uses execFile (async) so the TUI stays responsive during the scan.
477
- async function _freshTokendash(platform = "claude") {
478
- if (platform !== "claude") return null;
479
- const dbPath = join(homedir(), ".claude", "token-dashboard.db");
480
- // Read the token-dashboard DB directly with sqlite3 (no external python scan
481
- // needed — the DB is created by Nate's token-dashboard, github.com/nateherkai/token-dashboard).
482
- // all-time only; the db doesn't expose windowing here.
483
- if (!existsSync(dbPath)) return null;
484
- try {
485
- const raw = await execFileAsync(
486
- "sqlite3",
487
- [
488
- dbPath,
489
- "SELECT SUM(input_tokens),SUM(output_tokens),SUM(cache_create_5m_tokens)+SUM(cache_create_1h_tokens),SUM(cache_read_tokens) FROM messages",
490
- ],
491
- 5000,
492
- );
493
- const [i, o, cw, cr] = raw.trim().split("|").map(Number);
494
- if (![i, o, cw, cr].some((n) => Number.isFinite(n) && n > 0)) return null;
495
- return {
496
- all: {
497
- input: i || 0,
498
- output: o || 0,
499
- cacheCreate: cw || 0,
500
- cacheRead: cr || 0,
501
- },
502
- };
503
- } catch {
504
- return null;
505
- }
506
- }
507
-
508
- // tokscale: run `tokscale models --json` (bundled as npm dep — no bunx needed)
509
- // and sum the per-(client,model) rows for the requested platform. The JSON shape
510
- // (verified live) is:
511
- // { entries: [ { client, model, input, output, cacheRead, cacheWrite, ... }, ... ], ... }
512
- // `client` maps directly to our platform name. Synthetic/unknown model rows are dropped (they
513
- // carry no real usage). all-time only — tokscale's models view is not windowed. If no row
514
- // matches the platform, return null gracefully.
515
- //
516
- // ASYNC FIX (2026-06-27): was execSync with a 60s timeout — blocked the event loop entirely.
517
- // Now uses execFile (async) so the TUI stays responsive.
518
- async function _freshTokscale(platform = "claude") {
519
- try {
520
- const raw = await execFileAsync("tokscale", ["models", "--json"], 60000);
521
- const data = JSON.parse(raw);
522
- const entries = Array.isArray(data?.entries)
523
- ? data.entries
524
- : Array.isArray(data)
525
- ? data
526
- : [];
527
- const rows = entries.filter(
528
- (e) =>
529
- e &&
530
- e.client === platform &&
531
- e.model !== "<synthetic>" &&
532
- e.model !== "unknown" &&
533
- ((Number(e.input) || 0) > 0 || (Number(e.output) || 0) > 0),
534
- );
535
- if (!rows.length) return null;
536
- const acc = rows.reduce(
537
- (a, e) => ({
538
- input: a.input + (Number(e.input) || 0),
539
- output: a.output + (Number(e.output) || 0),
540
- cacheCreate: a.cacheCreate + (Number(e.cacheWrite) || 0),
541
- cacheRead: a.cacheRead + (Number(e.cacheRead) || 0),
542
- }),
543
- { input: 0, output: 0, cacheCreate: 0, cacheRead: 0 },
544
- );
545
- return { all: acc };
546
- } catch {
547
- return null;
548
- }
549
- }
550
-
551
- /**
552
- * Fresh-pull all three external verifier sources for a platform. Each is run live (no stale
553
- * snapshot) and returned as { window → {input,output,cacheCreate,cacheRead} } or null when the
554
- * source is unavailable / has no data for the platform. Never throws — a failing source is null.
555
- *
556
- * - ccusage : `ccusage <platform> daily --json` → 7d/30d/90d/all
557
- * - tokendash: refresh + read ~/.claude/token-dashboard.db → all (claude only)
558
- * - tokscale : `bunx tokscale@latest models --json` → all
559
- */
560
- export async function freshVerifierPillars(platform = "claude") {
561
- const p = platform || "claude";
562
- // Each call is internally try/catch → null; await Promise.all of resolved values keeps the
563
- // helper async (per the contract) without any individual failure rejecting the batch.
564
- const [ccusage, tokendash, tokscale] = await Promise.all([
565
- Promise.resolve()
566
- .then(() => _freshCcusage(p))
567
- .catch(() => null),
568
- Promise.resolve()
569
- .then(() => _freshTokendash(p))
570
- .catch(() => null),
571
- Promise.resolve()
572
- .then(() => _freshTokscale(p))
573
- .catch(() => null),
574
- ]);
575
- return { ccusage, tokscale, tokendash };
576
- }
1
+ export * from './adapters/tokenpull.mjs';