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 +2 -2
- package/cli.mjs +150 -65
- package/package.json +1 -1
- package/tokenpull.mjs +118 -0
- package/tools.mjs +42 -6
- package/tui.mjs +413 -186
package/README.md
CHANGED
|
@@ -67,7 +67,7 @@ sigrank
|
|
|
67
67
|
## Commands
|
|
68
68
|
|
|
69
69
|
```
|
|
70
|
-
⊙ SigRank CLI v0.
|
|
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
|
-
| `
|
|
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 —
|
|
16
|
-
* npx sigrank watch --
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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
|
-
|
|
707
|
-
const changed = prev !== null && prev !== cas?.yield
|
|
736
|
+
write(HIDE_CURSOR)
|
|
708
737
|
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
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(`${
|
|
753
|
+
push(` ${gold('⊙ SigRank')} ${bold('Watch')} ${dim(`${scopeLabel} · ${ts}`)}`)
|
|
728
754
|
push(` ${dim('─'.repeat(w - 4))}`)
|
|
729
|
-
push()
|
|
730
755
|
|
|
731
|
-
|
|
732
|
-
|
|
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
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
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
|
-
|
|
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 —
|
|
1217
|
-
writeln(` ${cyan('watch --
|
|
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
|
|
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
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
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
|
|
1367
|
-
window: flags.window
|
|
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
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
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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
|
}
|