sigrank 0.15.5 → 0.16.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.
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,43 +170,62 @@ 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;
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.
167
202
  const inWin = msgs.filter((m) => {
168
- if (w.days === Infinity) return true
169
- const t = m.ts ? Date.parse(m.ts) : NaN
170
- return Number.isFinite(t) && t >= cutoff && t <= nowMs
171
- })
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
+ });
172
207
  const pillars = inWin.reduce(
173
- (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
+ }),
174
214
  { input: 0, output: 0, cacheCreate: 0, cacheRead: 0 },
175
- )
176
- return { window: w.key, pillars, messages: inWin.length }
177
- })
215
+ );
216
+ return { window: w.key, pillars, messages: inWin.length };
217
+ });
178
218
 
179
219
  // Auto-detect background tooling we excluded → report it (the MCP "asks" by telling).
180
- let excludedTooling = []
220
+ let excludedTooling = [];
181
221
  try {
182
- const top = await readdir(r, { withFileTypes: true })
183
- excludedTooling = top.filter((d) => d.isDirectory() && EXCLUDE_TOOLING.test(d.name)).map((d) => d.name)
184
- } 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
+ }
185
229
 
186
230
  return {
187
231
  platform: adapter.platform,
@@ -191,7 +235,7 @@ export async function tokenpull({ adapter = claudeAdapter, root, now } = {}) {
191
235
  totalMessages: msgs.length,
192
236
  excludedTooling,
193
237
  windows,
194
- }
238
+ };
195
239
  }
196
240
 
197
241
  // ── Codex ──────────────────────────────────────────────────────────────────
@@ -202,38 +246,48 @@ export async function tokenpull({ adapter = claudeAdapter, root, now } = {}) {
202
246
  // The input/cacheCreate split is WINDOW-level (estInput = output × io_ratio), so Codex
203
247
  // has its own pull (tokenpullCodex) instead of the per-message claude pipeline.
204
248
  export const codexAdapter = {
205
- platform: 'codex',
206
- defaultRoot: () => join(homedir(), '.codex'),
249
+ platform: "codex",
250
+ defaultRoot: () => join(homedir(), ".codex"),
207
251
  async *records(root) {
208
- for (const sub of ['sessions', 'archived_sessions']) {
252
+ for (const sub of ["sessions", "archived_sessions"]) {
209
253
  for await (const path of _walkJsonl(join(root, sub))) {
210
- let text
211
- try { text = await readFile(path, 'utf8') } catch { continue }
212
- 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;
213
261
  // Apply the same background-tooling exclusion as the Claude adapter — skips
214
262
  // memory plugins / observers running under the Codex account.
215
- if (EXCLUDE_TOOLING.test(rel)) continue
216
- for (const line of text.split('\n')) {
217
- if (!line.includes('"token_count"')) continue
218
- let ev
219
- try { ev = JSON.parse(line) } catch { continue }
220
- const p = ev && ev.payload
221
- if (!p || p.type !== 'token_count') continue
222
- const u = (p.info || {}).last_token_usage || {}
223
- const inputIncl = Number(u.input_tokens) || 0
224
- 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;
225
277
  yield {
226
278
  ts: ev.timestamp || null,
227
- 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),
228
282
  cacheRead: cached,
229
283
  uncached: Math.max(0, inputIncl - cached), // (true input + cache-write), split window-level
230
284
  file: rel,
231
- }
285
+ };
232
286
  }
233
287
  }
234
288
  }
235
289
  },
236
- }
290
+ };
237
291
 
238
292
  /**
239
293
  * Pull local Codex usage → the 4 windows of CANONICAL pillars (always estimated).
@@ -241,30 +295,63 @@ export const codexAdapter = {
241
295
  * input/output ratio; Alpha = 2.0 default), cacheCreate = max(0, uncached − input).
242
296
  * Inject ioRatio/root/now/adapter for tests.
243
297
  */
244
- export async function tokenpullCodex({ adapter = codexAdapter, root, now, ioRatio = 2.0 } = {}) {
245
- const r = root || adapter.defaultRoot()
246
- const nowMs = now == null ? Date.now() : (typeof now === 'number' ? now : Date.parse(now))
247
- const recs = []
248
- const files = new Set()
249
- 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
+ }
250
313
 
251
314
  const windows = WINDOWS.map((w) => {
252
- const cutoff = w.days === Infinity ? -Infinity : nowMs - w.days * DAY_MS
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.
253
318
  const inWin = recs.filter((m) => {
254
- if (w.days === Infinity) return true
255
- const t = m.ts ? Date.parse(m.ts) : NaN
256
- return Number.isFinite(t) && t >= cutoff && t <= nowMs
257
- })
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
+ });
258
323
  const sum = inWin.reduce(
259
- (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
+ }),
260
329
  { output: 0, cacheRead: 0, uncached: 0 },
261
- )
262
- const input = Math.floor(sum.output * ioRatio) // estimated true input
263
- const cacheCreate = Math.max(0, sum.uncached - input) // split cache-write out of the combined input
264
- return { window: w.key, messages: inWin.length, pillars: { input, output: sum.output, cacheCreate, cacheRead: sum.cacheRead } }
265
- })
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
+ });
266
344
 
267
- 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
+ };
268
355
  }
269
356
 
270
357
  /**
@@ -278,35 +365,48 @@ export async function tokenpullCodex({ adapter = codexAdapter, root, now, ioRati
278
365
  * source's log format doesn't expose token counts at all.
279
366
  */
280
367
  export async function tokenpullAny(platform, opts = {}) {
281
- if (!platform || platform === 'claude') return tokenpull({ adapter: claudeAdapter, ...opts })
282
- if (platform === 'codex') {
368
+ if (!platform || platform === "claude")
369
+ return tokenpull({ adapter: claudeAdapter, ...opts });
370
+ if (platform === "codex") {
283
371
  // Auto-derive io_ratio from the operator's Claude data when not explicitly provided.
284
- let ioRatio = opts.ioRatio || 2.0
372
+ let ioRatio = opts.ioRatio || 2.0;
285
373
  if (!opts.ioRatio) {
286
374
  try {
287
- const c = await tokenpull({ adapter: claudeAdapter })
288
- const all = c.windows.find((w) => w.window === 'all')
289
- if (all && all.pillars.output > 0) ioRatio = all.pillars.input / all.pillars.output
290
- } catch { /* no Claude data → Alpha 2.0 */ }
375
+ // Forward root/now from opts so the Beta-ratio derivation is testable
376
+ // (without this, it always reads the real ~/.claude and can't be injected).
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
+ }
291
388
  }
292
- return tokenpullCodex({ ioRatio, ...opts })
389
+ return tokenpullCodex({ ioRatio, ...opts });
293
390
  }
294
391
  // Cloud agents (Devin, etc.) run server-side — no local JSONL to read
295
- const CLOUD_AGENTS = { devin: 'Cognition/Devin', }
392
+ const CLOUD_AGENTS = { devin: "Cognition/Devin" };
296
393
  if (platform in CLOUD_AGENTS) {
297
394
  throw new Error(
298
395
  `"${platform}" (${CLOUD_AGENTS[platform]}) runs in the cloud — sessions are not written to local JSONL files. ` +
299
- `There is no local data source to read. If ${CLOUD_AGENTS[platform]} exposes a usage API in future, an adapter can be added.`
300
- )
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
+ );
301
398
  }
302
- const adapter = ADAPTERS[platform]
303
- if (!adapter) throw new Error(`Unknown platform "${platform}". Valid platforms: claude, codex, ${Object.keys(ADAPTERS).join(', ')}`)
304
- 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 });
305
405
  // Surface adapter-level flags
306
- if (adapter.estimated) result.estimated = true
307
- if (adapter.dataGap) result.dataGap = adapter.dataGap
308
- if (adapter.setupNote) result.setupNote = adapter.setupNote
309
- 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;
310
410
  }
311
411
 
312
412
  // ── Fresh verifier pull (#9 / C1) ─────────────────────────────────────────────
@@ -327,36 +427,52 @@ export async function tokenpullAny(platform, opts = {}) {
327
427
  // ASYNC FIX (2026-06-27): was execSync (blocked the entire event loop for up to 15s,
328
428
  // freezing the TUI locked frame + key handling). Now uses execFile (async) so the
329
429
  // event loop keeps running — the TUI stays responsive while the external command runs.
330
- async function _freshCcusage(platform = 'claude') {
430
+ async function _freshCcusage(platform = "claude") {
331
431
  try {
332
- const raw = await execFileAsync('ccusage', [platform, 'daily', '--json'], 15000)
333
- const rows = JSON.parse(raw)?.daily ?? JSON.parse(raw)
334
- if (!Array.isArray(rows)) return null
335
- const now = Date.now()
336
- const result = {}
337
- for (const [win, days] of Object.entries({ '7d': 7, '30d': 30, '90d': 90 })) {
338
- const since = new Date(now - days * DAY_MS)
339
- 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;
340
451
  for (const r of rows) {
341
- if (new Date(r.date ?? r.day ?? '1970') >= since) {
342
- i += r.inputTokens ?? r.input_tokens ?? 0
343
- o += r.outputTokens ?? r.output_tokens ?? 0
344
- cw += r.cacheCreationTokens ?? r.cache_create_tokens ?? 0
345
- 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;
346
457
  }
347
458
  }
348
- result[win] = { input: i, output: o, cacheCreate: cw, cacheRead: cr }
459
+ result[win] = { input: i, output: o, cacheCreate: cw, cacheRead: cr };
349
460
  }
350
- let i = 0, o = 0, cw = 0, cr = 0
461
+ let i = 0,
462
+ o = 0,
463
+ cw = 0,
464
+ cr = 0;
351
465
  for (const r of rows) {
352
- i += r.inputTokens ?? r.input_tokens ?? 0
353
- o += r.outputTokens ?? r.output_tokens ?? 0
354
- cw += r.cacheCreationTokens ?? r.cache_create_tokens ?? 0
355
- 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;
356
470
  }
357
- result['all'] = { input: i, output: o, cacheCreate: cw, cacheRead: cr }
358
- return result
359
- } catch { return null }
471
+ result["all"] = { input: i, output: o, cacheCreate: cw, cacheRead: cr };
472
+ return result;
473
+ } catch {
474
+ return null;
475
+ }
360
476
  }
361
477
 
362
478
  // tokendash: REFRESH ~/.claude/token-dashboard.db by running the dashboard's scan, then read
@@ -366,21 +482,35 @@ async function _freshCcusage(platform = 'claude') {
366
482
  // ASYNC FIX (2026-06-27): was execSync with a 90s timeout — that blocked the ENTIRE event
367
483
  // loop for up to 90 seconds, freezing the TUI completely (no key handling, no frame repaint).
368
484
  // Now uses execFile (async) so the TUI stays responsive during the scan.
369
- async function _freshTokendash(platform = 'claude') {
370
- if (platform !== 'claude') return null
371
- 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");
372
488
  // Read the token-dashboard DB directly with sqlite3 (no external python scan
373
489
  // needed — the DB is created by the tokendash dashboard, now bundled as a dep).
374
490
  // all-time only; the db doesn't expose windowing here.
375
- if (!existsSync(dbPath)) return null
491
+ if (!existsSync(dbPath)) return null;
376
492
  try {
377
- const raw = await execFileAsync('sqlite3', [dbPath,
378
- 'SELECT SUM(input_tokens),SUM(output_tokens),SUM(cache_create_5m_tokens)+SUM(cache_create_1h_tokens),SUM(cache_read_tokens) FROM messages'
379
- ], 5000)
380
- const [i, o, cw, cr] = raw.trim().split('|').map(Number)
381
- if (![i, o, cw, cr].some((n) => Number.isFinite(n) && n > 0)) return null
382
- return { all: { input: i || 0, output: o || 0, cacheCreate: cw || 0, cacheRead: cr || 0 } }
383
- } 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
+ }
384
514
  }
385
515
 
386
516
  // tokscale: run `tokscale models --json` (bundled as npm dep — no bunx needed)
@@ -393,24 +523,37 @@ async function _freshTokendash(platform = 'claude') {
393
523
  //
394
524
  // ASYNC FIX (2026-06-27): was execSync with a 60s timeout — blocked the event loop entirely.
395
525
  // Now uses execFile (async) so the TUI stays responsive.
396
- async function _freshTokscale(platform = 'claude') {
526
+ async function _freshTokscale(platform = "claude") {
397
527
  try {
398
- const raw = await execFileAsync('tokscale', ['models', '--json'], 60000)
399
- const data = JSON.parse(raw)
400
- const entries = Array.isArray(data?.entries) ? data.entries : (Array.isArray(data) ? data : [])
401
- const rows = entries.filter((e) =>
402
- e && e.client === platform && e.model !== '<synthetic>' && e.model !== 'unknown' &&
403
- ((Number(e.input) || 0) > 0 || (Number(e.output) || 0) > 0),
404
- )
405
- if (!rows.length) return null
406
- const acc = rows.reduce((a, e) => ({
407
- input: a.input + (Number(e.input) || 0),
408
- output: a.output + (Number(e.output) || 0),
409
- cacheCreate: a.cacheCreate + (Number(e.cacheWrite) || 0),
410
- cacheRead: a.cacheRead + (Number(e.cacheRead) || 0),
411
- }), { input: 0, output: 0, cacheCreate: 0, cacheRead: 0 })
412
- return { all: acc }
413
- } 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
+ }
414
557
  }
415
558
 
416
559
  /**
@@ -422,14 +565,20 @@ async function _freshTokscale(platform = 'claude') {
422
565
  * - tokendash: refresh + read ~/.claude/token-dashboard.db → all (claude only)
423
566
  * - tokscale : `bunx tokscale@latest models --json` → all
424
567
  */
425
- export async function freshVerifierPillars(platform = 'claude') {
426
- const p = platform || 'claude'
568
+ export async function freshVerifierPillars(platform = "claude") {
569
+ const p = platform || "claude";
427
570
  // Each call is internally try/catch → null; await Promise.all of resolved values keeps the
428
571
  // helper async (per the contract) without any individual failure rejecting the batch.
429
572
  const [ccusage, tokendash, tokscale] = await Promise.all([
430
- Promise.resolve().then(() => _freshCcusage(p)).catch(() => null),
431
- Promise.resolve().then(() => _freshTokendash(p)).catch(() => null),
432
- Promise.resolve().then(() => _freshTokscale(p)).catch(() => null),
433
- ])
434
- 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 };
435
584
  }