sigrank 0.14.2 โ 0.15.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 +15 -4
- package/adapters.mjs +6 -4
- package/cli.mjs +9 -4
- package/index.mjs +11 -6
- package/package.json +1 -1
- package/submit.mjs +14 -0
- package/tokenpull.mjs +7 -2
- package/tools.mjs +166 -13
package/README.md
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
> **๐ SigRank is live: [signalaf.com](https://signalaf.com)** โ the leaderboard for how
|
|
2
|
+
> efficiently you use AI, not how much. See your projected rank in 60 seconds at
|
|
3
|
+
> [signalaf.com/score](https://signalaf.com/score). *Token counts only. Never your prompts.*
|
|
4
|
+
|
|
1
5
|
<div align="center">
|
|
2
6
|
|
|
3
7
|
<p><img src="./.github/assets/og-card.png" alt="SigRank โ the new standard in AI evaluation & benchmarks" width="800"></p>
|
|
@@ -35,9 +39,12 @@ sigrank enroll
|
|
|
35
39
|
|
|
36
40
|
# 3. Submit your cascade to the board
|
|
37
41
|
sigrank submit
|
|
42
|
+
|
|
43
|
+
# (cautious? see exactly what would be sent โ four counts + a signature โ sending nothing)
|
|
44
|
+
sigrank submit --dry-run
|
|
38
45
|
```
|
|
39
46
|
|
|
40
|
-
That's it.
|
|
47
|
+
That's it. sigrank reads your local AI session logs on-device, derives your token cascade (ฮฅ Yield, Leverage, Velocity, 10xDEV), and publishes to [signalaf.com](https://signalaf.com). No paste, no transcript content โ only the four token counts leave your machine.
|
|
41
48
|
|
|
42
49
|
Or just explore without signing in:
|
|
43
50
|
|
|
@@ -133,6 +140,7 @@ Six tabs. Keys: `1`-`6` or `โ` `โ` to switch ยท `R` refresh ยท `Q` quit.
|
|
|
133
140
|
```bash
|
|
134
141
|
sigrank enroll # sign in: paste a connect code (get one at signalaf.com โ Settings)
|
|
135
142
|
sigrank submit # publish your verified runs to the board (sign in first)
|
|
143
|
+
sigrank submit --dry-run # inspect the exact signed payload without sending anything
|
|
136
144
|
```
|
|
137
145
|
|
|
138
146
|
Or do it inside the TUI on the **Connect** tab (`6`), then press `[S]` to submit.
|
|
@@ -177,7 +185,9 @@ Or if installed globally:
|
|
|
177
185
|
| `tokenpull_submit(codename, window?)` | `{codename?, window?}` | `tokenpull` โ publish to board. Omit codename for preview |
|
|
178
186
|
| `tokenpull_compare(platform?)` | `{platform?}` | All four sources side-by-side: tokenpull + ccusage + token-dash + tokscale. Returns pillars, cascade metrics, and delta % vs tokenpull per window |
|
|
179
187
|
| `rank_windows` | `{platform?, window?}` | Multi-window cascade from local logs |
|
|
180
|
-
| `watch_tokenpull` | `{platform?, interval_s?}` |
|
|
188
|
+
| `watch_tokenpull` | `{platform?, interval_s?}` | One cascade snapshot per call (interval_s advisory) |
|
|
189
|
+
| `submit_verified` | `{window?, platform?, dry_run?}` | THE ranked path: builds + ed25519-signs Schema 1.0 snapshots and POSTs them. `platform:'multi'` sums all active platforms. `dry_run:true` returns the exact payload unsent |
|
|
190
|
+
| `enroll` | `{code, device_label?}` | Bind this device with a connect code from signalaf.com โ Settings |
|
|
181
191
|
|
|
182
192
|
---
|
|
183
193
|
|
|
@@ -250,7 +260,8 @@ All adapters are token-only (no message content, no cost fields, no credentials)
|
|
|
250
260
|
- **Token-only, always.** No message content is ever read, logged, or transmitted โ only token counts (`input`, `output`, `cache_creation`, `cache_read`), message IDs, and timestamps.
|
|
251
261
|
- **Local by default.** `tokenpull` reads only `~/.claude/projects` (Claude) or `~/.codex` (Codex) on your device. Numbers stay on your machine unless you explicitly submit with a codename.
|
|
252
262
|
- **Background tooling excluded.** Memory plugins, observers, summarizers (e.g. `claude-mem`, `mem0`, `observer-sessions`) are filtered from both Claude and Codex reads. `subagents/` are kept โ they represent real operator work.
|
|
253
|
-
- **
|
|
263
|
+
- **Board reads are anonymous.** No account needed to browse, compare, or watch.
|
|
264
|
+
- **Ranked submissions are signed, not trusted.** `sigrank submit` requires a one-time `enroll` (device-bound ed25519 key โ the private key never leaves your machine). Verify what's sent with `sigrank submit --dry-run`: the payload is four token counts, ratios, and a signature.
|
|
254
265
|
|
|
255
266
|
---
|
|
256
267
|
|
|
@@ -288,7 +299,7 @@ Tests verify (29 assertions):
|
|
|
288
299
|
| File | Responsibility |
|
|
289
300
|
|---|---|
|
|
290
301
|
| `index.mjs` | Entry point โ TTY detection, routes to CLI or MCP server |
|
|
291
|
-
| `cli.mjs` | CLI commands: board,
|
|
302
|
+
| `cli.mjs` | CLI commands: board, compare, watch, enroll, submit, help |
|
|
292
303
|
| `tui.mjs` | Full tabbed TUI: Dashboard / Trends / Compare / Board / Watch / Connect |
|
|
293
304
|
| `cascade.mjs` | Pure cascade math (ฮฅ, SNR, leverage, velocity, 10xDEV, class) |
|
|
294
305
|
| `tokenpull.mjs` | On-device log scanner โ Claude, Codex, multi-platform |
|
package/adapters.mjs
CHANGED
|
@@ -35,10 +35,10 @@
|
|
|
35
35
|
import { readdir, readFile } from 'node:fs/promises'
|
|
36
36
|
import { join, extname } from 'node:path'
|
|
37
37
|
import { homedir } from 'node:os'
|
|
38
|
-
import {
|
|
38
|
+
import { execFile as execFileCb } from 'node:child_process'
|
|
39
39
|
import { promisify } from 'node:util'
|
|
40
40
|
|
|
41
|
-
const
|
|
41
|
+
const execFileP = promisify(execFileCb)
|
|
42
42
|
const DAY_MS = 86_400_000 // shared with tokenpull.mjs but kept local to avoid circular import
|
|
43
43
|
|
|
44
44
|
// โโ File-system helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
@@ -75,10 +75,12 @@ function* parseJsonl(text, filePath) {
|
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
/**
|
|
78
|
+
/** Run sqlite3 -json and return parsed rows, or [] on error/unavailability.
|
|
79
|
+
* execFile with an args array โ no shell, so dbPath/sql need no quoting/escaping
|
|
80
|
+
* (matches the execFile hardening used in tools.mjs / tokenpull.mjs). */
|
|
79
81
|
async function sqliteJson(dbPath, sql) {
|
|
80
82
|
try {
|
|
81
|
-
const { stdout } = await
|
|
83
|
+
const { stdout } = await execFileP('sqlite3', ['-json', dbPath, sql], { timeout: 10_000 })
|
|
82
84
|
return JSON.parse(stdout || '[]')
|
|
83
85
|
} catch { return [] }
|
|
84
86
|
}
|
package/cli.mjs
CHANGED
|
@@ -1309,7 +1309,7 @@ async function runSubmit({ platform = 'claude', window } = {}) {
|
|
|
1309
1309
|
|
|
1310
1310
|
let out
|
|
1311
1311
|
try {
|
|
1312
|
-
out = await callTool('submit_verified', { platform, window: typeof window === 'string' ? window : undefined })
|
|
1312
|
+
out = await callTool('submit_verified', { platform, window: typeof window === 'string' ? window : undefined, dry_run: process.argv.includes('--dry-run') })
|
|
1313
1313
|
} catch (e) {
|
|
1314
1314
|
writeln(red(` โ ${e.message}`))
|
|
1315
1315
|
process.exitCode = 1
|
|
@@ -1326,7 +1326,9 @@ async function runSubmit({ platform = 'claude', window } = {}) {
|
|
|
1326
1326
|
let anyReceivedNotRanked = false
|
|
1327
1327
|
for (const w of out.windows || []) {
|
|
1328
1328
|
const label = String(w.window).padEnd(8)
|
|
1329
|
-
if (w.
|
|
1329
|
+
if (w.status === 'dry_run') {
|
|
1330
|
+
writeln(` ${gold('โ')} ${bold(label)} ${gold('DRY RUN')} ${dim('ยท nothing sent')}`)
|
|
1331
|
+
} else if (w.ranked) {
|
|
1330
1332
|
anyRanked = true
|
|
1331
1333
|
writeln(` ${green('โ')} ${bold(label)} ${green('RANKED')} ${dim('ยท live on the board')}`)
|
|
1332
1334
|
} else if (w.status === 'received') {
|
|
@@ -1350,7 +1352,7 @@ export async function runCli(argv) {
|
|
|
1350
1352
|
|
|
1351
1353
|
// parse --key value AND --key=value flags. E4: validate against the known set and warn
|
|
1352
1354
|
// (don't crash) on anything unknown, so a typo'd flag is visible instead of silently ignored.
|
|
1353
|
-
const KNOWN_FLAGS = new Set(['window', 'platform', 'refresh', 'once', 'submit', 'compare', 'label'])
|
|
1355
|
+
const KNOWN_FLAGS = new Set(['window', 'platform', 'refresh', 'once', 'submit', 'compare', 'label', 'dry-run'])
|
|
1354
1356
|
const flags = {}
|
|
1355
1357
|
for (let i = 1; i < args.length; i++) {
|
|
1356
1358
|
const a = args[i]
|
|
@@ -1412,8 +1414,11 @@ export async function runCli(argv) {
|
|
|
1412
1414
|
// default: full unified view
|
|
1413
1415
|
await runSigRank()
|
|
1414
1416
|
} else {
|
|
1415
|
-
// unknown command: show help
|
|
1417
|
+
// unknown command: show help + exit non-zero so typos fail loudly (index.mjs now
|
|
1418
|
+
// routes ALL arg'd invocations here instead of falling through to the MCP server)
|
|
1419
|
+
writeln(red(`\n โ unknown command: ${cmd}`))
|
|
1416
1420
|
await showHelp()
|
|
1421
|
+
process.exit(2)
|
|
1417
1422
|
}
|
|
1418
1423
|
} catch (e) {
|
|
1419
1424
|
write(SHOW_CURSOR)
|
package/index.mjs
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* CLI shortcuts (optional โ the TUI never needs them):
|
|
11
11
|
* npx sigrank enroll sign in: redeem a connect code from signalaf.com
|
|
12
12
|
* npx sigrank submit publish your verified runs to the board
|
|
13
|
-
* npx sigrank board |
|
|
13
|
+
* npx sigrank board | compare | watch read / publish helpers
|
|
14
14
|
* npx sigrank --help full reference
|
|
15
15
|
*
|
|
16
16
|
* For AI clients (NOT human commands): in a piped/non-TTY context this starts an MCP
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
|
25
25
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
26
|
-
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
|
|
26
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'
|
|
27
27
|
import { TOOLS, callTool } from './tools.mjs'
|
|
28
28
|
import { runCli } from './cli.mjs'
|
|
29
29
|
import { readFileSync } from 'node:fs'
|
|
@@ -55,6 +55,11 @@ async function startMcpServer() {
|
|
|
55
55
|
const server = new Server({ name: 'sigrank', version: serverVersion() }, { capabilities: { tools: {} } })
|
|
56
56
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }))
|
|
57
57
|
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
58
|
+
// Unknown tool name = a client/host bug โ JSON-RPC -32602 protocol error, per the MCP
|
|
59
|
+
// spec. isError results (below) are reserved for tools that ran and failed.
|
|
60
|
+
if (!TOOLS.some((t) => t.name === req.params.name)) {
|
|
61
|
+
throw new McpError(ErrorCode.InvalidParams, `Unknown tool: ${req.params.name}`)
|
|
62
|
+
}
|
|
58
63
|
try {
|
|
59
64
|
const out = await callTool(req.params.name, req.params.arguments)
|
|
60
65
|
return { content: [{ type: 'text', text: JSON.stringify(out, null, 2) }] }
|
|
@@ -66,14 +71,14 @@ async function startMcpServer() {
|
|
|
66
71
|
}
|
|
67
72
|
|
|
68
73
|
// Route:
|
|
69
|
-
// any CLI
|
|
74
|
+
// any arg โ the CLI (runCli shows help + exits non-zero on unknown commands,
|
|
75
|
+
// so a typo never silently starts a hung MCP stdio server on a TTY)
|
|
70
76
|
// no args + TTY stdout โ full tabbed TUI (interactive use)
|
|
71
77
|
// no args + piped โ MCP stdio server (AI client use)
|
|
72
78
|
const cliArgs = process.argv.slice(2)
|
|
73
|
-
|
|
74
|
-
if (cliArgs.length > 0 && (CLI_COMMANDS.has(cliArgs[0]) || cliArgs[0].startsWith('--'))) {
|
|
79
|
+
if (cliArgs.length > 0) {
|
|
75
80
|
runCli(process.argv)
|
|
76
|
-
} else if (
|
|
81
|
+
} else if (process.stdout.isTTY) {
|
|
77
82
|
// Interactive terminal โ launch the full tabbed TUI
|
|
78
83
|
const { runTui } = await import('./tui.mjs')
|
|
79
84
|
runTui()
|
package/package.json
CHANGED
package/submit.mjs
CHANGED
|
@@ -130,6 +130,20 @@ export async function submitSignedWindow(windowKey, pillars, messages, identity,
|
|
|
130
130
|
const payload = buildPayload(windowKey, pillars, messages, identity, opts.platform || 'claude', opts)
|
|
131
131
|
const signature = signPayload(payload, identity.private_key_pkcs8_b64)
|
|
132
132
|
|
|
133
|
+
// Dry run: build + sign exactly as a real publish, then STOP before the POST.
|
|
134
|
+
// Returns the exact payload that would be sent โ the privacy proof ("token
|
|
135
|
+
// counts only") is inspectable, and nothing touches the network.
|
|
136
|
+
if (opts.dryRun) {
|
|
137
|
+
return {
|
|
138
|
+
status: 'dry_run',
|
|
139
|
+
window: WINDOW_TYPE[windowKey] || windowKey,
|
|
140
|
+
would_post: `${apiBase}/api/v1/snapshots`,
|
|
141
|
+
payload,
|
|
142
|
+
signature,
|
|
143
|
+
detail: 'Nothing sent. Re-run without dry_run to publish.',
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
133
147
|
let res
|
|
134
148
|
let ack
|
|
135
149
|
try {
|
package/tokenpull.mjs
CHANGED
|
@@ -41,7 +41,9 @@ const _envPath = `${_localBin}${process.env.PATH ? ':' + process.env.PATH : ''}`
|
|
|
41
41
|
// (ccusage, tokscale) are found even when sigrank isn't globally installed.
|
|
42
42
|
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
|
+
// 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) => {
|
|
45
47
|
if (err) reject(err)
|
|
46
48
|
else resolve(stdout.toString())
|
|
47
49
|
})
|
|
@@ -347,7 +349,10 @@ async function _freshCcusage(platform = 'claude') {
|
|
|
347
349
|
}
|
|
348
350
|
let i = 0, o = 0, cw = 0, cr = 0
|
|
349
351
|
for (const r of rows) {
|
|
350
|
-
i
|
|
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
|
|
351
356
|
}
|
|
352
357
|
result['all'] = { input: i, output: o, cacheCreate: cw, cacheRead: cr }
|
|
353
358
|
return result
|
package/tools.mjs
CHANGED
|
@@ -34,7 +34,9 @@ const _envPath = `${_localBin}${process.env.PATH ? ':' + process.env.PATH : ''}`
|
|
|
34
34
|
// are found even when sigrank isn't globally installed.
|
|
35
35
|
function execFileAsync(cmd, args, timeoutMs) {
|
|
36
36
|
return new Promise((resolve, reject) => {
|
|
37
|
-
|
|
37
|
+
// NOTE: execFile does not accept a `stdio` option (it always pipes + buffers
|
|
38
|
+
// stdout/stderr against maxBuffer) โ a previous `stdio` key here was silently ignored.
|
|
39
|
+
execFile(cmd, args, { timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024, env: { ...process.env, PATH: _envPath } }, (err, stdout) => {
|
|
38
40
|
if (err) reject(err)
|
|
39
41
|
else resolve(stdout.toString())
|
|
40
42
|
})
|
|
@@ -71,7 +73,10 @@ async function _ccusagePillars(platform = 'claude') {
|
|
|
71
73
|
}
|
|
72
74
|
let i=0,o=0,cw=0,cr=0
|
|
73
75
|
for (const r of rows) {
|
|
74
|
-
i+=r.inputTokens??
|
|
76
|
+
i += r.inputTokens ?? r.input_tokens ?? 0
|
|
77
|
+
o += r.outputTokens ?? r.output_tokens ?? 0
|
|
78
|
+
cw += r.cacheCreationTokens ?? r.cache_create_tokens ?? 0
|
|
79
|
+
cr += r.cacheReadTokens ?? r.cache_read_tokens ?? 0
|
|
75
80
|
}
|
|
76
81
|
result['all'] = { input: i, output: o, cacheCreate: cw, cacheRead: cr }
|
|
77
82
|
return result
|
|
@@ -231,17 +236,19 @@ export const TOOLS = [
|
|
|
231
236
|
all: { type: 'string', description: 'ccusage/tokscale paste or JSON for the all-time window (optional)' },
|
|
232
237
|
source_tool: { type: 'string', enum: ['ccusage', 'tokscale', 'claude_max', 'token_dashboard', 'other'], description: 'which token reader produced the paste (for cross-tool variance tracking)' },
|
|
233
238
|
},
|
|
239
|
+
// at least one window paste is required (runtime check backs this up)
|
|
240
|
+
anyOf: [{ required: ['7d'] }, { required: ['30d'] }, { required: ['90d'] }, { required: ['all'] }],
|
|
234
241
|
},
|
|
235
242
|
},
|
|
236
243
|
{
|
|
237
244
|
name: 'watch_tokenpull',
|
|
238
245
|
description:
|
|
239
|
-
'
|
|
246
|
+
'One poll per call: pulls your local token logs and returns the current cascade for the watched window โ the tool never blocks or loops. Re-call at your desired cadence to watch for changes (interval_s is advisory only and echoed back as poll_interval_s). With submit:true (and an enrolled device) each call may also sign + publish the watched window to the board, rate-limited to once per 5 min per platform+window; default is preview-only (no submit).',
|
|
240
247
|
inputSchema: {
|
|
241
248
|
type: 'object',
|
|
242
249
|
properties: {
|
|
243
250
|
platform: { type: 'string', enum: ALL_PLATFORMS, description: 'platform to watch (default: claude)' },
|
|
244
|
-
interval_s: { type: 'number', description: 'poll
|
|
251
|
+
interval_s: { type: 'number', description: 'advisory poll cadence in seconds (default: 60, min: 10) โ echoed back as poll_interval_s; does not make the call block or loop' },
|
|
245
252
|
window: { type: 'string', enum: ['7d', '30d', '90d', 'all'], description: 'which window to watch (default: 7d โ most sensitive to recent activity)' },
|
|
246
253
|
submit: { type: 'boolean', description: 'auto-submit the watched window to the board as a VERIFIED operator each poll (requires `enroll`; default false = preview only)' },
|
|
247
254
|
},
|
|
@@ -278,15 +285,41 @@ export const TOOLS = [
|
|
|
278
285
|
{
|
|
279
286
|
name: 'submit_verified',
|
|
280
287
|
description:
|
|
281
|
-
'Publish your LOCAL token runs to the SigRank board as a VERIFIED operator โ the enrolled, signed path. Reads your pillars (tokenpull), builds the canonical Schema 1.0 snapshot per window, ed25519-signs it with your device key, and POSTs to /api/v1/snapshots. Requires `npx sigrank-mcp enroll` first (a bound device). Only signed submissions from a trusted device rank on the board. Token-only; the private key never leaves your machine.',
|
|
288
|
+
'Publish your LOCAL token runs to the SigRank board as a VERIFIED operator โ the enrolled, signed path. Reads your pillars (tokenpull), builds the canonical Schema 1.0 snapshot per window, ed25519-signs it with your device key, and POSTs to /api/v1/snapshots. Requires `npx sigrank-mcp enroll` first (a bound device). Only signed submissions from a trusted device rank on the board. Token-only; the private key never leaves your machine. Pass dry_run:true to inspect the exact signed payload without publishing.',
|
|
282
289
|
inputSchema: {
|
|
283
290
|
type: 'object',
|
|
284
291
|
properties: {
|
|
285
292
|
window: { type: 'string', enum: ['7d', '30d', '90d', 'all'], description: 'submit only this window (default: all 4)' },
|
|
286
|
-
platform: { type: 'string', enum: ALL_PLATFORMS, description:
|
|
293
|
+
platform: { type: 'string', enum: [...ALL_PLATFORMS, 'multi'], description: "source platform (default: claude). 'multi' = combined cascade summed across all locally-detected platforms (needs 2+ active); empty windows are skipped." },
|
|
294
|
+
dry_run: { type: 'boolean', description: 'build + sign but do NOT publish โ returns the exact payload that would be POSTed (token counts only), so you can inspect before submitting' },
|
|
287
295
|
},
|
|
288
296
|
},
|
|
289
297
|
},
|
|
298
|
+
{
|
|
299
|
+
name: 'simulate_change',
|
|
300
|
+
description:
|
|
301
|
+
"The first PRESCRIPTIVE SigRank tool โ 'what if I changed my token mix?' Takes your current 4 pillars (input/output/cacheCreate/cacheRead) and one or more proposed changes, runs the canonical cascade on BOTH the current and simulated values, and returns the exact ฮฅ Yield delta, class change, and per-metric diffs. This is the 'show me the payoff before I do the work' primitive: no network, no submission, pure local math. Use it to answer 'would increasing my cache-read by 50k tokens actually move my class?' before you change your workflow. Accepts the current pillars as JSON or 4 numbers (same as rank_paste) plus a `changes` object with any of the 4 pillar names mapped to new absolute values OR relative deltas (e.g. {cacheRead: '+50000'} or {input: 800000}).",
|
|
302
|
+
inputSchema: {
|
|
303
|
+
type: 'object',
|
|
304
|
+
properties: {
|
|
305
|
+
text: {
|
|
306
|
+
type: 'string',
|
|
307
|
+
description: 'Current token pillars โ ccusage JSON or "input output cacheCreate cacheRead" (same format as rank_paste).',
|
|
308
|
+
},
|
|
309
|
+
changes: {
|
|
310
|
+
type: 'object',
|
|
311
|
+
description: 'Proposed changes to apply. Keys: input, output, cacheCreate, cacheRead. Values are either absolute numbers (replace) or strings starting with +/- for relative deltas (add/subtract). Omitted pillars are unchanged.',
|
|
312
|
+
properties: {
|
|
313
|
+
input: { type: ['number', 'string'], description: 'new input token count (absolute) or "+/-N" for a relative delta' },
|
|
314
|
+
output: { type: ['number', 'string'], description: 'new output token count (absolute) or "+/-N" for a relative delta' },
|
|
315
|
+
cacheCreate: { type: ['number', 'string'], description: 'new cache-create token count (absolute) or "+/-N" for a relative delta' },
|
|
316
|
+
cacheRead: { type: ['number', 'string'], description: 'new cache-read token count (absolute) or "+/-N" for a relative delta' },
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
required: ['text', 'changes'],
|
|
321
|
+
},
|
|
322
|
+
},
|
|
290
323
|
]
|
|
291
324
|
|
|
292
325
|
// tokenpull window key โ the board's window_type enum.
|
|
@@ -295,7 +328,9 @@ const WINDOW_TYPE = { '7d': '7d', '30d': '30d', '90d': '90d', all: 'all_time' }
|
|
|
295
328
|
// E3: client-side auto-submit cooldown for watch_tokenpull. The server already dedups
|
|
296
329
|
// identical snapshots (exact hash โ 422), but a noisy poll loop with submit:true could still
|
|
297
330
|
// churn the network/board with near-identical rows. Cap auto-submit to once per WATCH_SUBMIT_COOLDOWN_MS
|
|
298
|
-
// per window (in-memory, per process). Keyed by window so different
|
|
331
|
+
// per platform+window (in-memory, per process). Keyed by platform:window so different
|
|
332
|
+
// platforms/windows don't block each other, and only armed on a non-error submit so a
|
|
333
|
+
// network failure doesn't lock out retries for 5 minutes.
|
|
299
334
|
const WATCH_SUBMIT_COOLDOWN_MS = 5 * 60 * 1000
|
|
300
335
|
const _lastWatchSubmitAt = new Map()
|
|
301
336
|
|
|
@@ -348,6 +383,10 @@ export async function callTool(name, args, opts = {}) {
|
|
|
348
383
|
|
|
349
384
|
if (name === 'rank_paste') {
|
|
350
385
|
if (!args?.text) throw new Error('rank_paste requires a non-empty `text` argument.')
|
|
386
|
+
// E2: reject oversized pastes before parsing (parity with submit_paste / rank_windows).
|
|
387
|
+
if (typeof args.text === 'string' && args.text.length > MAX_INPUT) {
|
|
388
|
+
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.` }
|
|
389
|
+
}
|
|
351
390
|
const pillars = parsePillars(args.text)
|
|
352
391
|
const c = withParseWarnings(pillars, cascade(pillars))
|
|
353
392
|
return { ...c, card: narrate(c) }
|
|
@@ -438,7 +477,7 @@ export async function callTool(name, args, opts = {}) {
|
|
|
438
477
|
msgs += w.messages || 0
|
|
439
478
|
}
|
|
440
479
|
if (sum.input + sum.output + sum.cacheCreate + sum.cacheRead <= 0) continue // skip empty window
|
|
441
|
-
const r = await submitSignedWindow(wk, sum, msgs, id, { apiBase, fetchImpl: doFetch, platform: 'multi', now: opts.now })
|
|
480
|
+
const r = await submitSignedWindow(wk, sum, msgs, id, { apiBase, fetchImpl: doFetch, platform: 'multi', now: opts.now, dryRun: !!args?.dry_run })
|
|
442
481
|
out.push({ window: wk, pillars: sum, ...r })
|
|
443
482
|
}
|
|
444
483
|
return { platform: 'multi', codename: id.codename, operator_id: id.operator_id, sources: detected.map((d) => d.platform), windows: out }
|
|
@@ -453,6 +492,7 @@ export async function callTool(name, args, opts = {}) {
|
|
|
453
492
|
fetchImpl: doFetch,
|
|
454
493
|
platform: pulled.platform,
|
|
455
494
|
now: opts.now,
|
|
495
|
+
dryRun: !!args?.dry_run,
|
|
456
496
|
})
|
|
457
497
|
out.push({ window: w.window, pillars: w.pillars, ...r })
|
|
458
498
|
}
|
|
@@ -599,21 +639,23 @@ export async function callTool(name, args, opts = {}) {
|
|
|
599
639
|
if (args?.submit === true) {
|
|
600
640
|
const id = opts.identity || ensureIdentity()
|
|
601
641
|
if (id.codename && id.operator_id && id.private_key_pkcs8_b64) {
|
|
602
|
-
// E3: client-side cooldown โ at most one auto-submit per window per 5 min.
|
|
603
|
-
// fast poll loop from churning the board even before the server's
|
|
642
|
+
// E3: client-side cooldown โ at most one auto-submit per platform+window per 5 min.
|
|
643
|
+
// Prevents a fast poll loop from churning the board even before the server's
|
|
644
|
+
// hash-dedup kicks in. Armed only on a non-error outcome so failed submits retry.
|
|
604
645
|
const clockNow = typeof opts.now === 'number' ? opts.now : Date.now()
|
|
605
|
-
const
|
|
646
|
+
const cdKey = `${pulled.platform}:${watchWindow}`
|
|
647
|
+
const last = _lastWatchSubmitAt.get(cdKey)
|
|
606
648
|
if (last != null && clockNow - last < WATCH_SUBMIT_COOLDOWN_MS) {
|
|
607
649
|
const waitS = Math.ceil((WATCH_SUBMIT_COOLDOWN_MS - (clockNow - last)) / 1000)
|
|
608
|
-
auth_submit = { status: 'cooldown', detail: `auto-submit for '${
|
|
650
|
+
auth_submit = { status: 'cooldown', detail: `auto-submit for '${cdKey}' on cooldown โ next in ~${waitS}s (max once / 5 min).`, retry_after_s: waitS }
|
|
609
651
|
} else {
|
|
610
|
-
_lastWatchSubmitAt.set(watchWindow, clockNow)
|
|
611
652
|
auth_submit = await submitSignedWindow(watchWindow, win.pillars, win.messages, id, {
|
|
612
653
|
apiBase,
|
|
613
654
|
fetchImpl: doFetch,
|
|
614
655
|
platform: pulled.platform,
|
|
615
656
|
now: opts.now,
|
|
616
657
|
})
|
|
658
|
+
if (auth_submit?.status !== 'error') _lastWatchSubmitAt.set(cdKey, clockNow)
|
|
617
659
|
}
|
|
618
660
|
} else {
|
|
619
661
|
auth_submit = { status: 'not_enrolled', detail: 'Run `npx sigrank-mcp enroll` to auto-submit verified runs.' }
|
|
@@ -701,5 +743,116 @@ export async function callTool(name, args, opts = {}) {
|
|
|
701
743
|
}
|
|
702
744
|
}
|
|
703
745
|
|
|
746
|
+
if (name === 'simulate_change') {
|
|
747
|
+
// The first prescriptive tool โ "what if I changed my token mix?"
|
|
748
|
+
// Pure local math: parse current pillars, apply proposed changes, run the
|
|
749
|
+
// cascade on both, return the delta. No network, no submission.
|
|
750
|
+
if (!args?.text) throw new Error('simulate_change requires a non-empty `text` argument (current pillars).')
|
|
751
|
+
if (typeof args.text === 'string' && args.text.length > MAX_INPUT) {
|
|
752
|
+
return { status: 'error', reason: 'input_too_large', detail: `text exceeds ${MAX_INPUT} chars.` }
|
|
753
|
+
}
|
|
754
|
+
if (!args?.changes || typeof args.changes !== 'object') {
|
|
755
|
+
throw new Error('simulate_change requires a `changes` object with at least one pillar change.')
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
const currentPillars = parsePillars(args.text)
|
|
759
|
+
const current = withParseWarnings(currentPillars, cascade(currentPillars))
|
|
760
|
+
|
|
761
|
+
// Apply changes: each pillar is either an absolute number (replace) or a
|
|
762
|
+
// string starting with +/- (relative delta). Omitted pillars are unchanged.
|
|
763
|
+
const PILLAR_KEYS = ['input', 'output', 'cacheCreate', 'cacheRead']
|
|
764
|
+
const simulated = { ...currentPillars }
|
|
765
|
+
const appliedChanges = {}
|
|
766
|
+
|
|
767
|
+
for (const key of PILLAR_KEYS) {
|
|
768
|
+
if (args.changes[key] == null) continue
|
|
769
|
+
const raw = args.changes[key]
|
|
770
|
+
let newVal
|
|
771
|
+
|
|
772
|
+
if (typeof raw === 'number') {
|
|
773
|
+
newVal = raw
|
|
774
|
+
} else if (typeof raw === 'string') {
|
|
775
|
+
const trimmed = raw.trim()
|
|
776
|
+
if (trimmed.startsWith('+') || trimmed.startsWith('-')) {
|
|
777
|
+
// Relative delta: add/subtract from current value
|
|
778
|
+
const delta = Number(trimmed)
|
|
779
|
+
if (!Number.isFinite(delta)) {
|
|
780
|
+
return { status: 'error', reason: 'invalid_change', detail: `changes.${key}: "${raw}" is not a valid relative delta.` }
|
|
781
|
+
}
|
|
782
|
+
newVal = currentPillars[key] + delta
|
|
783
|
+
} else {
|
|
784
|
+
// Absolute value as a string
|
|
785
|
+
newVal = Number(trimmed)
|
|
786
|
+
}
|
|
787
|
+
} else {
|
|
788
|
+
return { status: 'error', reason: 'invalid_change', detail: `changes.${key}: expected number or string, got ${typeof raw}.` }
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
if (!Number.isFinite(newVal)) {
|
|
792
|
+
return { status: 'error', reason: 'invalid_change', detail: `changes.${key}: result is not a finite number.` }
|
|
793
|
+
}
|
|
794
|
+
// Clamp to non-negative โ token counts can't be negative
|
|
795
|
+
if (newVal < 0) {
|
|
796
|
+
return { status: 'error', reason: 'invalid_change', detail: `changes.${key}: result ${newVal} is negative โ token counts must be >= 0.` }
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
simulated[key] = newVal
|
|
800
|
+
appliedChanges[key] = {
|
|
801
|
+
from: currentPillars[key],
|
|
802
|
+
to: newVal,
|
|
803
|
+
delta: newVal - currentPillars[key],
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
if (Object.keys(appliedChanges).length === 0) {
|
|
808
|
+
return { status: 'error', reason: 'no_changes', detail: 'No pillar changes specified in the `changes` object.' }
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
const simulatedResult = cascade(simulated)
|
|
812
|
+
|
|
813
|
+
// Compute deltas for every cascade metric
|
|
814
|
+
const metricDelta = (curr, sim) => {
|
|
815
|
+
if (curr == null && sim == null) return null
|
|
816
|
+
if (curr == null) return { from: null, to: sim, delta: null }
|
|
817
|
+
if (sim == null) return { from: curr, to: null, delta: null }
|
|
818
|
+
return { from: curr, to: sim, delta: Number((sim - curr).toFixed(4)) }
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
const classChanged = current.class !== simulatedResult.class
|
|
822
|
+
|
|
823
|
+
return {
|
|
824
|
+
current: {
|
|
825
|
+
pillars: currentPillars,
|
|
826
|
+
yield: current.yield,
|
|
827
|
+
snr: current.snr,
|
|
828
|
+
leverage: current.leverage,
|
|
829
|
+
velocity: current.velocity,
|
|
830
|
+
dev10x: current.dev10x,
|
|
831
|
+
class: current.class,
|
|
832
|
+
},
|
|
833
|
+
simulated: {
|
|
834
|
+
pillars: { input: simulated.input, output: simulated.output, cacheCreate: simulated.cacheCreate, cacheRead: simulated.cacheRead },
|
|
835
|
+
yield: simulatedResult.yield,
|
|
836
|
+
snr: simulatedResult.snr,
|
|
837
|
+
leverage: simulatedResult.leverage,
|
|
838
|
+
velocity: simulatedResult.velocity,
|
|
839
|
+
dev10x: simulatedResult.dev10x,
|
|
840
|
+
class: simulatedResult.class,
|
|
841
|
+
},
|
|
842
|
+
changes: appliedChanges,
|
|
843
|
+
deltas: {
|
|
844
|
+
yield: metricDelta(current.yield, simulatedResult.yield),
|
|
845
|
+
snr: metricDelta(current.snr, simulatedResult.snr),
|
|
846
|
+
leverage: metricDelta(current.leverage, simulatedResult.leverage),
|
|
847
|
+
velocity: metricDelta(current.velocity, simulatedResult.velocity),
|
|
848
|
+
dev10x: metricDelta(current.dev10x, simulatedResult.dev10x),
|
|
849
|
+
},
|
|
850
|
+
class_changed: classChanged,
|
|
851
|
+
...(classChanged ? { class_transition: `${current.class} โ ${simulatedResult.class}` } : {}),
|
|
852
|
+
...(simulatedResult.warnings ? { simulated_warnings: simulatedResult.warnings } : {}),
|
|
853
|
+
note: 'Local simulation only โ no submission. The actual score depends on server-side RS.xx weights and class thresholds.',
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
704
857
|
throw new Error(`Unknown tool: ${name}`)
|
|
705
858
|
}
|