sigrank 0.16.0 → 0.17.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/tokenpull.mjs CHANGED
@@ -15,22 +15,22 @@
15
15
  * the same { platform, defaultRoot(), messages(root) } contract — Claude is just the first.
16
16
  */
17
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'
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
25
 
26
- const DAY_MS = 86_400_000
26
+ const DAY_MS = 86_400_000;
27
27
 
28
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')
29
+ const _pkgRoot = join(dirname(fileURLToPath(import.meta.url)));
30
+ const _localBin = join(_pkgRoot, "node_modules", ".bin");
31
31
  // Prepend local node_modules/.bin to PATH so bundled deps are found even when
32
32
  // not globally installed (e.g., npx sigrank, local dev).
33
- const _envPath = `${_localBin}${process.env.PATH ? ':' + process.env.PATH : ''}`
33
+ const _envPath = `${_localBin}${process.env.PATH ? ":" + process.env.PATH : ""}`;
34
34
 
35
35
  // ASYNC FIX (2026-06-27): execFile wrapped in a Promise — replaces execSync in the
36
36
  // fresh verifier readers. execSync blocks the entire Node event loop (no key handling,
@@ -43,11 +43,20 @@ function execFileAsync(cmd, args, timeoutMs) {
43
43
  return new Promise((resolve, reject) => {
44
44
  // NOTE: execFile does not accept a `stdio` option (it always pipes + buffers
45
45
  // stdout/stderr against maxBuffer) — a previous `stdio` key here was silently ignored.
46
- execFile(cmd, args, { timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024, env: { ...process.env, PATH: _envPath } }, (err, stdout) => {
47
- if (err) reject(err)
48
- else resolve(stdout.toString())
49
- })
50
- })
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
+ });
51
60
  }
52
61
 
53
62
  // Background tooling that runs under your account but is NOT your work — memory plugins,
@@ -57,20 +66,20 @@ function execFileAsync(cmd, args, timeoutMs) {
57
66
  // token-dashboard / tokscale boards; SigRank filters them, so SigRank stays honest.
58
67
  // subagents/ are KEPT (real work). Future-robust signal: also drop entrypoint=sdk-cli.
59
68
  export const EXCLUDE_TOOLING =
60
- /(^|[/-])(claude-mem|mem0|claude-self-reflect|basic-memory|memento|cipher-mem|memory-keeper)\b|observer-(sessions|archive)/i
69
+ /(^|[/-])(claude-mem|mem0|claude-self-reflect|basic-memory|memento|cipher-mem|memory-keeper)\b|observer-(sessions|archive)/i;
61
70
 
62
71
  /** The four windows, in days. `all` = unbounded. */
63
72
  export const WINDOWS = [
64
- { key: '7d', days: 7 },
65
- { key: '30d', days: 30 },
66
- { key: '90d', days: 90 },
67
- { key: 'all', days: Infinity },
68
- ]
73
+ { key: "7d", days: 7 },
74
+ { key: "30d", days: 30 },
75
+ { key: "90d", days: 90 },
76
+ { key: "all", days: Infinity },
77
+ ];
69
78
 
70
79
  /** Hard cap: stop walking after this many .jsonl files to prevent OOM on
71
80
  * accidentally huge or circularly-symlinked directory trees.
72
81
  * Override via SIGRANK_MAX_JSONL_FILES env var. */
73
- const MAX_JSONL_FILES = Number(process.env.SIGRANK_MAX_JSONL_FILES) || 10_000
82
+ const MAX_JSONL_FILES = Number(process.env.SIGRANK_MAX_JSONL_FILES) || 10_000;
74
83
 
75
84
  /** Recursively yield every *.jsonl path under dir (any depth), sorted. Must be
76
85
  * recursive: Claude Code stores sub-agent transcripts in `<project>/subagents/`
@@ -80,22 +89,30 @@ const MAX_JSONL_FILES = Number(process.env.SIGRANK_MAX_JSONL_FILES) || 10_000
80
89
  * Hardened: skips symlinked directories (prevents circular traversal) and stops
81
90
  * after MAX_JSONL_FILES files (prevents OOM on pathological trees). */
82
91
  async function* _walkJsonl(dir, _counter = { n: 0 }) {
83
- if (_counter.n >= MAX_JSONL_FILES) return
84
- let entries
85
- try { entries = await readdir(dir, { withFileTypes: true }) } catch { return }
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
+ }
86
99
  for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
87
- if (_counter.n >= MAX_JSONL_FILES) return
88
- const full = join(dir, e.name)
100
+ if (_counter.n >= MAX_JSONL_FILES) return;
101
+ const full = join(dir, e.name);
89
102
  if (e.isDirectory()) {
90
103
  // Skip symlinked directories — prevents circular traversal on machines where
91
104
  // ~/.claude/projects/ contains a symlink to a large or self-referential tree.
92
- let stat
93
- try { stat = await lstat(full) } catch { continue }
94
- if (stat.isSymbolicLink()) continue
95
- yield* _walkJsonl(full, _counter)
96
- } else if (e.isFile() && e.name.endsWith('.jsonl')) {
97
- _counter.n++
98
- yield full
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;
99
116
  }
100
117
  }
101
118
  }
@@ -107,23 +124,31 @@ async function* _walkJsonl(dir, _counter = { n: 0 }) {
107
124
  * caller can dedup by (session_id, message_id) like token-dashboard.
108
125
  */
109
126
  export const claudeAdapter = {
110
- platform: 'claude',
111
- defaultRoot: () => join(homedir(), '.claude', 'projects'),
127
+ platform: "claude",
128
+ defaultRoot: () => join(homedir(), ".claude", "projects"),
112
129
  async *messages(root) {
113
130
  for await (const path of _walkJsonl(root)) {
114
- let text
115
- try { text = await readFile(path, 'utf8') } catch { continue }
116
- const rel = path.startsWith(root) ? path.slice(root.length + 1) : path
117
- if (EXCLUDE_TOOLING.test(rel)) continue // skip claude-mem observer & other background tooling
118
- for (const line of text.split('\n')) {
119
- const s = line.trim()
120
- if (!s) continue
121
- let ev
122
- try { ev = JSON.parse(s) } catch { continue }
123
- const m = ev && ev.message
124
- if (!m || typeof m !== 'object') continue
125
- const u = m.usage
126
- if (!u || typeof u !== 'object') continue
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;
127
152
  yield {
128
153
  id: m.id || null,
129
154
  sid: ev.sessionId || null,
@@ -133,11 +158,11 @@ export const claudeAdapter = {
133
158
  cacheCreate: Number(u.cache_creation_input_tokens) || 0,
134
159
  cacheRead: Number(u.cache_read_input_tokens) || 0,
135
160
  file: rel,
136
- }
161
+ };
137
162
  }
138
163
  }
139
164
  },
140
- }
165
+ };
141
166
 
142
167
  /**
143
168
  * Pull local usage and slice it into the four windows of raw pillars.
@@ -145,25 +170,27 @@ export const claudeAdapter = {
145
170
  * Returns { platform, root, generatedAt, files, totalMessages, windows:[{window,pillars,messages}] }.
146
171
  */
147
172
  export async function tokenpull({ adapter = claudeAdapter, root, now } = {}) {
148
- const r = root || adapter.defaultRoot()
149
- const nowMs = now == null ? Date.now() : (typeof now === 'number' ? now : Date.parse(now))
173
+ const r = root || adapter.defaultRoot();
174
+ const nowMs =
175
+ now == null ? Date.now() : typeof now === "number" ? now : Date.parse(now);
150
176
 
151
177
  // Dedup by (session_id, message_id), keeping the FINAL snapshot — matches
152
178
  // token-dashboard: Claude Code writes 2-3 partial→final lines per response with
153
179
  // the same message.id; only the final tally matches billing. No-id records each
154
180
  // get a unique synthetic key so they always count.
155
- const seen = new Map()
156
- const files = new Set()
157
- let noId = 0
181
+ const seen = new Map();
182
+ const files = new Set();
183
+ let noId = 0;
158
184
  for await (const msg of adapter.messages(r)) {
159
- const key = msg.sid && msg.id ? `${msg.sid}|${msg.id}` : (msg.id || `__noid_${noId++}`)
160
- seen.set(key, msg) // keep-last (final snapshot wins)
161
- if (msg.file) files.add(msg.file)
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);
162
189
  }
163
- const msgs = [...seen.values()]
190
+ const msgs = [...seen.values()];
164
191
 
165
192
  const windows = WINDOWS.map((w) => {
166
- const cutoff = w.days === Infinity ? -Infinity : nowMs - w.days * DAY_MS
193
+ const cutoff = w.days === Infinity ? -Infinity : nowMs - w.days * DAY_MS;
167
194
  // NULL-TIMESTAMP WINDOW ASYMMETRY: the `all` window returns true unconditionally,
168
195
  // so records whose adapter cannot supply a timestamp (ts: null or unparseable)
169
196
  // appear in all-time but disappear from 7d/30d/90d. This is intentional — we do
@@ -173,23 +200,32 @@ export async function tokenpull({ adapter = claudeAdapter, root, now } = {}) {
173
200
  // reflects the log file, not the session, and would misattribute old sessions
174
201
  // to recent file-touch times.
175
202
  const inWin = msgs.filter((m) => {
176
- if (w.days === Infinity) return true
177
- const t = m.ts ? Date.parse(m.ts) : NaN
178
- return Number.isFinite(t) && t >= cutoff && t <= nowMs
179
- })
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
+ });
180
207
  const pillars = inWin.reduce(
181
- (a, m) => ({ input: a.input + m.input, output: a.output + m.output, cacheCreate: a.cacheCreate + m.cacheCreate, cacheRead: a.cacheRead + m.cacheRead }),
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
+ }),
182
214
  { input: 0, output: 0, cacheCreate: 0, cacheRead: 0 },
183
- )
184
- return { window: w.key, pillars, messages: inWin.length }
185
- })
215
+ );
216
+ return { window: w.key, pillars, messages: inWin.length };
217
+ });
186
218
 
187
219
  // Auto-detect background tooling we excluded → report it (the MCP "asks" by telling).
188
- let excludedTooling = []
220
+ let excludedTooling = [];
189
221
  try {
190
- const top = await readdir(r, { withFileTypes: true })
191
- excludedTooling = top.filter((d) => d.isDirectory() && EXCLUDE_TOOLING.test(d.name)).map((d) => d.name)
192
- } catch { /* ignore */ }
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
+ }
193
229
 
194
230
  return {
195
231
  platform: adapter.platform,
@@ -199,7 +235,7 @@ export async function tokenpull({ adapter = claudeAdapter, root, now } = {}) {
199
235
  totalMessages: msgs.length,
200
236
  excludedTooling,
201
237
  windows,
202
- }
238
+ };
203
239
  }
204
240
 
205
241
  // ── Codex ──────────────────────────────────────────────────────────────────
@@ -210,38 +246,48 @@ export async function tokenpull({ adapter = claudeAdapter, root, now } = {}) {
210
246
  // The input/cacheCreate split is WINDOW-level (estInput = output × io_ratio), so Codex
211
247
  // has its own pull (tokenpullCodex) instead of the per-message claude pipeline.
212
248
  export const codexAdapter = {
213
- platform: 'codex',
214
- defaultRoot: () => join(homedir(), '.codex'),
249
+ platform: "codex",
250
+ defaultRoot: () => join(homedir(), ".codex"),
215
251
  async *records(root) {
216
- for (const sub of ['sessions', 'archived_sessions']) {
252
+ for (const sub of ["sessions", "archived_sessions"]) {
217
253
  for await (const path of _walkJsonl(join(root, sub))) {
218
- let text
219
- try { text = await readFile(path, 'utf8') } catch { continue }
220
- const rel = path.startsWith(root) ? path.slice(root.length + 1) : path
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;
221
261
  // Apply the same background-tooling exclusion as the Claude adapter — skips
222
262
  // memory plugins / observers running under the Codex account.
223
- if (EXCLUDE_TOOLING.test(rel)) continue
224
- for (const line of text.split('\n')) {
225
- if (!line.includes('"token_count"')) continue
226
- let ev
227
- try { ev = JSON.parse(line) } catch { continue }
228
- const p = ev && ev.payload
229
- if (!p || p.type !== 'token_count') continue
230
- const u = (p.info || {}).last_token_usage || {}
231
- const inputIncl = Number(u.input_tokens) || 0
232
- const cached = Number(u.cached_input_tokens) || 0
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;
233
277
  yield {
234
278
  ts: ev.timestamp || null,
235
- output: (Number(u.output_tokens) || 0) + (Number(u.reasoning_output_tokens) || 0),
279
+ output:
280
+ (Number(u.output_tokens) || 0) +
281
+ (Number(u.reasoning_output_tokens) || 0),
236
282
  cacheRead: cached,
237
283
  uncached: Math.max(0, inputIncl - cached), // (true input + cache-write), split window-level
238
284
  file: rel,
239
- }
285
+ };
240
286
  }
241
287
  }
242
288
  }
243
289
  },
244
- }
290
+ };
245
291
 
246
292
  /**
247
293
  * Pull local Codex usage → the 4 windows of CANONICAL pillars (always estimated).
@@ -249,32 +295,63 @@ export const codexAdapter = {
249
295
  * input/output ratio; Alpha = 2.0 default), cacheCreate = max(0, uncached − input).
250
296
  * Inject ioRatio/root/now/adapter for tests.
251
297
  */
252
- export async function tokenpullCodex({ adapter = codexAdapter, root, now, ioRatio = 2.0 } = {}) {
253
- const r = root || adapter.defaultRoot()
254
- const nowMs = now == null ? Date.now() : (typeof now === 'number' ? now : Date.parse(now))
255
- const recs = []
256
- const files = new Set()
257
- for await (const m of adapter.records(r)) { recs.push(m); if (m.file) files.add(m.file) }
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
+ }
258
313
 
259
314
  const windows = WINDOWS.map((w) => {
260
- const cutoff = w.days === Infinity ? -Infinity : nowMs - w.days * DAY_MS
315
+ const cutoff = w.days === Infinity ? -Infinity : nowMs - w.days * DAY_MS;
261
316
  // NULL-TIMESTAMP WINDOW ASYMMETRY: see tokenpull() above — same behavior.
262
317
  // Records without a parseable ts appear in `all` only, not in 7d/30d/90d.
263
318
  const inWin = recs.filter((m) => {
264
- if (w.days === Infinity) return true
265
- const t = m.ts ? Date.parse(m.ts) : NaN
266
- return Number.isFinite(t) && t >= cutoff && t <= nowMs
267
- })
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
+ });
268
323
  const sum = inWin.reduce(
269
- (a, m) => ({ output: a.output + m.output, cacheRead: a.cacheRead + m.cacheRead, uncached: a.uncached + m.uncached }),
324
+ (a, m) => ({
325
+ output: a.output + m.output,
326
+ cacheRead: a.cacheRead + m.cacheRead,
327
+ uncached: a.uncached + m.uncached,
328
+ }),
270
329
  { output: 0, cacheRead: 0, uncached: 0 },
271
- )
272
- const input = Math.floor(sum.output * ioRatio) // estimated true input
273
- const cacheCreate = Math.max(0, sum.uncached - input) // split cache-write out of the combined input
274
- return { window: w.key, messages: inWin.length, pillars: { input, output: sum.output, cacheCreate, cacheRead: sum.cacheRead } }
275
- })
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
+ });
276
344
 
277
- return { platform: 'codex', root: r, generatedAt: new Date(nowMs).toISOString(), files: files.size, totalMessages: recs.length, estimated: true, ioRatio, windows }
345
+ return {
346
+ 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
+ };
278
355
  }
279
356
 
280
357
  /**
@@ -288,37 +365,48 @@ export async function tokenpullCodex({ adapter = codexAdapter, root, now, ioRati
288
365
  * source's log format doesn't expose token counts at all.
289
366
  */
290
367
  export async function tokenpullAny(platform, opts = {}) {
291
- if (!platform || platform === 'claude') return tokenpull({ adapter: claudeAdapter, ...opts })
292
- if (platform === 'codex') {
368
+ if (!platform || platform === "claude")
369
+ return tokenpull({ adapter: claudeAdapter, ...opts });
370
+ if (platform === "codex") {
293
371
  // Auto-derive io_ratio from the operator's Claude data when not explicitly provided.
294
- let ioRatio = opts.ioRatio || 2.0
372
+ let ioRatio = opts.ioRatio || 2.0;
295
373
  if (!opts.ioRatio) {
296
374
  try {
297
375
  // Forward root/now from opts so the Beta-ratio derivation is testable
298
376
  // (without this, it always reads the real ~/.claude and can't be injected).
299
- const c = await tokenpull({ adapter: claudeAdapter, root: opts.root, now: opts.now })
300
- const all = c.windows.find((w) => w.window === 'all')
301
- if (all && all.pillars.output > 0) ioRatio = all.pillars.input / all.pillars.output
302
- } catch { /* no Claude data → Alpha 2.0 */ }
377
+ const c = await tokenpull({
378
+ adapter: claudeAdapter,
379
+ root: opts.root,
380
+ now: opts.now,
381
+ });
382
+ const all = c.windows.find((w) => w.window === "all");
383
+ if (all && all.pillars.output > 0)
384
+ ioRatio = all.pillars.input / all.pillars.output;
385
+ } catch {
386
+ /* no Claude data → Alpha 2.0 */
387
+ }
303
388
  }
304
- return tokenpullCodex({ ioRatio, ...opts })
389
+ return tokenpullCodex({ ioRatio, ...opts });
305
390
  }
306
391
  // Cloud agents (Devin, etc.) run server-side — no local JSONL to read
307
- const CLOUD_AGENTS = { devin: 'Cognition/Devin', }
392
+ const CLOUD_AGENTS = { devin: "Cognition/Devin" };
308
393
  if (platform in CLOUD_AGENTS) {
309
394
  throw new Error(
310
395
  `"${platform}" (${CLOUD_AGENTS[platform]}) runs in the cloud — sessions are not written to local JSONL files. ` +
311
- `There is no local data source to read. If ${CLOUD_AGENTS[platform]} exposes a usage API in future, an adapter can be added.`
312
- )
396
+ `There is no local data source to read. If ${CLOUD_AGENTS[platform]} exposes a usage API in future, an adapter can be added.`,
397
+ );
313
398
  }
314
- const adapter = ADAPTERS[platform]
315
- if (!adapter) throw new Error(`Unknown platform "${platform}". Valid platforms: claude, codex, ${Object.keys(ADAPTERS).join(', ')}`)
316
- const result = await tokenpull({ adapter, ...opts })
399
+ const adapter = ADAPTERS[platform];
400
+ if (!adapter)
401
+ throw new Error(
402
+ `Unknown platform "${platform}". Valid platforms: claude, codex, ${Object.keys(ADAPTERS).join(", ")}`,
403
+ );
404
+ const result = await tokenpull({ adapter, ...opts });
317
405
  // Surface adapter-level flags
318
- if (adapter.estimated) result.estimated = true
319
- if (adapter.dataGap) result.dataGap = adapter.dataGap
320
- if (adapter.setupNote) result.setupNote = adapter.setupNote
321
- return result
406
+ if (adapter.estimated) result.estimated = true;
407
+ if (adapter.dataGap) result.dataGap = adapter.dataGap;
408
+ if (adapter.setupNote) result.setupNote = adapter.setupNote;
409
+ return result;
322
410
  }
323
411
 
324
412
  // ── Fresh verifier pull (#9 / C1) ─────────────────────────────────────────────
@@ -339,36 +427,52 @@ export async function tokenpullAny(platform, opts = {}) {
339
427
  // ASYNC FIX (2026-06-27): was execSync (blocked the entire event loop for up to 15s,
340
428
  // freezing the TUI locked frame + key handling). Now uses execFile (async) so the
341
429
  // event loop keeps running — the TUI stays responsive while the external command runs.
342
- async function _freshCcusage(platform = 'claude') {
430
+ async function _freshCcusage(platform = "claude") {
343
431
  try {
344
- const raw = await execFileAsync('ccusage', [platform, 'daily', '--json'], 15000)
345
- const rows = JSON.parse(raw)?.daily ?? JSON.parse(raw)
346
- if (!Array.isArray(rows)) return null
347
- const now = Date.now()
348
- const result = {}
349
- for (const [win, days] of Object.entries({ '7d': 7, '30d': 30, '90d': 90 })) {
350
- const since = new Date(now - days * DAY_MS)
351
- let i = 0, o = 0, cw = 0, cr = 0
432
+ const raw = await execFileAsync(
433
+ "ccusage",
434
+ [platform, "daily", "--json"],
435
+ 15000,
436
+ );
437
+ const rows = JSON.parse(raw)?.daily ?? JSON.parse(raw);
438
+ if (!Array.isArray(rows)) return null;
439
+ const now = Date.now();
440
+ const result = {};
441
+ for (const [win, days] of Object.entries({
442
+ "7d": 7,
443
+ "30d": 30,
444
+ "90d": 90,
445
+ })) {
446
+ const since = new Date(now - days * DAY_MS);
447
+ let i = 0,
448
+ o = 0,
449
+ cw = 0,
450
+ cr = 0;
352
451
  for (const r of rows) {
353
- if (new Date(r.date ?? r.day ?? '1970') >= since) {
354
- i += r.inputTokens ?? r.input_tokens ?? 0
355
- o += r.outputTokens ?? r.output_tokens ?? 0
356
- cw += r.cacheCreationTokens ?? r.cache_create_tokens ?? 0
357
- cr += r.cacheReadTokens ?? r.cache_read_tokens ?? 0
452
+ if (new Date(r.date ?? r.day ?? "1970") >= since) {
453
+ i += r.inputTokens ?? r.input_tokens ?? 0;
454
+ o += r.outputTokens ?? r.output_tokens ?? 0;
455
+ cw += r.cacheCreationTokens ?? r.cache_create_tokens ?? 0;
456
+ cr += r.cacheReadTokens ?? r.cache_read_tokens ?? 0;
358
457
  }
359
458
  }
360
- result[win] = { input: i, output: o, cacheCreate: cw, cacheRead: cr }
459
+ result[win] = { input: i, output: o, cacheCreate: cw, cacheRead: cr };
361
460
  }
362
- let i = 0, o = 0, cw = 0, cr = 0
461
+ let i = 0,
462
+ o = 0,
463
+ cw = 0,
464
+ cr = 0;
363
465
  for (const r of rows) {
364
- i += r.inputTokens ?? r.input_tokens ?? 0
365
- o += r.outputTokens ?? r.output_tokens ?? 0
366
- cw += r.cacheCreationTokens ?? r.cache_create_tokens ?? 0
367
- cr += r.cacheReadTokens ?? r.cache_read_tokens ?? 0
466
+ i += r.inputTokens ?? r.input_tokens ?? 0;
467
+ o += r.outputTokens ?? r.output_tokens ?? 0;
468
+ cw += r.cacheCreationTokens ?? r.cache_create_tokens ?? 0;
469
+ cr += r.cacheReadTokens ?? r.cache_read_tokens ?? 0;
368
470
  }
369
- result['all'] = { input: i, output: o, cacheCreate: cw, cacheRead: cr }
370
- return result
371
- } catch { return null }
471
+ result["all"] = { input: i, output: o, cacheCreate: cw, cacheRead: cr };
472
+ return result;
473
+ } catch {
474
+ return null;
475
+ }
372
476
  }
373
477
 
374
478
  // tokendash: REFRESH ~/.claude/token-dashboard.db by running the dashboard's scan, then read
@@ -378,21 +482,35 @@ async function _freshCcusage(platform = 'claude') {
378
482
  // ASYNC FIX (2026-06-27): was execSync with a 90s timeout — that blocked the ENTIRE event
379
483
  // loop for up to 90 seconds, freezing the TUI completely (no key handling, no frame repaint).
380
484
  // Now uses execFile (async) so the TUI stays responsive during the scan.
381
- async function _freshTokendash(platform = 'claude') {
382
- if (platform !== 'claude') return null
383
- const dbPath = join(homedir(), '.claude', 'token-dashboard.db')
485
+ async function _freshTokendash(platform = "claude") {
486
+ if (platform !== "claude") return null;
487
+ const dbPath = join(homedir(), ".claude", "token-dashboard.db");
384
488
  // Read the token-dashboard DB directly with sqlite3 (no external python scan
385
489
  // needed — the DB is created by the tokendash dashboard, now bundled as a dep).
386
490
  // all-time only; the db doesn't expose windowing here.
387
- if (!existsSync(dbPath)) return null
491
+ if (!existsSync(dbPath)) return null;
388
492
  try {
389
- const raw = await execFileAsync('sqlite3', [dbPath,
390
- 'SELECT SUM(input_tokens),SUM(output_tokens),SUM(cache_create_5m_tokens)+SUM(cache_create_1h_tokens),SUM(cache_read_tokens) FROM messages'
391
- ], 5000)
392
- const [i, o, cw, cr] = raw.trim().split('|').map(Number)
393
- if (![i, o, cw, cr].some((n) => Number.isFinite(n) && n > 0)) return null
394
- return { all: { input: i || 0, output: o || 0, cacheCreate: cw || 0, cacheRead: cr || 0 } }
395
- } catch { return null }
493
+ const raw = await execFileAsync(
494
+ "sqlite3",
495
+ [
496
+ dbPath,
497
+ "SELECT SUM(input_tokens),SUM(output_tokens),SUM(cache_create_5m_tokens)+SUM(cache_create_1h_tokens),SUM(cache_read_tokens) FROM messages",
498
+ ],
499
+ 5000,
500
+ );
501
+ const [i, o, cw, cr] = raw.trim().split("|").map(Number);
502
+ if (![i, o, cw, cr].some((n) => Number.isFinite(n) && n > 0)) return null;
503
+ return {
504
+ all: {
505
+ input: i || 0,
506
+ output: o || 0,
507
+ cacheCreate: cw || 0,
508
+ cacheRead: cr || 0,
509
+ },
510
+ };
511
+ } catch {
512
+ return null;
513
+ }
396
514
  }
397
515
 
398
516
  // tokscale: run `tokscale models --json` (bundled as npm dep — no bunx needed)
@@ -405,24 +523,37 @@ async function _freshTokendash(platform = 'claude') {
405
523
  //
406
524
  // ASYNC FIX (2026-06-27): was execSync with a 60s timeout — blocked the event loop entirely.
407
525
  // Now uses execFile (async) so the TUI stays responsive.
408
- async function _freshTokscale(platform = 'claude') {
526
+ async function _freshTokscale(platform = "claude") {
409
527
  try {
410
- const raw = await execFileAsync('tokscale', ['models', '--json'], 60000)
411
- const data = JSON.parse(raw)
412
- const entries = Array.isArray(data?.entries) ? data.entries : (Array.isArray(data) ? data : [])
413
- const rows = entries.filter((e) =>
414
- e && e.client === platform && e.model !== '<synthetic>' && e.model !== 'unknown' &&
415
- ((Number(e.input) || 0) > 0 || (Number(e.output) || 0) > 0),
416
- )
417
- if (!rows.length) return null
418
- const acc = rows.reduce((a, e) => ({
419
- input: a.input + (Number(e.input) || 0),
420
- output: a.output + (Number(e.output) || 0),
421
- cacheCreate: a.cacheCreate + (Number(e.cacheWrite) || 0),
422
- cacheRead: a.cacheRead + (Number(e.cacheRead) || 0),
423
- }), { input: 0, output: 0, cacheCreate: 0, cacheRead: 0 })
424
- return { all: acc }
425
- } catch { return null }
528
+ const raw = await execFileAsync("tokscale", ["models", "--json"], 60000);
529
+ const data = JSON.parse(raw);
530
+ const entries = Array.isArray(data?.entries)
531
+ ? data.entries
532
+ : Array.isArray(data)
533
+ ? data
534
+ : [];
535
+ const rows = entries.filter(
536
+ (e) =>
537
+ e &&
538
+ e.client === platform &&
539
+ e.model !== "<synthetic>" &&
540
+ e.model !== "unknown" &&
541
+ ((Number(e.input) || 0) > 0 || (Number(e.output) || 0) > 0),
542
+ );
543
+ if (!rows.length) return null;
544
+ const acc = rows.reduce(
545
+ (a, e) => ({
546
+ input: a.input + (Number(e.input) || 0),
547
+ output: a.output + (Number(e.output) || 0),
548
+ cacheCreate: a.cacheCreate + (Number(e.cacheWrite) || 0),
549
+ cacheRead: a.cacheRead + (Number(e.cacheRead) || 0),
550
+ }),
551
+ { input: 0, output: 0, cacheCreate: 0, cacheRead: 0 },
552
+ );
553
+ return { all: acc };
554
+ } catch {
555
+ return null;
556
+ }
426
557
  }
427
558
 
428
559
  /**
@@ -434,14 +565,20 @@ async function _freshTokscale(platform = 'claude') {
434
565
  * - tokendash: refresh + read ~/.claude/token-dashboard.db → all (claude only)
435
566
  * - tokscale : `bunx tokscale@latest models --json` → all
436
567
  */
437
- export async function freshVerifierPillars(platform = 'claude') {
438
- const p = platform || 'claude'
568
+ export async function freshVerifierPillars(platform = "claude") {
569
+ const p = platform || "claude";
439
570
  // Each call is internally try/catch → null; await Promise.all of resolved values keeps the
440
571
  // helper async (per the contract) without any individual failure rejecting the batch.
441
572
  const [ccusage, tokendash, tokscale] = await Promise.all([
442
- Promise.resolve().then(() => _freshCcusage(p)).catch(() => null),
443
- Promise.resolve().then(() => _freshTokendash(p)).catch(() => null),
444
- Promise.resolve().then(() => _freshTokscale(p)).catch(() => null),
445
- ])
446
- return { ccusage, tokscale, tokendash }
573
+ Promise.resolve()
574
+ .then(() => _freshCcusage(p))
575
+ .catch(() => null),
576
+ Promise.resolve()
577
+ .then(() => _freshTokendash(p))
578
+ .catch(() => null),
579
+ Promise.resolve()
580
+ .then(() => _freshTokscale(p))
581
+ .catch(() => null),
582
+ ]);
583
+ return { ccusage, tokscale, tokendash };
447
584
  }