sigrank 0.11.5 → 0.12.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/README.md CHANGED
@@ -67,7 +67,7 @@ sigrank
67
67
  ## Commands
68
68
 
69
69
  ```
70
- ⊙ SigRank CLI v0.11.2
70
+ ⊙ SigRank CLI v0.12.1
71
71
 
72
72
  Default (no args)
73
73
  sigrank unified dashboard: cascade + token pillars + board
@@ -258,7 +258,7 @@ All adapters are token-only (no message content, no cost fields, no credentials)
258
258
  | Var | Default | Description |
259
259
  |---|---|---|
260
260
  | `SIGRANK_API_BASE` | `https://signalaf.com` | Override the board host |
261
- | `SIGRANK_FETCH_TIMEOUT_MS` | `10000` | Board API fetch timeout |
261
+ | `SIGRANK_FETCH_TIMEOUT` | `10000` | Board API fetch timeout (ms) |
262
262
 
263
263
  ---
264
264
 
package/cli.mjs CHANGED
@@ -12,8 +12,9 @@
12
12
  * npx sigrank me your cascade across all 4 windows
13
13
  * npx sigrank me --platform amp use a different platform adapter
14
14
  * npx sigrank me --compare raw pillar comparison: ccusage vs tokenpull vs token-dashboard
15
- * npx sigrank watch RT tune meter — local cascade, refreshes
16
- * npx sigrank watch --window 7d watch a specific window
15
+ * npx sigrank watch RT tune meter — ALL active platforms × all windows
16
+ * npx sigrank watch --platform X watch one platform only (optional filter)
17
+ * npx sigrank watch --window 7d watch one window only (optional filter)
17
18
  *
18
19
  * Color palette mirrors the SigRank web dark theme:
19
20
  * gold = class TRANSMITTER headline + rank #1
@@ -395,6 +396,16 @@ async function runMe({ platform = 'claude', compare = false } = {}) {
395
396
 
396
397
  // ── COMPARE command ───────────────────────────────────────────────────────────
397
398
  // Side-by-side: ccusage (JSON) vs tokenpull vs token-dashboard (SQLite)
399
+ //
400
+ // NOTE (P3 2026-06-27): These sync verifier readers (ccusagePillars / tokscalePillars /
401
+ // tokenDashPillars / appPillars) are INTENTIONALLY separate from tokenpull.mjs
402
+ // `freshVerifierPillars()`. The compare command uses cached/file-based data sources
403
+ // (tokscale_report.json, direct db read with windowed+model-filtered queries) for a
404
+ // quick side-by-side, while freshVerifierPillars runs all three live (bunx tokscale,
405
+ // scan+read tokendash) for the dashboard. Merging them would change compare's data
406
+ // source and lose the windowed tokendash breakdown. ccusagePillars IS functionally
407
+ // identical to _freshCcusage and could be consolidated in a future pass if compare
408
+ // switches to live data — but that's a behavior change, not a refactor.
398
409
 
399
410
  function ccusagePillars(platform = 'claude') {
400
411
  // ccusage <platform> daily --json → sum by window
@@ -684,37 +695,50 @@ async function runCompare({ platform = 'claude' } = {}) {
684
695
  }
685
696
 
686
697
  // ── WATCH command ─────────────────────────────────────────────────────────────
698
+ //
699
+ // A5: by default (no --platform / --window) the watcher auto-loads EVERYTHING — every
700
+ // active platform (input+output > 0) × every window (7d/30d/90d/all), refreshed each tick.
701
+ // --platform / --window remain OPTIONAL filters for back-compat (focus one cell). The
702
+ // refresh loop + --submit behavior are preserved; submit publishes whichever windows are
703
+ // shown whenever their Υ changes.
704
+
705
+ const WATCH_WINDOWS = ['7d', '30d', '90d', 'all']
706
+
707
+ // Detect which platforms have real local data (input+output > 0 in any window) via tokenpull.
708
+ // Returns an array of pull results (the same shape tokenpullAny yields), one per active platform.
709
+ async function detectActivePulls() {
710
+ const { tokenpullAny } = await import('./tokenpull.mjs')
711
+ const settled = await Promise.allSettled(ALL_PLATFORMS.map((p) => tokenpullAny(p)))
712
+ const active = []
713
+ for (const r of settled) {
714
+ if (r.status !== 'fulfilled' || !r.value) continue
715
+ const d = r.value
716
+ const live = (d.windows || []).some((w) => (w.pillars.input + w.pillars.output) > 0)
717
+ if (live) active.push(d)
718
+ }
719
+ return active
720
+ }
687
721
 
688
- async function runWatch({ platform = 'claude', window: win = '7d', refresh = 30, submit = false } = {}) {
689
- let prev = null
722
+ async function runWatch({ platform, window: win, refresh = 30, submit = false } = {}) {
690
723
  let lines = 0
691
- let lastSubmit = null
692
724
  const id = submit ? ensureIdentity() : null
693
725
  const enrolled = !!(id && id.codename && id.operator_id && id.private_key_pkcs8_b64)
694
726
 
695
- write(HIDE_CURSOR)
727
+ // Filters: when provided, narrow the grid; when absent, watch all active × all windows.
728
+ const winFilter = typeof win === 'string' && win ? win : null
729
+ const platFilter = typeof platform === 'string' && platform ? platform : null
730
+ const windows = winFilter ? [winFilter] : WATCH_WINDOWS
696
731
 
697
- const draw = async () => {
698
- let result
699
- try {
700
- result = await callTool('watch_tokenpull', { platform, window: win })
701
- } catch (e) {
702
- writeln(red(` ✗ ${e.message}`))
703
- return
704
- }
732
+ // Per-cell Υ memory (key = `${platform}|${window}`) so each cell tracks its own change.
733
+ const prevY = new Map()
734
+ const lastSubmitMsg = new Map()
705
735
 
706
- const cas = result.cascade
707
- const changed = prev !== null && prev !== cas?.yield
736
+ write(HIDE_CURSOR)
708
737
 
709
- // --submit (D7 §7): publish the verified window on first observation + whenever Υ changes.
710
- if (submit && enrolled && (prev === null || changed)) {
711
- try {
712
- const r = await submitSignedWindow(win, result.pillars, result.messages, id, { platform: result.platform })
713
- lastSubmit = r.status === 'received' ? green(`✓ submitted · tier=${r.verification_tier || '—'}`) : red(`✗ ${r.reason || r.status}`)
714
- } catch (e) {
715
- lastSubmit = red(`✗ ${e.message}`)
716
- }
717
- }
738
+ const draw = async () => {
739
+ // Re-detect each tick so a platform that just started writing logs appears automatically.
740
+ let pulls = await detectActivePulls().catch(() => [])
741
+ if (platFilter) pulls = pulls.filter((d) => d.platform === platFilter)
718
742
 
719
743
  if (lines > 0) write(CURSOR_UP(lines))
720
744
 
@@ -723,31 +747,64 @@ async function runWatch({ platform = 'claude', window: win = '7d', refresh = 30,
723
747
  const w = termWidth()
724
748
  const ts = new Date().toLocaleTimeString('en-US', { hour12: false })
725
749
 
750
+ const scopeLabel = (platFilter || 'all active platforms') + ' · ' + (winFilter ? `window: ${winFilter}` : 'all windows')
751
+
726
752
  push()
727
- push(` ${gold('⊙ SigRank')} ${bold('Watch')} ${dim(`${platform} · window: ${win} · ${ts}`)}`)
753
+ push(` ${gold('⊙ SigRank')} ${bold('Watch')} ${dim(`${scopeLabel} · ${ts}`)}`)
728
754
  push(` ${dim('─'.repeat(w - 4))}`)
729
- push()
730
755
 
731
- const yDisplay = cas?.yield != null ? fmtYield(cas.yield) : '—'
732
- const indicator = changed ? green(' ▲ updated') : dim(' · no change')
756
+ if (pulls.length === 0) {
757
+ push()
758
+ push(` ${dim('no active platforms detected — run some sessions, this will pick them up automatically')}`)
759
+ push()
760
+ }
733
761
 
734
- push(` ${bold('Υ Yield')} ${cas?.yield != null ? gold(yDisplay) : '—'}${indicator}`)
735
- push(` ${bold('SNR')} ${fmtSNR(cas?.snr)}`)
736
- push(` ${bold('Leverage')} ${cas?.leverage != null ? `${fmtLev(cas.leverage)}×` : '—'}`)
737
- push(` ${bold('Velocity')} ${cas?.velocity != null ? cas.velocity.toFixed(2) : '—'}`)
738
- push(` ${bold('10xDEV')} ${cas?.dev10x != null ? cas.dev10x.toFixed(2) : '—'}`)
739
- push(` ${bold('Class')} ${colorClass(cas?.class ?? '—')}`)
740
- push()
741
- if (submit) {
742
- push(` ${dim('submit:')} ${enrolled ? (lastSubmit || dim('waiting for a change…')) : red('not enrolled — run `npx sigrank enroll`')}`)
762
+ for (const d of pulls) {
763
+ const plat = d.platform
764
+ const byWin = {}
765
+ for (const ww of (d.windows || [])) byWin[ww.window] = ww
766
+ push()
767
+ push(` ${cyan(plat)}${d.estimated ? dim(' (est)') : ''}`)
768
+ for (const wk of windows) {
769
+ const ww = byWin[wk]
770
+ const cas = ww ? cascadeFromPillars(ww.pillars) : null
771
+ const key = `${plat}|${wk}`
772
+ const prev = prevY.get(key)
773
+ const yNow = cas?.yield ?? null
774
+ const changed = prev !== undefined && prev !== yNow
775
+
776
+ // --submit: publish this cell on first observation + whenever its Υ changes.
777
+ if (submit && enrolled && ww && yNow != null && (prev === undefined || changed)) {
778
+ try {
779
+ const r = await submitSignedWindow(wk, ww.pillars, ww.messages, id, { platform: plat })
780
+ lastSubmitMsg.set(key, r.status === 'received' ? green(`✓ tier=${r.verification_tier || '—'}`) : red(`✗ ${r.reason || r.status}`))
781
+ } catch (e) {
782
+ lastSubmitMsg.set(key, red(`✗ ${e.message}`))
783
+ }
784
+ }
785
+ prevY.set(key, yNow)
786
+
787
+ const winLabel = wk === 'all' ? 'all-time' : wk
788
+ const yDisplay = cas?.yield != null ? gold(fmtYield(cas.yield)) : dim('—')
789
+ const indicator = changed ? green(' ▲') : dim(' ·')
790
+ const metrics = cas
791
+ ? `${dim('SNR')} ${fmtSNR(cas.snr)} ${dim('Lev')} ${cas.leverage != null ? fmtLev(cas.leverage) + '×' : '—'} ${dim('Vel')} ${cas.velocity != null ? cas.velocity.toFixed(2) : '—'} ${colorClass(cas.class ?? '—')}`
792
+ : dim('no data')
793
+ const submitNote = (submit && enrolled) ? ' ' + (lastSubmitMsg.get(key) || dim('…')) : ''
794
+ push(` ${padEnd(winLabel, 8)} ${bold('Υ')} ${yDisplay}${indicator} ${metrics}${submitNote}`)
795
+ }
743
796
  }
797
+
798
+ push()
744
799
  push(` ${dim('─'.repeat(w - 4))}`)
800
+ if (submit && !enrolled) {
801
+ push(` ${red('not enrolled — run `npx sigrank enroll` to auto-submit')}`)
802
+ }
745
803
  push(` ${dim(`polling every ${refresh}s · ${submit ? 'auto-submit ON · ' : ''}tokens stay on your machine · ctrl+c to exit`)}`)
746
804
  push()
747
805
 
748
806
  write(out.join('\n'))
749
807
  lines = out.length
750
- prev = cas?.yield ?? null
751
808
  }
752
809
 
753
810
  try {
@@ -784,7 +841,7 @@ async function runSigRank() {
784
841
  writeln(` ${gold('⊙ SigRank')} ${bold('Operator Dashboard')}`)
785
842
  writeln(` ${dim('reading local data…')}`)
786
843
 
787
- const { tokenpullAny } = await import('./tokenpull.mjs')
844
+ const { tokenpullAny, freshVerifierPillars } = await import('./tokenpull.mjs')
788
845
 
789
846
  // Local sources first (fast) — board fetch with 5s timeout runs in parallel
790
847
  const boardPromise = Promise.race([
@@ -792,24 +849,16 @@ async function runSigRank() {
792
849
  new Promise(r => setTimeout(() => r(null), 5000)),
793
850
  ])
794
851
 
795
- const [platformResults, tdPillars] = await Promise.all([
852
+ // FIX A1-CLI (2026-06-27): verifiers (ccusage/tokscale/tokendash) are fetched
853
+ // ON-DEMAND for ACTIVE platforms only — not synchronously for all 15 platforms
854
+ // upfront (which blocked Dashboard paint up to ~40s, same class of bug as TUI
855
+ // FIX A1). The sync ccusagePillars()/tokscalePillars()/tokenDashPillars() loop
856
+ // over ALL_PLATFORMS is replaced by parallel freshVerifierPillars() calls after
857
+ // we know which platforms actually have local data.
858
+ const [platformResults] = await Promise.all([
796
859
  Promise.allSettled(ALL_PLATFORMS.map(p => tokenpullAny(p))),
797
- Promise.resolve(tokenDashPillars()),
798
860
  ])
799
861
 
800
- // per-platform verifiers: ccusage + tokscale both support multiple platforms
801
- // build map: platform → { cc, ts }
802
- const verifierMap = {}
803
- for (const platform of ALL_PLATFORMS) {
804
- verifierMap[platform] = {
805
- cc: ccusagePillars(platform),
806
- ts: tokscalePillars(platform),
807
- }
808
- }
809
- // keep top-level claude refs for combined section
810
- const ccPillars = verifierMap['claude']?.cc ?? null
811
- const tsPillars = verifierMap['claude']?.ts ?? null
812
-
813
862
  const boardData = await boardPromise
814
863
 
815
864
  // filter to platforms with actual data
@@ -825,6 +874,21 @@ async function runSigRank() {
825
874
  active.push(d)
826
875
  }
827
876
 
877
+ // Fetch verifiers on-demand for active platforms only (parallel, never throws).
878
+ // tdPillars (token-dashboard) is claude-only and pulled from the claude fresh result.
879
+ const verifierMap = {}
880
+ let tdPillars = null
881
+ if (active.length > 0) {
882
+ const fresh = await Promise.all(
883
+ active.map(d => freshVerifierPillars(d.platform).catch(() => ({ ccusage: null, tokscale: null, tokendash: null })))
884
+ )
885
+ for (let i = 0; i < active.length; i++) {
886
+ const f = fresh[i]
887
+ verifierMap[active[i].platform] = { cc: f.ccusage, ts: f.tokscale }
888
+ if (active[i].platform === 'claude' && f.tokendash) tdPillars = f.tokendash
889
+ }
890
+ }
891
+
828
892
  // clear the 3 loading lines (blank + header + "reading…")
829
893
  write(CURSOR_UP(3) + ERASE_LINE + CURSOR_UP(1) + ERASE_LINE + CURSOR_UP(1) + ERASE_LINE)
830
894
 
@@ -1213,11 +1277,12 @@ async function showHelp() {
1213
1277
  writeln(` ${cyan('compare --platform codex')} compare for a specific platform`)
1214
1278
  writeln(` ${cyan('tui')} full tabbed TUI: Dashboard / Compare / Board / Watch`)
1215
1279
  writeln(` ${cyan('tui --platform codex')} TUI with a different default platform`)
1216
- writeln(` ${cyan('watch')} live tune meter — re-reads local logs every 30s`)
1217
- writeln(` ${cyan('watch --window 7d')} watch a specific window`)
1280
+ writeln(` ${cyan('watch')} live tune meter — ALL active platforms × all windows, every 30s`)
1281
+ writeln(` ${cyan('watch --platform codex')} watch only one platform (optional filter)`)
1282
+ writeln(` ${cyan('watch --window 7d')} watch only one window (optional filter)`)
1218
1283
  writeln()
1219
1284
  writeln(` ${bold('Options')}`)
1220
- writeln(` ${dim('--window')} 7d · 30d · 90d · all (default: 30d for board, 7d for watch)`)
1285
+ writeln(` ${dim('--window')} 7d · 30d · 90d · all (default: 30d for board; all windows for watch)`)
1221
1286
  writeln(` ${dim('--platform')} claude · codex · amp · gemini · opencode · goose · …`)
1222
1287
  writeln(` ${dim('--refresh')} poll interval in seconds (default: 30)`)
1223
1288
  writeln(` ${dim('--once')} print once and exit (board only)`)
@@ -1337,14 +1402,31 @@ export async function runCli(argv) {
1337
1402
  const args = argv.slice(2) // strip 'node' + script path
1338
1403
  const cmd = args[0]
1339
1404
 
1340
- // parse --key value flags
1405
+ // parse --key value AND --key=value flags. E4: validate against the known set and warn
1406
+ // (don't crash) on anything unknown, so a typo'd flag is visible instead of silently ignored.
1407
+ const KNOWN_FLAGS = new Set(['window', 'platform', 'refresh', 'once', 'submit', 'compare', 'label'])
1341
1408
  const flags = {}
1342
1409
  for (let i = 1; i < args.length; i++) {
1343
- if (args[i].startsWith('--')) {
1344
- const key = args[i].slice(2)
1345
- const val = args[i + 1] && !args[i + 1].startsWith('--') ? args[++i] : true
1346
- flags[key] = val
1410
+ const a = args[i]
1411
+ if (!a.startsWith('--')) continue
1412
+ const body = a.slice(2)
1413
+ let key, val
1414
+ const eq = body.indexOf('=')
1415
+ if (eq !== -1) {
1416
+ // --flag=value form
1417
+ key = body.slice(0, eq)
1418
+ val = body.slice(eq + 1)
1419
+ } else {
1420
+ // --flag [value] form: consume the next token as the value unless it's another flag
1421
+ key = body
1422
+ val = args[i + 1] && !args[i + 1].startsWith('--') ? args[++i] : true
1423
+ }
1424
+ if (!key) continue
1425
+ if (!KNOWN_FLAGS.has(key)) {
1426
+ writeln(dim(` (ignoring unknown flag --${key}; known: ${[...KNOWN_FLAGS].join(', ')})`))
1427
+ continue
1347
1428
  }
1429
+ flags[key] = val
1348
1430
  }
1349
1431
 
1350
1432
  try {
@@ -1362,9 +1444,12 @@ export async function runCli(argv) {
1362
1444
  const { runTui } = await import('./tui.mjs')
1363
1445
  await runTui({ platform: flags.platform ?? 'claude', window: flags.window ?? '7d' })
1364
1446
  } else if (cmd === 'watch') {
1447
+ // A5: no --platform/--window → watch ALL active platforms × ALL windows. Pass the raw
1448
+ // flags through (undefined = no filter) so runWatch's auto-all mode engages by default;
1449
+ // a provided flag narrows the grid (back-compat).
1365
1450
  await runWatch({
1366
- platform: flags.platform ?? 'claude',
1367
- window: flags.window ?? '7d',
1451
+ platform: typeof flags.platform === 'string' ? flags.platform : undefined,
1452
+ window: typeof flags.window === 'string' ? flags.window : undefined,
1368
1453
  refresh: Number(flags.refresh) || 30,
1369
1454
  submit: flags.submit === true || flags.submit === 'true',
1370
1455
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sigrank",
3
- "version": "0.11.5",
3
+ "version": "0.12.1",
4
4
  "description": "SigRank MCP server — the yield cascade + live leaderboard as MCP tools any agent can call",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/tokenpull.mjs CHANGED
@@ -16,6 +16,8 @@
16
16
  */
17
17
 
18
18
  import { readdir, readFile, lstat } from 'node:fs/promises'
19
+ import { existsSync } from 'node:fs'
20
+ import { execSync } from 'node:child_process'
19
21
  import { join } from 'node:path'
20
22
  import { homedir } from 'node:os'
21
23
  import { ADAPTERS } from './adapters.mjs'
@@ -280,3 +282,119 @@ export async function tokenpullAny(platform, opts = {}) {
280
282
  if (adapter.setupNote) result.setupNote = adapter.setupNote
281
283
  return result
282
284
  }
285
+
286
+ // ── Fresh verifier pull (#9 / C1) ─────────────────────────────────────────────
287
+ // The owner-requested FRESH pull of the three external verifier sources. Unlike the
288
+ // stale snapshot readers (a 3-day-old token-dashboard.db, a static tokscale_report.json),
289
+ // these RUN the source tool on every call so the numbers are live. Token-only: each tool
290
+ // reports usage counts, never transcript content. Every external call is wrapped so a
291
+ // missing binary / parse error / timeout returns null and NEVER throws — a verifier the
292
+ // machine doesn't have simply doesn't appear.
293
+ //
294
+ // Returns { ccusage, tokscale, tokendash } where each value is either null or an object
295
+ // keyed by window: { '7d':{...}, '30d':{...}, '90d':{...}, all:{...} } (windows that can't
296
+ // be derived from a source are omitted; 'all' is present whenever the source has data).
297
+
298
+ // ccusage: run `ccusage <platform> daily --json` and bucket the daily rows into the four
299
+ // windows by date. Logic copied from tools.mjs `_ccusagePillars` (do NOT import private fns).
300
+ function _freshCcusage(platform = 'claude') {
301
+ try {
302
+ const raw = execSync(`ccusage ${platform} daily --json`, { timeout: 15000, stdio: ['ignore', 'pipe', 'ignore'] }).toString()
303
+ const rows = JSON.parse(raw)?.daily ?? JSON.parse(raw)
304
+ if (!Array.isArray(rows)) return null
305
+ const now = Date.now()
306
+ const result = {}
307
+ for (const [win, days] of Object.entries({ '7d': 7, '30d': 30, '90d': 90 })) {
308
+ const since = new Date(now - days * DAY_MS)
309
+ let i = 0, o = 0, cw = 0, cr = 0
310
+ for (const r of rows) {
311
+ if (new Date(r.date ?? r.day ?? '1970') >= since) {
312
+ i += r.inputTokens ?? r.input_tokens ?? 0
313
+ o += r.outputTokens ?? r.output_tokens ?? 0
314
+ cw += r.cacheCreationTokens ?? r.cache_create_tokens ?? 0
315
+ cr += r.cacheReadTokens ?? r.cache_read_tokens ?? 0
316
+ }
317
+ }
318
+ result[win] = { input: i, output: o, cacheCreate: cw, cacheRead: cr }
319
+ }
320
+ let i = 0, o = 0, cw = 0, cr = 0
321
+ for (const r of rows) {
322
+ i += r.inputTokens ?? 0; o += r.outputTokens ?? 0; cw += r.cacheCreationTokens ?? 0; cr += r.cacheReadTokens ?? 0
323
+ }
324
+ result['all'] = { input: i, output: o, cacheCreate: cw, cacheRead: cr }
325
+ return result
326
+ } catch { return null }
327
+ }
328
+
329
+ // tokendash: REFRESH ~/.claude/token-dashboard.db by running the dashboard's scan, then read
330
+ // the live db via sqlite3. Claude only (the db only holds Claude Code sessions). The scan is
331
+ // best-effort: if it fails (tool missing, slow), we still read whatever the db currently has.
332
+ function _freshTokendash(platform = 'claude') {
333
+ if (platform !== 'claude') return null
334
+ const dbPath = join(homedir(), '.claude', 'token-dashboard.db')
335
+ const scanCli = join(homedir(), 'Desktop', 'token-dashboard', 'cli.py')
336
+ // Step 1 — refresh the db (best-effort; failures must not abort the read).
337
+ if (existsSync(scanCli)) {
338
+ try {
339
+ execSync(`python3 "${scanCli}" scan`, { timeout: 90000, stdio: ['ignore', 'ignore', 'ignore'] })
340
+ } catch { /* continue: read whatever the db currently has */ }
341
+ }
342
+ // Step 2 — read the (now-refreshed) db. all-time only; the db doesn't expose windowing here.
343
+ if (!existsSync(dbPath)) return null
344
+ try {
345
+ const raw = execSync(
346
+ `sqlite3 "${dbPath}" "SELECT SUM(input_tokens),SUM(output_tokens),SUM(cache_create_5m_tokens)+SUM(cache_create_1h_tokens),SUM(cache_read_tokens) FROM messages"`,
347
+ { timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] },
348
+ ).toString().trim()
349
+ const [i, o, cw, cr] = raw.split('|').map(Number)
350
+ if (![i, o, cw, cr].some((n) => Number.isFinite(n) && n > 0)) return null
351
+ return { all: { input: i || 0, output: o || 0, cacheCreate: cw || 0, cacheRead: cr || 0 } }
352
+ } catch { return null }
353
+ }
354
+
355
+ // tokscale: run `bunx tokscale@latest models --json` and sum the per-(client,model) rows for
356
+ // the requested platform. The JSON shape (verified live) is:
357
+ // { entries: [ { client, model, input, output, cacheRead, cacheWrite, ... }, ... ], ... }
358
+ // `client` maps directly to our platform name. Synthetic/unknown model rows are dropped (they
359
+ // carry no real usage). all-time only — tokscale's models view is not windowed. If no row
360
+ // matches the platform, return null gracefully.
361
+ function _freshTokscale(platform = 'claude') {
362
+ try {
363
+ const raw = execSync(`bunx tokscale@latest models --json`, { timeout: 60000, stdio: ['ignore', 'pipe', 'ignore'] }).toString()
364
+ const data = JSON.parse(raw)
365
+ const entries = Array.isArray(data?.entries) ? data.entries : (Array.isArray(data) ? data : [])
366
+ const rows = entries.filter((e) =>
367
+ e && e.client === platform && e.model !== '<synthetic>' && e.model !== 'unknown' &&
368
+ ((Number(e.input) || 0) > 0 || (Number(e.output) || 0) > 0),
369
+ )
370
+ if (!rows.length) return null
371
+ const acc = rows.reduce((a, e) => ({
372
+ input: a.input + (Number(e.input) || 0),
373
+ output: a.output + (Number(e.output) || 0),
374
+ cacheCreate: a.cacheCreate + (Number(e.cacheWrite) || 0),
375
+ cacheRead: a.cacheRead + (Number(e.cacheRead) || 0),
376
+ }), { input: 0, output: 0, cacheCreate: 0, cacheRead: 0 })
377
+ return { all: acc }
378
+ } catch { return null }
379
+ }
380
+
381
+ /**
382
+ * Fresh-pull all three external verifier sources for a platform. Each is run live (no stale
383
+ * snapshot) and returned as { window → {input,output,cacheCreate,cacheRead} } or null when the
384
+ * source is unavailable / has no data for the platform. Never throws — a failing source is null.
385
+ *
386
+ * - ccusage : `ccusage <platform> daily --json` → 7d/30d/90d/all
387
+ * - tokendash: refresh + read ~/.claude/token-dashboard.db → all (claude only)
388
+ * - tokscale : `bunx tokscale@latest models --json` → all
389
+ */
390
+ export async function freshVerifierPillars(platform = 'claude') {
391
+ const p = platform || 'claude'
392
+ // Each call is internally try/catch → null; await Promise.all of resolved values keeps the
393
+ // helper async (per the contract) without any individual failure rejecting the batch.
394
+ const [ccusage, tokendash, tokscale] = await Promise.all([
395
+ Promise.resolve().then(() => _freshCcusage(p)).catch(() => null),
396
+ Promise.resolve().then(() => _freshTokendash(p)).catch(() => null),
397
+ Promise.resolve().then(() => _freshTokscale(p)).catch(() => null),
398
+ ])
399
+ return { ccusage, tokscale, tokendash }
400
+ }
package/tools.mjs CHANGED
@@ -22,6 +22,12 @@ import path from 'node:path'
22
22
 
23
23
  // ── Verifier readers (sync, on-device, token-only) ────────────────────────────
24
24
  // These mirror the implementations in cli.mjs / tui.mjs without the circular import.
25
+ //
26
+ // NOTE (P3 2026-06-27): Intentionally separate from tokenpull.mjs `freshVerifierPillars()`.
27
+ // The MCP `tokenpull_compare` tool uses these file-based/cached readers (tokscale_report.json,
28
+ // direct db read) for a quick comparison, while freshVerifierPillars runs all sources live
29
+ // (bunx tokscale, scan+read tokendash) for the TUI/CLI dashboard. Different data sources =
30
+ // different behavior; do NOT merge without understanding the trade-off.
25
31
 
26
32
  function _ccusagePillars(platform = 'claude') {
27
33
  try {
@@ -113,6 +119,9 @@ async function pullByPlatform(platform, opts = {}) {
113
119
  export const DEFAULT_API_BASE = process.env.SIGRANK_API_BASE || 'https://signalaf.com'
114
120
  /** Default network timeout in ms (override via opts.fetchTimeout or SIGRANK_FETCH_TIMEOUT). */
115
121
  export const DEFAULT_FETCH_TIMEOUT = Number(process.env.SIGRANK_FETCH_TIMEOUT) || 10_000
122
+ /** Max accepted length for a single paste/string arg (chars). Token counts are tiny; anything
123
+ * past this is malformed or abusive — reject cleanly before parsing/POSTing (E2 hardening). */
124
+ const MAX_INPUT = 1_000_000
116
125
 
117
126
  export const TOOLS = [
118
127
  {
@@ -246,6 +255,13 @@ export const TOOLS = [
246
255
  // tokenpull window key → the board's window_type enum.
247
256
  const WINDOW_TYPE = { '7d': '7d', '30d': '30d', '90d': '90d', all: 'all_time' }
248
257
 
258
+ // E3: client-side auto-submit cooldown for watch_tokenpull. The server already dedups
259
+ // identical snapshots (exact hash → 422), but a noisy poll loop with submit:true could still
260
+ // churn the network/board with near-identical rows. Cap auto-submit to once per WATCH_SUBMIT_COOLDOWN_MS
261
+ // per window (in-memory, per process). Keyed by window so different windows don't block each other.
262
+ const WATCH_SUBMIT_COOLDOWN_MS = 5 * 60 * 1000
263
+ const _lastWatchSubmitAt = new Map()
264
+
249
265
  export async function callTool(name, args, opts = {}) {
250
266
  const apiBase = opts.apiBase || DEFAULT_API_BASE
251
267
  const timeoutMs = opts.fetchTimeout ?? DEFAULT_FETCH_TIMEOUT
@@ -390,6 +406,9 @@ export async function callTool(name, args, opts = {}) {
390
406
 
391
407
  if (name === 'submit_paste') {
392
408
  if (!args?.text) throw new Error('submit_paste requires a non-empty `text` argument.')
409
+ if (typeof args.text === 'string' && args.text.length > MAX_INPUT) {
410
+ return { status: 'error', reason: 'input_too_large', detail: `text exceeds ${MAX_INPUT} chars (${args.text.length}). Paste only the token-count table, not full output.` }
411
+ }
393
412
  // Local preview first — also validates the paste is parseable before any POST.
394
413
  const pillars = parsePillars(args.text)
395
414
  const c = withParseWarnings(pillars, cascade(pillars))
@@ -469,6 +488,13 @@ export async function callTool(name, args, opts = {}) {
469
488
  // in the same shape as tokenpull output for easy follow-up with tokenpull_submit.
470
489
  const WINDOW_KEYS = ['7d', '30d', '90d', 'all']
471
490
  const sourceTool = args?.source_tool || null
491
+ // E2: reject any oversized window paste up front (token tables are tiny).
492
+ for (const wk of WINDOW_KEYS) {
493
+ const v = args?.[wk]
494
+ if (typeof v === 'string' && v.length > MAX_INPUT) {
495
+ return { status: 'error', reason: 'input_too_large', detail: `window '${wk}' exceeds ${MAX_INPUT} chars (${v.length}). Paste only the token-count table.` }
496
+ }
497
+ }
472
498
  const windows = []
473
499
  for (const wk of WINDOW_KEYS) {
474
500
  const text = args?.[wk]
@@ -514,12 +540,22 @@ export async function callTool(name, args, opts = {}) {
514
540
  if (args?.submit === true) {
515
541
  const id = opts.identity || ensureIdentity()
516
542
  if (id.codename && id.operator_id && id.private_key_pkcs8_b64) {
517
- auth_submit = await submitSignedWindow(watchWindow, win.pillars, win.messages, id, {
518
- apiBase,
519
- fetchImpl: doFetch,
520
- platform: pulled.platform,
521
- now: opts.now,
522
- })
543
+ // E3: client-side cooldown — at most one auto-submit per window per 5 min. Prevents a
544
+ // fast poll loop from churning the board even before the server's hash-dedup kicks in.
545
+ const clockNow = typeof opts.now === 'number' ? opts.now : Date.now()
546
+ const last = _lastWatchSubmitAt.get(watchWindow)
547
+ if (last != null && clockNow - last < WATCH_SUBMIT_COOLDOWN_MS) {
548
+ const waitS = Math.ceil((WATCH_SUBMIT_COOLDOWN_MS - (clockNow - last)) / 1000)
549
+ auth_submit = { status: 'cooldown', detail: `auto-submit for '${watchWindow}' on cooldown — next in ~${waitS}s (max once / 5 min).`, retry_after_s: waitS }
550
+ } else {
551
+ _lastWatchSubmitAt.set(watchWindow, clockNow)
552
+ auth_submit = await submitSignedWindow(watchWindow, win.pillars, win.messages, id, {
553
+ apiBase,
554
+ fetchImpl: doFetch,
555
+ platform: pulled.platform,
556
+ now: opts.now,
557
+ })
558
+ }
523
559
  } else {
524
560
  auth_submit = { status: 'not_enrolled', detail: 'Run `npx sigrank-mcp enroll` to auto-submit verified runs.' }
525
561
  }