sigrank 0.17.5 → 0.18.3

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
@@ -66,10 +66,17 @@ For all builders, burners and 10xers.
66
66
  | **[sigrank-vscode](https://github.com/SunrisesIllNeverSee/sigrank-vscode)** | The IDE extension — see your cascade metrics inline in VS Code. | `code --install-extension sigrank.sigrank` |
67
67
  | **[fundscore](https://github.com/SunrisesIllNeverSee/fundscore)** | The repo scorer — investor-readiness scoring for GitHub repos. CLI + MCP server. | `npx fundscore` |
68
68
 
69
+ ### Also in the MO§ES™ suite
70
+
71
+ | Site | What it is |
72
+ | ---- | ---------- |
73
+ | **[SIGNOMY](https://signomy.xyz)** | Governed AI agent marketplace where ranked agents form teams, fill slots, run missions, and earn revenue under constitutional protocol. Agents are free. Operators pay. |
74
+ | **[MO§ES](https://mos2es.com)** | The governance framework that underpins SigRank, SIGNOMY, and all governed agent operations. Structural accountability for agentic systems. |
75
+
69
76
  ## Quickstart — 3 steps to the board
70
77
 
71
78
  ```bash
72
- # 1. Install (pulls ccusage + tokscale + tokendash automatically — no separate installs)
79
+ # 1. Install (pulls ccusage + tokscale automatically — no separate installs)
73
80
  npm install -g sigrank
74
81
 
75
82
  # 2. Sign in (paste a connect code from signalaf.com → Settings → New key)
@@ -147,7 +154,7 @@ smithery tool call sigrank rank_paste '{"text": "1000000 500000 50000 800000"}'
147
154
  ## Commands
148
155
 
149
156
  ```
150
- ⊙ SigRank CLI v0.17.5
157
+ ⊙ SigRank CLI v0.0.177
151
158
 
152
159
  Default (no args)
153
160
  sigrank unified dashboard: cascade + token pillars + board
@@ -296,7 +303,7 @@ The dashboard pulls from multiple sources and shows them side-by-side for verifi
296
303
  | ----------------- | ------------------------------------------------------------------------------ | --------------------- |
297
304
  | `tokenpull` | On-device JSONL scanner (canon source) | claude, codex, amp, … |
298
305
  | `ccusage` | `ccusage <platform> daily --json` CLI (bundled) | claude, codex |
299
- | `token-dashboard` | `~/.claude/token-dashboard.db` SQLite (bundled) | claude only |
306
+ | `token-dashboard` | `~/.claude/token-dashboard.db` SQLite ([Nate's](https://github.com/nateherkai/token-dashboard)) | claude only |
300
307
  | `tokscale` | `tokscale models --json` CLI (bundled, falls back to `~/tokscale_report.json`) | claude, codex |
301
308
 
302
309
  **Codex input is estimated** — Codex logs don't expose true input tokens directly. The formula:
package/adapters.mjs CHANGED
@@ -97,10 +97,11 @@ function* parseJsonl(text, filePath) {
97
97
  /** Run sqlite3 -json and return parsed rows, or [] on error/unavailability.
98
98
  * execFile with an args array — no shell, so dbPath/sql need no quoting/escaping
99
99
  * (matches the execFile hardening used in tools.mjs / tokenpull.mjs). */
100
- async function sqliteJson(dbPath, sql) {
100
+ async function sqliteJson(dbPath, sql, timeoutMs = 10_000) {
101
101
  try {
102
102
  const { stdout } = await execFileP("sqlite3", ["-json", dbPath, sql], {
103
- timeout: 10_000,
103
+ timeout: timeoutMs,
104
+ maxBuffer: 256 * 1024 * 1024, // 256MB — Devin's sessions.db yields ~15MB JSON
104
105
  });
105
106
  return JSON.parse(stdout || "[]");
106
107
  } catch {
@@ -729,6 +730,110 @@ export const hermesAdapter = {
729
730
  },
730
731
  };
731
732
 
733
+ // ── 14. Devin CLI ────────────────────────────────────────────────────────────
734
+ // SQLite: ~/.local/share/devin/cli/sessions.db
735
+ // Same combined-input problem as Codex: input_tokens INCLUDES cache write, so we
736
+ // yield { ts, output, cacheRead, uncached } and let tokenpullCodex() do the
737
+ // ioRatio split (input = output × ioRatio, cacheCreate = uncached − input).
738
+ // ioRatio comes from Claude (Beta) or the 7:1:2 average (Alpha = 0.5).
739
+ export const devinAdapter = {
740
+ platform: "devin",
741
+ defaultRoot: () => join(homedir(), ".local", "share", "devin", "cli"),
742
+ async *records(root) {
743
+ for (const r of roots("DEVIN_HOME", root)) {
744
+ const dbPath = join(r, "sessions.db");
745
+ const rows = await sqliteJson(
746
+ dbPath,
747
+ `SELECT row_id, session_id,
748
+ json_extract(chat_message, '$.metadata.metrics.input_tokens') as input_tokens,
749
+ json_extract(chat_message, '$.metadata.metrics.output_tokens') as output_tokens,
750
+ json_extract(chat_message, '$.metadata.metrics.cache_read_tokens') as cache_read_tokens,
751
+ json_extract(chat_message, '$.metadata.created_at') as created_at
752
+ FROM message_nodes
753
+ WHERE json_extract(chat_message, '$.role') = 'assistant'
754
+ AND json_extract(chat_message, '$.metadata.metrics.input_tokens') IS NOT NULL
755
+ ORDER BY created_at`,
756
+ 60_000,
757
+ );
758
+ for (const row of rows) {
759
+ const inputIncl = Number(row.input_tokens || 0);
760
+ const cached = Number(row.cache_read_tokens || 0);
761
+ const output = Number(row.output_tokens || 0);
762
+ if (inputIncl + output + cached === 0) continue;
763
+ yield {
764
+ ts: row.created_at || null,
765
+ output,
766
+ cacheRead: cached,
767
+ uncached: Math.max(0, inputIncl - cached),
768
+ file: dbPath,
769
+ };
770
+ }
771
+ }
772
+ },
773
+ };
774
+
775
+ // ── 15. Other (user-supplied JSON) ───────────────────────────────────────────
776
+ // For platforms/models not yet on the adapter list. The user writes a JSON file
777
+ // with the 4 pillars per window and points SIGRANK_OTHER_PATH at it. This lets
778
+ // anyone with a new tool submit without needing a custom adapter.
779
+ //
780
+ // JSON format (all fields optional, missing windows are skipped):
781
+ // {
782
+ // "platform": "my-tool", // optional, defaults to "other"
783
+ // "windows": {
784
+ // "all": { "input": 1000, "output": 2000, "cacheCreate": 500, "cacheRead": 8000 },
785
+ // "30d": { "input": 500, "output": 1000, "cacheCreate": 200, "cacheRead": 4000 },
786
+ // "7d": { "input": 100, "output": 200, "cacheCreate": 50, "cacheRead": 800 }
787
+ // }
788
+ // }
789
+ //
790
+ // The adapter yields one "message" per window with the summed pillars. Since
791
+ // there's no per-message timestamp, all data lands in the "all" window only
792
+ // (same as null-timestamp records in other adapters).
793
+ export const otherAdapter = {
794
+ platform: "other",
795
+ defaultRoot: () => process.env.SIGRANK_OTHER_PATH || "",
796
+ async *messages(root) {
797
+ const filePath = root || process.env.SIGRANK_OTHER_PATH;
798
+ if (!filePath) {
799
+ throw new Error(
800
+ "other adapter: set SIGRANK_OTHER_PATH to a JSON file with your token pillars. " +
801
+ 'Format: { "windows": { "all": { "input": N, "output": N, "cacheCreate": N, "cacheRead": N } } }',
802
+ );
803
+ }
804
+ let text;
805
+ try {
806
+ text = await readFile(filePath, "utf8");
807
+ } catch {
808
+ throw new Error(`other adapter: cannot read ${filePath} — check SIGRANK_OTHER_PATH`);
809
+ }
810
+ let data;
811
+ try {
812
+ data = JSON.parse(text);
813
+ } catch {
814
+ throw new Error(`other adapter: invalid JSON in ${filePath}`);
815
+ }
816
+ const windows = data.windows || {};
817
+ for (const [win, pillars] of Object.entries(windows)) {
818
+ const input = Number(pillars.input || 0);
819
+ const output = Number(pillars.output || 0);
820
+ const cacheCreate = Number(pillars.cacheCreate || pillars.cache_create || 0);
821
+ const cacheRead = Number(pillars.cacheRead || pillars.cache_read || 0);
822
+ if (input + output + cacheCreate + cacheRead === 0) continue;
823
+ yield {
824
+ id: `other:${win}`,
825
+ sid: `other-${win}`,
826
+ ts: null, // no timestamp → lands in "all" window only
827
+ input,
828
+ output,
829
+ cacheCreate,
830
+ cacheRead,
831
+ file: filePath,
832
+ };
833
+ }
834
+ },
835
+ };
836
+
732
837
  // ── Registry ──────────────────────────────────────────────────────────────────
733
838
  /** All non-Claude, non-Codex adapters keyed by platform ID. */
734
839
  export const ADAPTERS = {
@@ -745,6 +850,8 @@ export const ADAPTERS = {
745
850
  goose: gooseAdapter,
746
851
  kilo: kiloAdapter,
747
852
  hermes: hermesAdapter,
853
+ devin: devinAdapter,
854
+ other: otherAdapter,
748
855
  };
749
856
 
750
857
  export const ALL_PLATFORMS = Object.keys(ADAPTERS).concat(["claude", "codex"]);
package/cli.mjs CHANGED
@@ -236,7 +236,10 @@ async function fetchBoard(window = "30d") {
236
236
  const res = await fetch(
237
237
  `${DEFAULT_API_BASE}/api/v1/leaderboard?window=${window}&metric=yield_`,
238
238
  {
239
- headers: { accept: "application/json" },
239
+ headers: {
240
+ accept: "application/json",
241
+ "user-agent": `node/${process.version} sigrank-mcp/cli`,
242
+ },
240
243
  },
241
244
  );
242
245
  if (!res.ok) throw new Error(`Board API → HTTP ${res.status}`);
@@ -491,7 +494,7 @@ function appPillars() {
491
494
  async function tokenDashPillars() {
492
495
  const dbPath = path.join(os.homedir(), ".claude", "token-dashboard.db");
493
496
  if (!existsSync(dbPath)) return null;
494
- // Read directly with sqlite3 — no python script needed (bundled dep).
497
+ // Read directly with sqlite3 — DB created by Nate's token-dashboard (github.com/nateherkai/token-dashboard).
495
498
  // Windowed queries using sqlite3's datetime() function.
496
499
  const cf =
497
500
  "(model LIKE '%claude%' OR model LIKE '%fable%' OR model LIKE '%sonnet%' OR model LIKE '%opus%' OR model LIKE '%haiku%')";
@@ -934,6 +937,7 @@ async function runWatch({
934
937
  const ALL_PLATFORMS = [
935
938
  "claude",
936
939
  "codex",
940
+ "devin",
937
941
  "amp",
938
942
  "gemini",
939
943
  "kimi",
@@ -944,8 +948,10 @@ const ALL_PLATFORMS = [
944
948
  "droid",
945
949
  "codebuff",
946
950
  "copilot",
951
+ "opencode",
947
952
  "openclaw",
948
953
  "pi",
954
+ "other",
949
955
  ];
950
956
 
951
957
  async function runSigRank() {
@@ -1472,6 +1478,7 @@ async function runSigRank() {
1472
1478
  headers: {
1473
1479
  "content-type": "application/json",
1474
1480
  accept: "application/json",
1481
+ "user-agent": `node/${process.version} sigrank-mcp/cli`,
1475
1482
  },
1476
1483
  body: JSON.stringify({
1477
1484
  codename,
package/index.mjs CHANGED
@@ -194,7 +194,7 @@ async function startMcpServer() {
194
194
  uri: "sigrank://install-guide",
195
195
  name: "Quick Start Guide",
196
196
  description:
197
- "Step-by-step install and first submission: npx sigrank → enroll → submit. Covers ccusage/tokscale/tokendash bundled deps, privacy model (token counts only, never prompts), and dry-run workflow.",
197
+ "Step-by-step install and first submission: npx sigrank → enroll → submit. Covers ccusage/tokscale bundled deps + token-dashboard (Nate's, github.com/nateherkai/token-dashboard), privacy model (token counts only, never prompts), and dry-run workflow.",
198
198
  mimeType: "text/markdown",
199
199
  },
200
200
  {
@@ -289,14 +289,20 @@ No global install needed. The MCP server starts automatically when an AI client
289
289
  npx sigrank submit --dry-run
290
290
  \`\`\`
291
291
 
292
- ## Token Pull Sources (bundled)
292
+ ## Token Pull Sources
293
293
 
294
- SigRank bundles three token readers as dependencies:
294
+ SigRank bundles two token readers as npm dependencies:
295
295
  - **ccusage** — Claude Code session logs
296
296
  - **tokscale** — multi-platform token telemetry
297
- - **tokendash** — dashboard + TUI for token usage
298
297
 
299
- No separate install needed. All three are called automatically by the tokenpull tools.
298
+ A third source, **token-dashboard**, is read from \`~/.claude/token-dashboard.db\` (SQLite).
299
+ That DB is created by [Nate's token-dashboard](https://github.com/nateherkai/token-dashboard) (Python):
300
+ \`\`\`bash
301
+ git clone https://github.com/nateherkai/token-dashboard.git
302
+ cd token-dashboard
303
+ python3 cli.py dashboard
304
+ \`\`\`
305
+ Run the scan once to create the DB; SigRank reads it directly via sqlite3 after that.
300
306
 
301
307
  ## MCP Client Setup
302
308
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sigrank",
3
- "version": "0.17.5",
3
+ "version": "0.18.3",
4
4
  "author": "SunrisesIllNeverSee (https://github.com/SunrisesIllNeverSee)",
5
5
  "description": "SigRank MCP server — the yield cascade + live leaderboard as MCP tools any agent can call",
6
6
  "mcpName": "io.github.SunrisesIllNeverSee/sigrank-mcp",
@@ -64,8 +64,7 @@
64
64
  ],
65
65
  "dependencies": {
66
66
  "@modelcontextprotocol/sdk": "^1.0.0",
67
- "@zhangferry-dev/tokendash": "^1.7.5",
68
- "ccusage": "^20.0.14",
69
- "tokscale": "^4.0.4"
67
+ "ccusage": "^20.0.17",
68
+ "tokscale": "^4.5.2"
70
69
  }
71
70
  }
package/submit.mjs CHANGED
@@ -11,9 +11,22 @@
11
11
 
12
12
  import { snapshotHash, signPayload } from "./sign.mjs";
13
13
  import { preflight } from "./preflight.mjs";
14
+ import { readFileSync } from "node:fs";
14
15
 
15
16
  const WINDOW_TYPE = { "7d": "7d", "30d": "30d", "90d": "90d", all: "all_time" };
16
17
  const WINDOW_SPAN_DAYS = { "7d": 7, "30d": 30, "90d": 90, all_time: 3650 };
18
+
19
+ /** Resolve this package's version for the User-Agent stamp (best-effort). */
20
+ function _pkgVersion() {
21
+ try {
22
+ const pkg = JSON.parse(
23
+ readFileSync(new URL("./package.json", import.meta.url), "utf-8"),
24
+ );
25
+ return pkg.version;
26
+ } catch {
27
+ return "unknown";
28
+ }
29
+ }
17
30
  const PLATFORM_ENUM = new Set([
18
31
  "claude",
19
32
  "chatgpt",
@@ -181,8 +194,13 @@ export async function submitSignedWindow(
181
194
  ((url, init = {}) => {
182
195
  const ctrl = new AbortController();
183
196
  const timer = setTimeout(() => ctrl.abort(), 15_000);
197
+ const headers = {
198
+ "user-agent": `node/${process.version} sigrank-mcp/${_pkgVersion()}`,
199
+ ...(init.headers || {}),
200
+ };
184
201
  return fetch(url, {
185
202
  ...init,
203
+ headers,
186
204
  signal: init.signal || ctrl.signal,
187
205
  }).finally(() => clearTimeout(timer));
188
206
  });
package/tokenpull.mjs CHANGED
@@ -343,7 +343,7 @@ export async function tokenpullCodex({
343
343
  });
344
344
 
345
345
  return {
346
- platform: "codex",
346
+ platform: adapter.platform || "codex",
347
347
  root: r,
348
348
  generatedAt: new Date(nowMs).toISOString(),
349
349
  files: files.size,
@@ -367,13 +367,12 @@ export async function tokenpullCodex({
367
367
  export async function tokenpullAny(platform, opts = {}) {
368
368
  if (!platform || platform === "claude")
369
369
  return tokenpull({ adapter: claudeAdapter, ...opts });
370
- if (platform === "codex") {
371
- // Auto-derive io_ratio from the operator's Claude data when not explicitly provided.
370
+ // Codex + Devin: both combine input + cache write in input_tokens, so both go
371
+ // through tokenpullCodex() which splits via ioRatio (Beta from Claude, Alpha 2.0).
372
+ if (platform === "codex" || platform === "devin") {
372
373
  let ioRatio = opts.ioRatio || 2.0;
373
374
  if (!opts.ioRatio) {
374
375
  try {
375
- // Forward root/now from opts so the Beta-ratio derivation is testable
376
- // (without this, it always reads the real ~/.claude and can't be injected).
377
376
  const c = await tokenpull({
378
377
  adapter: claudeAdapter,
379
378
  root: opts.root,
@@ -386,15 +385,8 @@ export async function tokenpullAny(platform, opts = {}) {
386
385
  /* no Claude data → Alpha 2.0 */
387
386
  }
388
387
  }
389
- return tokenpullCodex({ ioRatio, ...opts });
390
- }
391
- // Cloud agents (Devin, etc.) run server-side — no local JSONL to read
392
- const CLOUD_AGENTS = { devin: "Cognition/Devin" };
393
- if (platform in CLOUD_AGENTS) {
394
- throw new Error(
395
- `"${platform}" (${CLOUD_AGENTS[platform]}) runs in the cloud — sessions are not written to local JSONL files. ` +
396
- `There is no local data source to read. If ${CLOUD_AGENTS[platform]} exposes a usage API in future, an adapter can be added.`,
397
- );
388
+ const adapter = platform === "codex" ? codexAdapter : ADAPTERS.devin;
389
+ return tokenpullCodex({ adapter, ioRatio, ...opts });
398
390
  }
399
391
  const adapter = ADAPTERS[platform];
400
392
  if (!adapter)
@@ -486,7 +478,7 @@ async function _freshTokendash(platform = "claude") {
486
478
  if (platform !== "claude") return null;
487
479
  const dbPath = join(homedir(), ".claude", "token-dashboard.db");
488
480
  // Read the token-dashboard DB directly with sqlite3 (no external python scan
489
- // needed — the DB is created by the tokendash dashboard, now bundled as a dep).
481
+ // needed — the DB is created by Nate's token-dashboard, github.com/nateherkai/token-dashboard).
490
482
  // all-time only; the db doesn't expose windowing here.
491
483
  if (!existsSync(dbPath)) return null;
492
484
  try {
package/tools.mjs CHANGED
@@ -26,7 +26,7 @@ import { ALL_PLATFORMS } from "./adapters.mjs";
26
26
  import { ensureIdentity, recordEnrollment } from "./keystore.mjs";
27
27
  import { submitSignedWindow } from "./submit.mjs";
28
28
  import { createHash } from "node:crypto";
29
- import { execFile } from "node:child_process";
29
+ import { execFile, execFileSync } from "node:child_process";
30
30
  import { existsSync, readFileSync } from "node:fs";
31
31
  import os from "node:os";
32
32
  import path from "node:path";
@@ -256,6 +256,56 @@ export const DEFAULT_FETCH_TIMEOUT =
256
256
  * past this is malformed or abusive — reject cleanly before parsing/POSTing (E2 hardening). */
257
257
  const MAX_INPUT = 1_000_000;
258
258
 
259
+ /** Resolve this package's version for the User-Agent stamp (best-effort). */
260
+ function agentVersionStamp() {
261
+ try {
262
+ const pkg = JSON.parse(
263
+ readFileSync(new URL("./package.json", import.meta.url), "utf-8"),
264
+ );
265
+ return pkg.version;
266
+ } catch {
267
+ return "unknown";
268
+ }
269
+ }
270
+
271
+ /**
272
+ * curl-based fetch fallback for when Node's fetch is blocked by Vercel's bot
273
+ * protection (TLS fingerprinting). Returns a Response-like object with .ok,
274
+ * .status, .json(), .text(). Uses execFileSync for synchronous curl execution.
275
+ */
276
+ function curlFetch(url, init = {}, timeoutMs = 10_000) {
277
+ const args = ["-s", "-S", "-w", "\n__HTTP_STATUS__%{http_code}", "--max-time", String(Math.ceil(timeoutMs / 1000))];
278
+ if (init.method) args.push("-X", init.method);
279
+ const headers = init.headers || {};
280
+ for (const [k, v] of Object.entries(headers)) {
281
+ args.push("-H", `${k}: ${v}`);
282
+ }
283
+ if (init.body) {
284
+ args.push("-d", init.body);
285
+ }
286
+ args.push(url);
287
+ let stdout;
288
+ try {
289
+ stdout = execFileSync("curl", args, {
290
+ encoding: "utf-8",
291
+ maxBuffer: 10 * 1024 * 1024,
292
+ timeout: timeoutMs + 2000,
293
+ });
294
+ } catch (e) {
295
+ throw new Error(`curl transport failed: ${e.message}`);
296
+ }
297
+ // Parse the status code from the trailer
298
+ const statusMatch = stdout.match(/__HTTP_STATUS__(\d+)$/);
299
+ const status = statusMatch ? parseInt(statusMatch[1], 10) : 0;
300
+ const bodyText = statusMatch ? stdout.slice(0, statusMatch.index) : stdout;
301
+ return {
302
+ ok: status >= 200 && status < 300,
303
+ status,
304
+ json: async () => { try { return JSON.parse(bodyText); } catch { return {}; } },
305
+ text: async () => bodyText,
306
+ };
307
+ }
308
+
259
309
  // ── Smithery quality: output schemas + annotations ──────────────────────────
260
310
  // MCP tool annotations hint to clients about side-effects, read-only status, etc.
261
311
  const ANNOTATIONS = {
@@ -747,8 +797,8 @@ export const TOOLS = [
747
797
  properties: {
748
798
  platform: {
749
799
  type: "string",
750
- enum: ALL_PLATFORMS,
751
- description: `source platform (default: claude). Supported: ${ALL_PLATFORMS.join(", ")}. codex is estimated via io_ratio. Some platforms need setup (e.g. copilot requires COPILOT_OTEL_ENABLED=true).`,
800
+ enum: [...ALL_PLATFORMS, "multi"],
801
+ description: `source platform (default: claude). Supported: ${ALL_PLATFORMS.join(", ")}, multi. 'multi' = combined cascade summed across all locally-detected platforms (needs 2+ active). 'devin' reads from ~/.local/share/devin/cli/sessions.db (SQLite, all windows). 'codex' is estimated via io_ratio. 'other' reads from a user-supplied JSON file (set SIGRANK_OTHER_PATH). Some platforms need setup (e.g. copilot requires COPILOT_OTEL_ENABLED=true).`,
752
802
  },
753
803
  },
754
804
  },
@@ -780,8 +830,8 @@ export const TOOLS = [
780
830
  },
781
831
  platform: {
782
832
  type: "string",
783
- enum: ALL_PLATFORMS,
784
- description: `Source platform to pull from (default: claude). Supported: ${ALL_PLATFORMS.join(", ")}. Each platform reads its own session logs locally.`,
833
+ enum: [...ALL_PLATFORMS, "multi"],
834
+ description: `Source platform to pull from (default: claude). Supported: ${ALL_PLATFORMS.join(", ")}, multi. 'multi' = combined cascade summed across all locally-detected platforms (needs 2+ active). 'devin' reads from ~/.local/share/devin/cli/sessions.db (SQLite, all windows). 'other' reads from a user-supplied JSON file (set SIGRANK_OTHER_PATH). Each platform reads its own session logs locally.`,
785
835
  },
786
836
  },
787
837
  },
@@ -1333,9 +1383,73 @@ const _lastWatchSubmitAt = new Map();
1333
1383
  // keeps only platforms with real local data, and sorts claude → codex → rest.
1334
1384
  // Pass `platforms` to scope it (e.g. ['claude'] for a fast first paint).
1335
1385
  export async function pullActivePlatforms({ platforms } = {}, opts = {}) {
1336
- const targets = platforms && platforms.length ? platforms : ALL_PLATFORMS;
1386
+ // If the caller specified explicit platforms, use those.
1387
+ if (platforms && platforms.length) {
1388
+ return _pullExplicit(platforms, opts);
1389
+ }
1390
+ // Auto-detect: run tokscale once to discover all active clients, then pull
1391
+ // only those. This is much faster than trying all 17 adapters (most fail
1392
+ // silently on a machine that doesn't have that tool installed).
1393
+ //
1394
+ // Flow: tokscale surfaces ALL clients → map to our platform names →
1395
+ // ccusage is primary for Claude (more accurate) → other platforms use
1396
+ // their adapters. Anything in tokscale NOT in ccusage gets included.
1397
+ const detected = await _tokscaleDetectClients().catch(() => null);
1398
+ if (detected && detected.length > 0) {
1399
+ return _pullExplicit(detected, opts);
1400
+ }
1401
+ // Fallback: tokscale not installed or failed → try all adapters (old behavior)
1402
+ return _pullExplicit(ALL_PLATFORMS, opts);
1403
+ }
1404
+
1405
+ /** Run `tokscale models --json` and return the list of our platform names. */
1406
+ async function _tokscaleDetectClients() {
1407
+ const raw = await execFileAsync("tokscale", ["models", "--json"], 60000);
1408
+ const data = JSON.parse(raw);
1409
+ const entries = Array.isArray(data?.entries)
1410
+ ? data.entries
1411
+ : Array.isArray(data)
1412
+ ? data
1413
+ : [];
1414
+ if (!entries.length) return [];
1415
+ // Map tokscale client names → our platform names
1416
+ const TOKSCALE_CLIENT_MAP = {
1417
+ claude: "claude",
1418
+ "devin-cli": "devin",
1419
+ codex: "codex",
1420
+ copilot: "copilot",
1421
+ gemini: "gemini",
1422
+ amp: "amp",
1423
+ kimi: "kimi",
1424
+ qwen: "qwen",
1425
+ goose: "goose",
1426
+ kilo: "kilo",
1427
+ hermes: "hermes",
1428
+ droid: "droid",
1429
+ codebuff: "codebuff",
1430
+ opencode: "opencode",
1431
+ openclaw: "openclaw",
1432
+ pi: "pi",
1433
+ cline: "other", // no native adapter yet
1434
+ "antigravity-cli": "other", // no native adapter yet
1435
+ };
1436
+ const clients = new Set();
1437
+ for (const e of entries) {
1438
+ if (!e || !e.client) continue;
1439
+ if (e.model === "<synthetic>" || e.model === "unknown") continue;
1440
+ const input = Number(e.input) || 0;
1441
+ const output = Number(e.output) || 0;
1442
+ if (input + output === 0) continue;
1443
+ const platform = TOKSCALE_CLIENT_MAP[e.client] || "other";
1444
+ clients.add(platform);
1445
+ }
1446
+ return [...clients];
1447
+ }
1448
+
1449
+ /** Pull a specific set of platforms in parallel, filter to active ones. */
1450
+ async function _pullExplicit(platforms, opts = {}) {
1337
1451
  const settled = await Promise.allSettled(
1338
- targets.map((p) => callTool("tokenpull", { platform: p }, opts)),
1452
+ platforms.map((p) => callTool("tokenpull", { platform: p }, opts)),
1339
1453
  );
1340
1454
  const active = settled
1341
1455
  .filter((r) => r.status === "fulfilled" && r.value)
@@ -1357,13 +1471,45 @@ export async function callTool(name, args, opts = {}) {
1357
1471
  const rawFetch = opts.fetchImpl || fetch;
1358
1472
 
1359
1473
  // Wrap every fetch with an AbortController timeout so a hung network call never
1360
- // blocks the MCP client indefinitely.
1361
- const doFetch = (url, init = {}) => {
1474
+ // blocks the MCP client indefinitely. Vercel's bot protection (security
1475
+ // checkpoint) blocks Node's fetch via TLS fingerprinting — no UA fix helps.
1476
+ // When fetch gets a 403 Vercel challenge, fall back to curl (which passes).
1477
+ const doFetch = async (url, init = {}) => {
1362
1478
  const ac = new AbortController();
1363
1479
  const timer = setTimeout(() => ac.abort(), timeoutMs);
1364
- return rawFetch(url, { ...init, signal: ac.signal }).finally(() =>
1365
- clearTimeout(timer),
1366
- );
1480
+ const headers = {
1481
+ "user-agent": `node/${process.version} sigrank-mcp/${agentVersionStamp()}`,
1482
+ ...(init.headers || {}),
1483
+ };
1484
+ try {
1485
+ const res = await rawFetch(url, { ...init, headers, signal: ac.signal });
1486
+ // Detect Vercel security checkpoint (403 + HTML body, not JSON)
1487
+ if (res.status === 403) {
1488
+ const text = await res.text();
1489
+ if (text.includes("Vercel Security Checkpoint") || text.includes("x-vercel-challenge")) {
1490
+ // Fall back to curl transport
1491
+ return curlFetch(url, init, timeoutMs);
1492
+ }
1493
+ // Real 403 from the API — return a mock response object
1494
+ return {
1495
+ ok: false,
1496
+ status: 403,
1497
+ json: async () => { try { return JSON.parse(text); } catch { return {}; } },
1498
+ text: async () => text,
1499
+ };
1500
+ }
1501
+ return res;
1502
+ } catch (e) {
1503
+ if (e.name === "AbortError") throw e;
1504
+ // Network error — try curl as fallback
1505
+ try {
1506
+ return await curlFetch(url, init, timeoutMs);
1507
+ } catch {
1508
+ throw e;
1509
+ }
1510
+ } finally {
1511
+ clearTimeout(timer);
1512
+ }
1367
1513
  };
1368
1514
 
1369
1515
  const fetchJson = async (path) => {
@@ -1405,8 +1551,12 @@ export async function callTool(name, args, opts = {}) {
1405
1551
  const c = withParseWarnings(pillars, cascade(pillars));
1406
1552
  return { ...c, card: narrate(c) };
1407
1553
  }
1408
- if (name === "get_leaderboard")
1409
- return fetchJson("/api/v1/leaderboard?metric=yield_");
1554
+ if (name === "get_leaderboard") {
1555
+ const params = new URLSearchParams({ metric: "yield_" });
1556
+ if (args?.limit) params.set("limit", String(args.limit));
1557
+ if (args?.window) params.set("window", args.window);
1558
+ return fetchJson(`/api/v1/leaderboard?${params}`);
1559
+ }
1410
1560
  if (name === "get_operator") {
1411
1561
  const codename = String(args?.codename || "").trim();
1412
1562
  if (!codename)
@@ -1677,6 +1827,67 @@ export async function callTool(name, args, opts = {}) {
1677
1827
  if (name === "tokenpull") {
1678
1828
  // Local read → 4 windows of pillars → cascade each. Token-only, on-device.
1679
1829
  const platform = args?.platform || "claude";
1830
+
1831
+ // MULTI: sum every locally-detected platform's pillars per window. Same logic as
1832
+ // submit_verified's multi flow — Devin (cloud, via tokscale) is included.
1833
+ if (platform === "multi") {
1834
+ const detected = [];
1835
+ for (const p of ALL_PLATFORMS) {
1836
+ const r = await pullByPlatform(p, opts).catch(() => null);
1837
+ const live =
1838
+ r &&
1839
+ (r.windows || []).some(
1840
+ (w) =>
1841
+ w.pillars.input +
1842
+ w.pillars.output +
1843
+ w.pillars.cacheCreate +
1844
+ w.pillars.cacheRead >
1845
+ 0,
1846
+ );
1847
+ if (live) detected.push(r);
1848
+ }
1849
+ if (detected.length < 2) {
1850
+ return {
1851
+ platform: "multi",
1852
+ status: "skipped",
1853
+ reason: "need_2_platforms",
1854
+ detail: `multi needs 2+ active platforms; found ${detected.length} (${detected.map((d) => d.platform).join(", ") || "none"}).`,
1855
+ windows: [],
1856
+ };
1857
+ }
1858
+ const winKeys = ["7d", "30d", "90d", "all"];
1859
+ const windows = [];
1860
+ for (const wk of winKeys) {
1861
+ const sum = { input: 0, output: 0, cacheCreate: 0, cacheRead: 0 };
1862
+ let msgs = 0;
1863
+ for (const d of detected) {
1864
+ const w = (d.windows || []).find((x) => x.window === wk);
1865
+ if (!w) continue;
1866
+ sum.input += w.pillars.input || 0;
1867
+ sum.output += w.pillars.output || 0;
1868
+ sum.cacheCreate += w.pillars.cacheCreate || 0;
1869
+ sum.cacheRead += w.pillars.cacheRead || 0;
1870
+ msgs += w.messages || 0;
1871
+ }
1872
+ if (sum.input + sum.output + sum.cacheCreate + sum.cacheRead <= 0)
1873
+ continue;
1874
+ const c = cascade(sum);
1875
+ windows.push({
1876
+ window: wk,
1877
+ messages: msgs,
1878
+ pillars: sum,
1879
+ cascade: c,
1880
+ card: narrate(c, `${wk} multi`),
1881
+ });
1882
+ }
1883
+ return {
1884
+ platform: "multi",
1885
+ estimated: true,
1886
+ sources: detected.map((d) => d.platform),
1887
+ windows,
1888
+ };
1889
+ }
1890
+
1680
1891
  const pulled = await pullByPlatform(platform, opts);
1681
1892
  const windows = pulled.windows.map((w) => {
1682
1893
  const c = cascade(w.pillars);
@@ -1704,7 +1915,111 @@ export async function callTool(name, args, opts = {}) {
1704
1915
  // (server re-scores). The board stays platform-agnostic via the 4 pillars; the
1705
1916
  // source platform rides along as a tag. Conversion already happened in the adapter.
1706
1917
  const codename = String(args?.codename || "").trim();
1707
- const pulled = await pullByPlatform(args?.platform || "claude", opts);
1918
+ const platform = args?.platform || "claude";
1919
+
1920
+ // MULTI: same combined cross-platform cascade as submit_verified. Includes Devin
1921
+ // (cloud, via tokscale). Aggregate every locally-detected platform's pillars per
1922
+ // window and publish as platform='multi'.
1923
+ if (platform === "multi") {
1924
+ const detected = [];
1925
+ for (const p of ALL_PLATFORMS) {
1926
+ const r = await pullByPlatform(p, opts).catch(() => null);
1927
+ const live =
1928
+ r &&
1929
+ (r.windows || []).some(
1930
+ (w) =>
1931
+ w.pillars.input +
1932
+ w.pillars.output +
1933
+ w.pillars.cacheCreate +
1934
+ w.pillars.cacheRead >
1935
+ 0,
1936
+ );
1937
+ if (live) detected.push(r);
1938
+ }
1939
+ if (detected.length < 2) {
1940
+ return {
1941
+ platform: "multi",
1942
+ codename: codename || undefined,
1943
+ status: "skipped",
1944
+ reason: "need_2_platforms",
1945
+ detail: `multi needs 2+ active platforms; found ${detected.length} (${detected.map((d) => d.platform).join(", ") || "none"}).`,
1946
+ windows: [],
1947
+ };
1948
+ }
1949
+ const winKeys = args?.window ? [args.window] : ["7d", "30d", "90d", "all"];
1950
+ const out = [];
1951
+ for (const wk of winKeys) {
1952
+ const sum = { input: 0, output: 0, cacheCreate: 0, cacheRead: 0 };
1953
+ let msgs = 0;
1954
+ for (const d of detected) {
1955
+ const w = (d.windows || []).find((x) => x.window === wk);
1956
+ if (!w) continue;
1957
+ sum.input += w.pillars.input || 0;
1958
+ sum.output += w.pillars.output || 0;
1959
+ sum.cacheCreate += w.pillars.cacheCreate || 0;
1960
+ sum.cacheRead += w.pillars.cacheRead || 0;
1961
+ msgs += w.messages || 0;
1962
+ }
1963
+ if (sum.input + sum.output + sum.cacheCreate + sum.cacheRead <= 0)
1964
+ continue;
1965
+ const c = cascade(sum);
1966
+ const card = narrate(c, `${wk} multi`);
1967
+ if (!codename) {
1968
+ out.push({
1969
+ window: wk,
1970
+ pillars: sum,
1971
+ cascade: c,
1972
+ card,
1973
+ submission: { status: "not_submitted", reason: "codename_required" },
1974
+ });
1975
+ continue;
1976
+ }
1977
+ const rawPaste = `${sum.input} ${sum.output} ${sum.cacheCreate} ${sum.cacheRead}`;
1978
+ const windowType = WINDOW_TYPE[wk] || wk;
1979
+ const stamp = uploadStamp({
1980
+ codename,
1981
+ window: windowType,
1982
+ pillars: sum,
1983
+ platform: "multi",
1984
+ });
1985
+ const res = await doFetch(`${apiBase}/api/v1/ingest-paste`, {
1986
+ method: "POST",
1987
+ headers: {
1988
+ "content-type": "application/json",
1989
+ accept: "application/json",
1990
+ },
1991
+ body: JSON.stringify({
1992
+ codename,
1993
+ raw_paste: rawPaste,
1994
+ window_type: windowType,
1995
+ telemetry: { platform: { primary: "multi" } },
1996
+ ...stamp,
1997
+ }),
1998
+ });
1999
+ let ack;
2000
+ try {
2001
+ ack = await res.json();
2002
+ } catch {
2003
+ ack = { status: "parse_error", httpStatus: res.status };
2004
+ }
2005
+ const ranked = ack?.ranked ?? ack?.accepted ?? false;
2006
+ out.push({
2007
+ window: wk,
2008
+ pillars: sum,
2009
+ cascade: c,
2010
+ card,
2011
+ submission: { ...stamp, httpStatus: res.status, ranked, ...ack },
2012
+ });
2013
+ }
2014
+ return {
2015
+ platform: "multi",
2016
+ codename: codename || undefined,
2017
+ sources: detected.map((d) => d.platform),
2018
+ windows: out,
2019
+ };
2020
+ }
2021
+
2022
+ const pulled = await pullByPlatform(platform, opts);
1708
2023
  const targets = args?.window
1709
2024
  ? pulled.windows.filter((w) => w.window === args.window)
1710
2025
  : pulled.windows;
@@ -2774,6 +3089,7 @@ export async function callTool(name, args, opts = {}) {
2774
3089
  const top = ops.map((op) => ({
2775
3090
  ...op,
2776
3091
  behavioral_framing: _behavioralFraming(op),
3092
+ competitive: _competitiveLayer(op, board),
2777
3093
  }));
2778
3094
 
2779
3095
  const best = top[0];
@@ -2781,7 +3097,13 @@ export async function callTool(name, args, opts = {}) {
2781
3097
  ? `${best.codename} tops the SigRank leaderboard at Υ ${best.yield_?.toLocaleString?.() || best.yield_} — ${_behavioralFraming(best)}`
2782
3098
  : "No operators on the board yet.";
2783
3099
 
2784
- return { top_operators: top, total_operators: total, summary };
3100
+ return {
3101
+ top_operators: top,
3102
+ total_operators: total,
3103
+ summary,
3104
+ cta: "Check my rank",
3105
+ shareable_url: best ? `${DEFAULT_API_BASE}/operator/${encodeURIComponent(best.codename)}` : null,
3106
+ };
2785
3107
  }
2786
3108
 
2787
3109
  if (name === "compare_self") {
@@ -2843,6 +3165,10 @@ export async function callTool(name, args, opts = {}) {
2843
3165
 
2844
3166
  const suggestion = _improvementSuggestion(klass, yourMetrics);
2845
3167
 
3168
+ // Competitive layer per SHARED_DESIGN_DECISIONS.md §3/§4/§5
3169
+ const competitive = _competitiveLayer(yourMetrics, board);
3170
+ const competitiveSummary = _competitiveSummary(yourMetrics, board);
3171
+
2846
3172
  return {
2847
3173
  your_metrics: yourMetrics,
2848
3174
  power_user_assessment: powerUserAssessment,
@@ -2850,8 +3176,16 @@ export async function callTool(name, args, opts = {}) {
2850
3176
  your_yield_vs_avg: yieldVsAvg,
2851
3177
  your_class_meaning: classMeaning,
2852
3178
  percentile,
3179
+ rank: competitive.rank,
3180
+ total_operators: competitive.total_operators,
3181
+ class_tier: competitive.class_tier,
3182
+ delta_from_average: competitive.delta_from_average,
3183
+ delta_from_top: competitive.delta_from_top,
2853
3184
  },
3185
+ competitive_summary: competitiveSummary,
3186
+ shareable_url: competitive.shareable_url,
2854
3187
  suggestion,
3188
+ cta: "See where I stand",
2855
3189
  };
2856
3190
  }
2857
3191
 
@@ -2863,9 +3197,10 @@ export async function callTool(name, args, opts = {}) {
2863
3197
  "compare_operators requires both `codename_a` and `codename_b`.",
2864
3198
  );
2865
3199
 
2866
- const [opA, opB] = await Promise.all([
3200
+ const [opA, opB, board] = await Promise.all([
2867
3201
  fetchJson(`/api/v1/operators/${encodeURIComponent(nameA)}`),
2868
3202
  fetchJson(`/api/v1/operators/${encodeURIComponent(nameB)}`),
3203
+ fetchJson("/api/v1/leaderboard?metric=yield_"),
2869
3204
  ]);
2870
3205
 
2871
3206
  const yieldA = opA.yield_ || 0;
@@ -2884,6 +3219,7 @@ export async function callTool(name, args, opts = {}) {
2884
3219
  velocity: opA.velocity,
2885
3220
  class: opA.class,
2886
3221
  rank: opA.rank,
3222
+ competitive: _competitiveLayer(opA, board),
2887
3223
  },
2888
3224
  operator_b: {
2889
3225
  codename: opB.codename,
@@ -2892,9 +3228,11 @@ export async function callTool(name, args, opts = {}) {
2892
3228
  velocity: opB.velocity,
2893
3229
  class: opB.class,
2894
3230
  rank: opB.rank,
3231
+ competitive: _competitiveLayer(opB, board),
2895
3232
  },
2896
3233
  verdict,
2897
3234
  yield_delta: delta,
3235
+ cta: "Compare me to others",
2898
3236
  };
2899
3237
  }
2900
3238
 
@@ -2915,6 +3253,8 @@ export async function callTool(name, args, opts = {}) {
2915
3253
  { class: "Burner", meaning: "Early-stage — tokens burned more than compounded. Low leverage, low velocity. The shift: reuse prior context." },
2916
3254
  ],
2917
3255
  link: "https://signalaf.com/score — check your class tier and yield",
3256
+ shareable_url: `${DEFAULT_API_BASE}/score`,
3257
+ cta: "Learn the scoring",
2918
3258
  };
2919
3259
  }
2920
3260
 
@@ -2986,6 +3326,11 @@ export async function callTool(name, args, opts = {}) {
2986
3326
 
2987
3327
  const summary = `Your Υ Yield is ${y.toLocaleString()} (${klass}). ${_improvementSuggestion(klass, metrics)}`;
2988
3328
 
3329
+ // Competitive layer per SHARED_DESIGN_DECISIONS.md §3/§4/§5
3330
+ const board = await fetchJson("/api/v1/leaderboard?metric=yield_");
3331
+ const competitive = _competitiveLayer(metrics, board);
3332
+ const competitiveSummary = _competitiveSummary(metrics, board);
3333
+
2989
3334
  return {
2990
3335
  your_metrics: {
2991
3336
  yield_: y,
@@ -2993,8 +3338,19 @@ export async function callTool(name, args, opts = {}) {
2993
3338
  velocity: v,
2994
3339
  class: klass,
2995
3340
  },
3341
+ competitive: {
3342
+ rank: competitive.rank,
3343
+ total_operators: competitive.total_operators,
3344
+ percentile: competitive.percentile,
3345
+ class_tier: competitive.class_tier,
3346
+ delta_from_average: competitive.delta_from_average,
3347
+ delta_from_top: competitive.delta_from_top,
3348
+ },
3349
+ competitive_summary: competitiveSummary,
3350
+ shareable_url: competitive.shareable_url,
2996
3351
  suggestions,
2997
3352
  summary,
3353
+ cta: "Improve my score",
2998
3354
  };
2999
3355
  }
3000
3356
 
@@ -3003,6 +3359,90 @@ export async function callTool(name, args, opts = {}) {
3003
3359
 
3004
3360
  // ── Intent tool helpers — behavioral framing in power-user language ──────────
3005
3361
 
3362
+ /**
3363
+ * Competitive layer for tool responses per SHARED_DESIGN_DECISIONS.md §3/§4/§5.
3364
+ * Every tool response that includes operator data must show:
3365
+ * rank, percentile, class_tier, delta_from_average, delta_from_top, shareable_url
3366
+ * Response style: factual + competitive ("You rank #12 of 47 operators...")
3367
+ */
3368
+ function _competitiveLayer(op, board) {
3369
+ const allOps = Array.isArray(board?.operators || board) ? (board.operators || board) : [];
3370
+ const yields = allOps.map((o) => o.yield_ || 0).sort((a, b) => a - b);
3371
+ const yourYield = op.yield_ || 0;
3372
+ const total = allOps.length;
3373
+
3374
+ // Rank: find operator's position (1-based)
3375
+ let rank = op.rank || null;
3376
+ if (!rank && total > 0) {
3377
+ const sorted = [...allOps].sort((a, b) => (b.yield_ || 0) - (a.yield_ || 0));
3378
+ const idx = sorted.findIndex((o) => o.codename === op.codename);
3379
+ rank = idx >= 0 ? idx + 1 : null;
3380
+ }
3381
+
3382
+ // Percentile: % of operators with yield below this operator
3383
+ const percentile = total > 0
3384
+ ? Math.round((yields.filter((y) => y < yourYield).length / total) * 100)
3385
+ : 0;
3386
+
3387
+ // Delta from average
3388
+ const avgYield = total > 0 ? yields.reduce((s, y) => s + y, 0) / total : 0;
3389
+ const deltaFromAvg = avgYield > 0
3390
+ ? { absolute: Math.round(yourYield - avgYield),
3391
+ percent: Math.round(((yourYield - avgYield) / avgYield) * 100) }
3392
+ : { absolute: 0, percent: 0 };
3393
+
3394
+ // Delta from top operator
3395
+ const topYield = total > 0 ? Math.max(...yields) : 0;
3396
+ const deltaFromTop = topYield > 0
3397
+ ? { absolute: Math.round(topYield - yourYield),
3398
+ percent: Math.round(((topYield - yourYield) / topYield) * 100) }
3399
+ : { absolute: 0, percent: 0 };
3400
+
3401
+ // Shareable URL
3402
+ const shareableUrl = op.codename && op.codename !== "you (local)"
3403
+ ? `${DEFAULT_API_BASE}/operator/${encodeURIComponent(op.codename)}`
3404
+ : null;
3405
+
3406
+ return {
3407
+ rank,
3408
+ total_operators: total,
3409
+ percentile,
3410
+ class_tier: op.class || "Burner",
3411
+ delta_from_average: deltaFromAvg,
3412
+ delta_from_top: deltaFromTop,
3413
+ shareable_url: shareableUrl,
3414
+ };
3415
+ }
3416
+
3417
+ /** Factual + competitive summary line per SHARED_DESIGN_DECISIONS.md §4 */
3418
+ function _competitiveSummary(op, board) {
3419
+ const cl = _competitiveLayer(op, board);
3420
+ const parts = [];
3421
+
3422
+ if (cl.rank && cl.total_operators > 0) {
3423
+ parts.push(`You rank #${cl.rank} of ${cl.total_operators} operators.`);
3424
+ }
3425
+
3426
+ const topOp = (board?.operators || board || []).reduce(
3427
+ (best, o) => ((o.yield_ || 0) > (best?.yield_ || 0) ? o : best),
3428
+ null,
3429
+ );
3430
+ if (topOp && topOp.codename) {
3431
+ parts.push(`Top operator is ${topOp.codename} with Υ ${(topOp.yield_ || 0).toLocaleString()}.`);
3432
+ }
3433
+
3434
+ if (cl.delta_from_average.percent !== 0) {
3435
+ const dir = cl.delta_from_average.percent > 0 ? "above" : "below";
3436
+ parts.push(`You're ${Math.abs(cl.delta_from_average.percent)}% ${dir} average.`);
3437
+ }
3438
+
3439
+ if (cl.delta_from_top.percent > 0) {
3440
+ parts.push(`${cl.delta_from_top.percent}% below top.`);
3441
+ }
3442
+
3443
+ return parts.join(" ");
3444
+ }
3445
+
3006
3446
  function _behavioralFraming(op) {
3007
3447
  const y = op.yield_ || 0;
3008
3448
  const l = op.leverage || 0;