sigrank 0.12.5 → 0.13.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/README.md +1 -1
- package/cli.mjs +60 -22
- package/package.json +5 -2
- package/tokenpull.mjs +19 -12
- package/tools.mjs +28 -3
package/README.md
CHANGED
package/cli.mjs
CHANGED
|
@@ -29,17 +29,25 @@ import { classify } from './cascade.mjs'
|
|
|
29
29
|
import { ensureIdentity, keystorePath } from './keystore.mjs'
|
|
30
30
|
import { submitSignedWindow } from './submit.mjs'
|
|
31
31
|
import { execFile } from 'child_process'
|
|
32
|
-
import { existsSync, readFileSync
|
|
32
|
+
import { existsSync, readFileSync } from 'fs'
|
|
33
33
|
import os from 'os'
|
|
34
34
|
import path from 'path'
|
|
35
|
+
import { fileURLToPath } from 'url'
|
|
36
|
+
|
|
37
|
+
// Resolve local node_modules/.bin for bundled deps (ccusage, tokscale)
|
|
38
|
+
const _pkgRoot = path.dirname(fileURLToPath(import.meta.url))
|
|
39
|
+
const _localBin = path.join(_pkgRoot, 'node_modules', '.bin')
|
|
40
|
+
const _envPath = `${_localBin}${process.env.PATH ? ':' + process.env.PATH : ''}`
|
|
35
41
|
|
|
36
42
|
// ASYNC FIX (2026-06-27): execFile wrapped in a Promise — replaces execSync for
|
|
37
43
|
// defense-in-depth (shell injection prevention). execFile passes args as an
|
|
38
44
|
// array, so no shell parsing occurs — even if platform contained special chars,
|
|
39
45
|
// they'd be treated as literal arguments, not shell commands.
|
|
46
|
+
// BIN FIX (2026-06-27): PATH includes local node_modules/.bin so bundled deps
|
|
47
|
+
// are found even when sigrank isn't globally installed.
|
|
40
48
|
function execFileAsync(cmd, args, timeoutMs) {
|
|
41
49
|
return new Promise((resolve, reject) => {
|
|
42
|
-
execFile(cmd, args, { timeout: timeoutMs, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 10 * 1024 * 1024 }, (err, stdout) => {
|
|
50
|
+
execFile(cmd, args, { timeout: timeoutMs, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 10 * 1024 * 1024, env: { ...process.env, PATH: _envPath } }, (err, stdout) => {
|
|
43
51
|
if (err) reject(err)
|
|
44
52
|
else resolve(stdout.toString())
|
|
45
53
|
})
|
|
@@ -460,8 +468,28 @@ async function ccusagePillars(platform = 'claude') {
|
|
|
460
468
|
}
|
|
461
469
|
}
|
|
462
470
|
|
|
463
|
-
function tokscalePillars(platform = 'claude') {
|
|
464
|
-
//
|
|
471
|
+
async function tokscalePillars(platform = 'claude') {
|
|
472
|
+
// Try the bundled tokscale CLI first (fresh data), fall back to saved report file.
|
|
473
|
+
try {
|
|
474
|
+
const raw = await execFileAsync('tokscale', ['models', '--json'], 60000)
|
|
475
|
+
const data = JSON.parse(raw)
|
|
476
|
+
const entries = Array.isArray(data?.entries) ? data.entries : (Array.isArray(data) ? data : [])
|
|
477
|
+
const rows = entries.filter(e =>
|
|
478
|
+
e && e.client === platform &&
|
|
479
|
+
e.model !== '<synthetic>' && e.model !== 'unknown' &&
|
|
480
|
+
((Number(e.input) || 0) > 0 || (Number(e.output) || 0) > 0)
|
|
481
|
+
)
|
|
482
|
+
if (rows.length) {
|
|
483
|
+
const p = rows.reduce((acc, e) => ({
|
|
484
|
+
input: acc.input + (Number(e.input) || 0),
|
|
485
|
+
output: acc.output + (Number(e.output) || 0),
|
|
486
|
+
cacheCreate: acc.cacheCreate + (Number(e.cacheWrite) || 0),
|
|
487
|
+
cacheRead: acc.cacheRead + (Number(e.cacheRead) || 0),
|
|
488
|
+
}), { input: 0, output: 0, cacheCreate: 0, cacheRead: 0 })
|
|
489
|
+
return { all: p }
|
|
490
|
+
}
|
|
491
|
+
} catch { /* fall through to file-based read */ }
|
|
492
|
+
// Fallback: read saved tokscale_report.json — all-time only (no timestamps in export)
|
|
465
493
|
const reportPath = path.join(os.homedir(), 'tokscale_report.json')
|
|
466
494
|
if (!existsSync(reportPath)) return null
|
|
467
495
|
try {
|
|
@@ -509,24 +537,34 @@ function appPillars() {
|
|
|
509
537
|
async function tokenDashPillars() {
|
|
510
538
|
const dbPath = path.join(os.homedir(), '.claude', 'token-dashboard.db')
|
|
511
539
|
if (!existsSync(dbPath)) return null
|
|
540
|
+
// Read directly with sqlite3 — no python script needed (bundled dep).
|
|
541
|
+
// Windowed queries using sqlite3's datetime() function.
|
|
542
|
+
const cf = "(model LIKE '%claude%' OR model LIKE '%fable%' OR model LIKE '%sonnet%' OR model LIKE '%opus%' OR model LIKE '%haiku%')"
|
|
512
543
|
try {
|
|
513
|
-
const
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
544
|
+
const wins = [
|
|
545
|
+
['7d', "datetime('now','-7 days')"],
|
|
546
|
+
['30d', "datetime('now','-30 days')"],
|
|
547
|
+
['90d', "datetime('now','-90 days')"],
|
|
548
|
+
]
|
|
549
|
+
const sql = (cutoff) =>
|
|
550
|
+
`SELECT SUM(input_tokens),SUM(output_tokens),SUM(cache_create_5m_tokens+cache_create_1h_tokens),SUM(cache_read_tokens) FROM messages WHERE timestamp>=${cutoff} AND ${cf}`
|
|
551
|
+
const allSql = `SELECT SUM(input_tokens),SUM(output_tokens),SUM(cache_create_5m_tokens+cache_create_1h_tokens),SUM(cache_read_tokens) FROM messages WHERE ${cf}`
|
|
552
|
+
const [d7, d30, d90, dAll] = await Promise.all([
|
|
553
|
+
execFileAsync('sqlite3', [dbPath, sql(wins[0][1])], 5000),
|
|
554
|
+
execFileAsync('sqlite3', [dbPath, sql(wins[1][1])], 5000),
|
|
555
|
+
execFileAsync('sqlite3', [dbPath, sql(wins[2][1])], 5000),
|
|
556
|
+
execFileAsync('sqlite3', [dbPath, allSql], 5000),
|
|
557
|
+
])
|
|
558
|
+
const parse = (raw) => {
|
|
559
|
+
const [i,o,cw,cr] = raw.trim().split('|').map(Number)
|
|
560
|
+
return { input: i||0, output: o||0, cacheCreate: cw||0, cacheRead: cr||0 }
|
|
561
|
+
}
|
|
562
|
+
return {
|
|
563
|
+
'7d': parse(d7),
|
|
564
|
+
'30d': parse(d30),
|
|
565
|
+
'90d': parse(d90),
|
|
566
|
+
all: parse(dAll),
|
|
567
|
+
}
|
|
530
568
|
} catch {
|
|
531
569
|
return null
|
|
532
570
|
}
|
|
@@ -577,7 +615,7 @@ async function runCompare({ platform = 'claude' } = {}) {
|
|
|
577
615
|
ccusagePillars(platform).catch(() => null),
|
|
578
616
|
callTool('tokenpull', { platform }).catch(() => null),
|
|
579
617
|
(platform === 'claude' ? tokenDashPillars() : Promise.resolve(null)).catch(() => null),
|
|
580
|
-
|
|
618
|
+
tokscalePillars(platform).catch(() => null),
|
|
581
619
|
Promise.resolve(platform === 'claude' ? appPillars() : null),
|
|
582
620
|
])
|
|
583
621
|
write(CURSOR_UP(1) + ERASE_LINE)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sigrank",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
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",
|
|
@@ -41,6 +41,9 @@
|
|
|
41
41
|
"LICENSE"
|
|
42
42
|
],
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@modelcontextprotocol/sdk": "^1.0.0"
|
|
44
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
45
|
+
"@zhangferry-dev/tokendash": "^1.7.5",
|
|
46
|
+
"ccusage": "^20.0.14",
|
|
47
|
+
"tokscale": "^4.0.4"
|
|
45
48
|
}
|
|
46
49
|
}
|
package/tokenpull.mjs
CHANGED
|
@@ -18,20 +18,30 @@
|
|
|
18
18
|
import { readdir, readFile, lstat } from 'node:fs/promises'
|
|
19
19
|
import { existsSync } from 'node:fs'
|
|
20
20
|
import { execFile } from 'node:child_process'
|
|
21
|
-
import { join } from 'node:path'
|
|
21
|
+
import { join, dirname } from 'node:path'
|
|
22
22
|
import { homedir } from 'node:os'
|
|
23
|
+
import { fileURLToPath } from 'node:url'
|
|
23
24
|
import { ADAPTERS } from './adapters.mjs'
|
|
24
25
|
|
|
25
26
|
const DAY_MS = 86_400_000
|
|
26
27
|
|
|
28
|
+
// Resolve the package root for finding bundled binaries (ccusage, tokscale, etc.)
|
|
29
|
+
const _pkgRoot = join(dirname(fileURLToPath(import.meta.url)))
|
|
30
|
+
const _localBin = join(_pkgRoot, 'node_modules', '.bin')
|
|
31
|
+
// Prepend local node_modules/.bin to PATH so bundled deps are found even when
|
|
32
|
+
// not globally installed (e.g., npx sigrank, local dev).
|
|
33
|
+
const _envPath = `${_localBin}${process.env.PATH ? ':' + process.env.PATH : ''}`
|
|
34
|
+
|
|
27
35
|
// ASYNC FIX (2026-06-27): execFile wrapped in a Promise — replaces execSync in the
|
|
28
36
|
// fresh verifier readers. execSync blocks the entire Node event loop (no key handling,
|
|
29
37
|
// no screen repaint for up to 90s during the tokendash scan). execFile is async: the
|
|
30
38
|
// event loop keeps running, so the TUI stays responsive while external commands run.
|
|
31
39
|
// Returns stdout as a string. Rejects on error or timeout (caller catches → null).
|
|
40
|
+
// BIN FIX (2026-06-27): PATH includes local node_modules/.bin so bundled deps
|
|
41
|
+
// (ccusage, tokscale) are found even when sigrank isn't globally installed.
|
|
32
42
|
function execFileAsync(cmd, args, timeoutMs) {
|
|
33
43
|
return new Promise((resolve, reject) => {
|
|
34
|
-
execFile(cmd, args, { timeout: timeoutMs, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 10 * 1024 * 1024 }, (err, stdout) => {
|
|
44
|
+
execFile(cmd, args, { timeout: timeoutMs, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 10 * 1024 * 1024, env: { ...process.env, PATH: _envPath } }, (err, stdout) => {
|
|
35
45
|
if (err) reject(err)
|
|
36
46
|
else resolve(stdout.toString())
|
|
37
47
|
})
|
|
@@ -354,13 +364,9 @@ async function _freshCcusage(platform = 'claude') {
|
|
|
354
364
|
async function _freshTokendash(platform = 'claude') {
|
|
355
365
|
if (platform !== 'claude') return null
|
|
356
366
|
const dbPath = join(homedir(), '.claude', 'token-dashboard.db')
|
|
357
|
-
|
|
358
|
-
//
|
|
359
|
-
|
|
360
|
-
try { await execFileAsync('python3', [scanCli, 'scan'], 90000) }
|
|
361
|
-
catch { /* continue: read whatever the db currently has */ }
|
|
362
|
-
}
|
|
363
|
-
// Step 2 — read the (now-refreshed) db. all-time only; the db doesn't expose windowing here.
|
|
367
|
+
// Read the token-dashboard DB directly with sqlite3 (no external python scan
|
|
368
|
+
// needed — the DB is created by the tokendash dashboard, now bundled as a dep).
|
|
369
|
+
// all-time only; the db doesn't expose windowing here.
|
|
364
370
|
if (!existsSync(dbPath)) return null
|
|
365
371
|
try {
|
|
366
372
|
const raw = await execFileAsync('sqlite3', [dbPath,
|
|
@@ -372,8 +378,9 @@ async function _freshTokendash(platform = 'claude') {
|
|
|
372
378
|
} catch { return null }
|
|
373
379
|
}
|
|
374
380
|
|
|
375
|
-
// tokscale: run `
|
|
376
|
-
// the requested platform. The JSON shape
|
|
381
|
+
// tokscale: run `tokscale models --json` (bundled as npm dep — no bunx needed)
|
|
382
|
+
// and sum the per-(client,model) rows for the requested platform. The JSON shape
|
|
383
|
+
// (verified live) is:
|
|
377
384
|
// { entries: [ { client, model, input, output, cacheRead, cacheWrite, ... }, ... ], ... }
|
|
378
385
|
// `client` maps directly to our platform name. Synthetic/unknown model rows are dropped (they
|
|
379
386
|
// carry no real usage). all-time only — tokscale's models view is not windowed. If no row
|
|
@@ -383,7 +390,7 @@ async function _freshTokendash(platform = 'claude') {
|
|
|
383
390
|
// Now uses execFile (async) so the TUI stays responsive.
|
|
384
391
|
async function _freshTokscale(platform = 'claude') {
|
|
385
392
|
try {
|
|
386
|
-
const raw = await execFileAsync('
|
|
393
|
+
const raw = await execFileAsync('tokscale', ['models', '--json'], 60000)
|
|
387
394
|
const data = JSON.parse(raw)
|
|
388
395
|
const entries = Array.isArray(data?.entries) ? data.entries : (Array.isArray(data) ? data : [])
|
|
389
396
|
const rows = entries.filter((e) =>
|
package/tools.mjs
CHANGED
|
@@ -19,14 +19,22 @@ import { execFile } from 'node:child_process'
|
|
|
19
19
|
import { existsSync, readFileSync } from 'node:fs'
|
|
20
20
|
import os from 'node:os'
|
|
21
21
|
import path from 'node:path'
|
|
22
|
+
import { fileURLToPath } from 'node:url'
|
|
23
|
+
|
|
24
|
+
// Resolve local node_modules/.bin for bundled deps (ccusage, tokscale)
|
|
25
|
+
const _pkgRoot = path.dirname(fileURLToPath(import.meta.url))
|
|
26
|
+
const _localBin = path.join(_pkgRoot, 'node_modules', '.bin')
|
|
27
|
+
const _envPath = `${_localBin}${process.env.PATH ? ':' + process.env.PATH : ''}`
|
|
22
28
|
|
|
23
29
|
// ASYNC FIX (2026-06-27): execFile wrapped in a Promise — replaces execSync for
|
|
24
30
|
// defense-in-depth (shell injection prevention + non-blocking). The platform param
|
|
25
31
|
// is enum-validated at the MCP schema level, but execFile also prevents shell
|
|
26
32
|
// interpolation attacks by passing args as an array (no shell parsing).
|
|
33
|
+
// BIN FIX (2026-06-27): PATH includes local node_modules/.bin so bundled deps
|
|
34
|
+
// are found even when sigrank isn't globally installed.
|
|
27
35
|
function execFileAsync(cmd, args, timeoutMs) {
|
|
28
36
|
return new Promise((resolve, reject) => {
|
|
29
|
-
execFile(cmd, args, { timeout: timeoutMs, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 10 * 1024 * 1024 }, (err, stdout) => {
|
|
37
|
+
execFile(cmd, args, { timeout: timeoutMs, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 10 * 1024 * 1024, env: { ...process.env, PATH: _envPath } }, (err, stdout) => {
|
|
30
38
|
if (err) reject(err)
|
|
31
39
|
else resolve(stdout.toString())
|
|
32
40
|
})
|
|
@@ -82,7 +90,24 @@ async function _tokenDashPillars() {
|
|
|
82
90
|
} catch { return null }
|
|
83
91
|
}
|
|
84
92
|
|
|
85
|
-
function _tokscalePillars(platform = 'claude') {
|
|
93
|
+
async function _tokscalePillars(platform = 'claude') {
|
|
94
|
+
// Try the bundled tokscale CLI first (fresh data), fall back to saved report file.
|
|
95
|
+
try {
|
|
96
|
+
const raw = await execFileAsync('tokscale', ['models', '--json'], 60000)
|
|
97
|
+
const data = JSON.parse(raw)
|
|
98
|
+
const entries = Array.isArray(data?.entries) ? data.entries : (Array.isArray(data) ? data : [])
|
|
99
|
+
const rows = entries.filter(e =>
|
|
100
|
+
e && e.client === platform && e.model !== '<synthetic>' && e.model !== 'unknown' && ((Number(e.input) || 0) > 0 || (Number(e.output) || 0) > 0)
|
|
101
|
+
)
|
|
102
|
+
if (rows.length) {
|
|
103
|
+
const acc = rows.reduce((a,e) => ({
|
|
104
|
+
input: a.input+(Number(e.input)||0), output: a.output+(Number(e.output)||0),
|
|
105
|
+
cacheCreate: a.cacheCreate+(Number(e.cacheWrite)||0), cacheRead: a.cacheRead+(Number(e.cacheRead)||0),
|
|
106
|
+
}), { input:0, output:0, cacheCreate:0, cacheRead:0 })
|
|
107
|
+
return { all: acc }
|
|
108
|
+
}
|
|
109
|
+
} catch { /* fall through to file-based read */ }
|
|
110
|
+
// Fallback: read saved tokscale_report.json if it exists
|
|
86
111
|
const p = path.join(os.homedir(), 'tokscale_report.json')
|
|
87
112
|
if (!existsSync(p)) return null
|
|
88
113
|
try {
|
|
@@ -595,7 +620,7 @@ export async function callTool(name, args, opts = {}) {
|
|
|
595
620
|
pullByPlatform(platform, opts).catch(() => null),
|
|
596
621
|
_ccusagePillars(platform).catch(() => null),
|
|
597
622
|
(platform === 'claude' ? _tokenDashPillars() : Promise.resolve(null)).catch(() => null),
|
|
598
|
-
|
|
623
|
+
_tokscalePillars(platform).catch(() => null),
|
|
599
624
|
])
|
|
600
625
|
|
|
601
626
|
// Build tokenpull window lookup
|