sigrank 0.17.4 → 0.18.2

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.4
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.4",
3
+ "version": "0.18.2",
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
  },
@@ -1357,13 +1407,45 @@ export async function callTool(name, args, opts = {}) {
1357
1407
  const rawFetch = opts.fetchImpl || fetch;
1358
1408
 
1359
1409
  // Wrap every fetch with an AbortController timeout so a hung network call never
1360
- // blocks the MCP client indefinitely.
1361
- const doFetch = (url, init = {}) => {
1410
+ // blocks the MCP client indefinitely. Vercel's bot protection (security
1411
+ // checkpoint) blocks Node's fetch via TLS fingerprinting — no UA fix helps.
1412
+ // When fetch gets a 403 Vercel challenge, fall back to curl (which passes).
1413
+ const doFetch = async (url, init = {}) => {
1362
1414
  const ac = new AbortController();
1363
1415
  const timer = setTimeout(() => ac.abort(), timeoutMs);
1364
- return rawFetch(url, { ...init, signal: ac.signal }).finally(() =>
1365
- clearTimeout(timer),
1366
- );
1416
+ const headers = {
1417
+ "user-agent": `node/${process.version} sigrank-mcp/${agentVersionStamp()}`,
1418
+ ...(init.headers || {}),
1419
+ };
1420
+ try {
1421
+ const res = await rawFetch(url, { ...init, headers, signal: ac.signal });
1422
+ // Detect Vercel security checkpoint (403 + HTML body, not JSON)
1423
+ if (res.status === 403) {
1424
+ const text = await res.text();
1425
+ if (text.includes("Vercel Security Checkpoint") || text.includes("x-vercel-challenge")) {
1426
+ // Fall back to curl transport
1427
+ return curlFetch(url, init, timeoutMs);
1428
+ }
1429
+ // Real 403 from the API — return a mock response object
1430
+ return {
1431
+ ok: false,
1432
+ status: 403,
1433
+ json: async () => { try { return JSON.parse(text); } catch { return {}; } },
1434
+ text: async () => text,
1435
+ };
1436
+ }
1437
+ return res;
1438
+ } catch (e) {
1439
+ if (e.name === "AbortError") throw e;
1440
+ // Network error — try curl as fallback
1441
+ try {
1442
+ return await curlFetch(url, init, timeoutMs);
1443
+ } catch {
1444
+ throw e;
1445
+ }
1446
+ } finally {
1447
+ clearTimeout(timer);
1448
+ }
1367
1449
  };
1368
1450
 
1369
1451
  const fetchJson = async (path) => {
@@ -1405,8 +1487,12 @@ export async function callTool(name, args, opts = {}) {
1405
1487
  const c = withParseWarnings(pillars, cascade(pillars));
1406
1488
  return { ...c, card: narrate(c) };
1407
1489
  }
1408
- if (name === "get_leaderboard")
1409
- return fetchJson("/api/v1/leaderboard?metric=yield_");
1490
+ if (name === "get_leaderboard") {
1491
+ const params = new URLSearchParams({ metric: "yield_" });
1492
+ if (args?.limit) params.set("limit", String(args.limit));
1493
+ if (args?.window) params.set("window", args.window);
1494
+ return fetchJson(`/api/v1/leaderboard?${params}`);
1495
+ }
1410
1496
  if (name === "get_operator") {
1411
1497
  const codename = String(args?.codename || "").trim();
1412
1498
  if (!codename)
@@ -1677,6 +1763,67 @@ export async function callTool(name, args, opts = {}) {
1677
1763
  if (name === "tokenpull") {
1678
1764
  // Local read → 4 windows of pillars → cascade each. Token-only, on-device.
1679
1765
  const platform = args?.platform || "claude";
1766
+
1767
+ // MULTI: sum every locally-detected platform's pillars per window. Same logic as
1768
+ // submit_verified's multi flow — Devin (cloud, via tokscale) is included.
1769
+ if (platform === "multi") {
1770
+ const detected = [];
1771
+ for (const p of ALL_PLATFORMS) {
1772
+ const r = await pullByPlatform(p, opts).catch(() => null);
1773
+ const live =
1774
+ r &&
1775
+ (r.windows || []).some(
1776
+ (w) =>
1777
+ w.pillars.input +
1778
+ w.pillars.output +
1779
+ w.pillars.cacheCreate +
1780
+ w.pillars.cacheRead >
1781
+ 0,
1782
+ );
1783
+ if (live) detected.push(r);
1784
+ }
1785
+ if (detected.length < 2) {
1786
+ return {
1787
+ platform: "multi",
1788
+ status: "skipped",
1789
+ reason: "need_2_platforms",
1790
+ detail: `multi needs 2+ active platforms; found ${detected.length} (${detected.map((d) => d.platform).join(", ") || "none"}).`,
1791
+ windows: [],
1792
+ };
1793
+ }
1794
+ const winKeys = ["7d", "30d", "90d", "all"];
1795
+ const windows = [];
1796
+ for (const wk of winKeys) {
1797
+ const sum = { input: 0, output: 0, cacheCreate: 0, cacheRead: 0 };
1798
+ let msgs = 0;
1799
+ for (const d of detected) {
1800
+ const w = (d.windows || []).find((x) => x.window === wk);
1801
+ if (!w) continue;
1802
+ sum.input += w.pillars.input || 0;
1803
+ sum.output += w.pillars.output || 0;
1804
+ sum.cacheCreate += w.pillars.cacheCreate || 0;
1805
+ sum.cacheRead += w.pillars.cacheRead || 0;
1806
+ msgs += w.messages || 0;
1807
+ }
1808
+ if (sum.input + sum.output + sum.cacheCreate + sum.cacheRead <= 0)
1809
+ continue;
1810
+ const c = cascade(sum);
1811
+ windows.push({
1812
+ window: wk,
1813
+ messages: msgs,
1814
+ pillars: sum,
1815
+ cascade: c,
1816
+ card: narrate(c, `${wk} multi`),
1817
+ });
1818
+ }
1819
+ return {
1820
+ platform: "multi",
1821
+ estimated: true,
1822
+ sources: detected.map((d) => d.platform),
1823
+ windows,
1824
+ };
1825
+ }
1826
+
1680
1827
  const pulled = await pullByPlatform(platform, opts);
1681
1828
  const windows = pulled.windows.map((w) => {
1682
1829
  const c = cascade(w.pillars);
@@ -1704,7 +1851,111 @@ export async function callTool(name, args, opts = {}) {
1704
1851
  // (server re-scores). The board stays platform-agnostic via the 4 pillars; the
1705
1852
  // source platform rides along as a tag. Conversion already happened in the adapter.
1706
1853
  const codename = String(args?.codename || "").trim();
1707
- const pulled = await pullByPlatform(args?.platform || "claude", opts);
1854
+ const platform = args?.platform || "claude";
1855
+
1856
+ // MULTI: same combined cross-platform cascade as submit_verified. Includes Devin
1857
+ // (cloud, via tokscale). Aggregate every locally-detected platform's pillars per
1858
+ // window and publish as platform='multi'.
1859
+ if (platform === "multi") {
1860
+ const detected = [];
1861
+ for (const p of ALL_PLATFORMS) {
1862
+ const r = await pullByPlatform(p, opts).catch(() => null);
1863
+ const live =
1864
+ r &&
1865
+ (r.windows || []).some(
1866
+ (w) =>
1867
+ w.pillars.input +
1868
+ w.pillars.output +
1869
+ w.pillars.cacheCreate +
1870
+ w.pillars.cacheRead >
1871
+ 0,
1872
+ );
1873
+ if (live) detected.push(r);
1874
+ }
1875
+ if (detected.length < 2) {
1876
+ return {
1877
+ platform: "multi",
1878
+ codename: codename || undefined,
1879
+ status: "skipped",
1880
+ reason: "need_2_platforms",
1881
+ detail: `multi needs 2+ active platforms; found ${detected.length} (${detected.map((d) => d.platform).join(", ") || "none"}).`,
1882
+ windows: [],
1883
+ };
1884
+ }
1885
+ const winKeys = args?.window ? [args.window] : ["7d", "30d", "90d", "all"];
1886
+ const out = [];
1887
+ for (const wk of winKeys) {
1888
+ const sum = { input: 0, output: 0, cacheCreate: 0, cacheRead: 0 };
1889
+ let msgs = 0;
1890
+ for (const d of detected) {
1891
+ const w = (d.windows || []).find((x) => x.window === wk);
1892
+ if (!w) continue;
1893
+ sum.input += w.pillars.input || 0;
1894
+ sum.output += w.pillars.output || 0;
1895
+ sum.cacheCreate += w.pillars.cacheCreate || 0;
1896
+ sum.cacheRead += w.pillars.cacheRead || 0;
1897
+ msgs += w.messages || 0;
1898
+ }
1899
+ if (sum.input + sum.output + sum.cacheCreate + sum.cacheRead <= 0)
1900
+ continue;
1901
+ const c = cascade(sum);
1902
+ const card = narrate(c, `${wk} multi`);
1903
+ if (!codename) {
1904
+ out.push({
1905
+ window: wk,
1906
+ pillars: sum,
1907
+ cascade: c,
1908
+ card,
1909
+ submission: { status: "not_submitted", reason: "codename_required" },
1910
+ });
1911
+ continue;
1912
+ }
1913
+ const rawPaste = `${sum.input} ${sum.output} ${sum.cacheCreate} ${sum.cacheRead}`;
1914
+ const windowType = WINDOW_TYPE[wk] || wk;
1915
+ const stamp = uploadStamp({
1916
+ codename,
1917
+ window: windowType,
1918
+ pillars: sum,
1919
+ platform: "multi",
1920
+ });
1921
+ const res = await doFetch(`${apiBase}/api/v1/ingest-paste`, {
1922
+ method: "POST",
1923
+ headers: {
1924
+ "content-type": "application/json",
1925
+ accept: "application/json",
1926
+ },
1927
+ body: JSON.stringify({
1928
+ codename,
1929
+ raw_paste: rawPaste,
1930
+ window_type: windowType,
1931
+ telemetry: { platform: { primary: "multi" } },
1932
+ ...stamp,
1933
+ }),
1934
+ });
1935
+ let ack;
1936
+ try {
1937
+ ack = await res.json();
1938
+ } catch {
1939
+ ack = { status: "parse_error", httpStatus: res.status };
1940
+ }
1941
+ const ranked = ack?.ranked ?? ack?.accepted ?? false;
1942
+ out.push({
1943
+ window: wk,
1944
+ pillars: sum,
1945
+ cascade: c,
1946
+ card,
1947
+ submission: { ...stamp, httpStatus: res.status, ranked, ...ack },
1948
+ });
1949
+ }
1950
+ return {
1951
+ platform: "multi",
1952
+ codename: codename || undefined,
1953
+ sources: detected.map((d) => d.platform),
1954
+ windows: out,
1955
+ };
1956
+ }
1957
+
1958
+ const pulled = await pullByPlatform(platform, opts);
1708
1959
  const targets = args?.window
1709
1960
  ? pulled.windows.filter((w) => w.window === args.window)
1710
1961
  : pulled.windows;
@@ -2774,6 +3025,7 @@ export async function callTool(name, args, opts = {}) {
2774
3025
  const top = ops.map((op) => ({
2775
3026
  ...op,
2776
3027
  behavioral_framing: _behavioralFraming(op),
3028
+ competitive: _competitiveLayer(op, board),
2777
3029
  }));
2778
3030
 
2779
3031
  const best = top[0];
@@ -2781,7 +3033,13 @@ export async function callTool(name, args, opts = {}) {
2781
3033
  ? `${best.codename} tops the SigRank leaderboard at Υ ${best.yield_?.toLocaleString?.() || best.yield_} — ${_behavioralFraming(best)}`
2782
3034
  : "No operators on the board yet.";
2783
3035
 
2784
- return { top_operators: top, total_operators: total, summary };
3036
+ return {
3037
+ top_operators: top,
3038
+ total_operators: total,
3039
+ summary,
3040
+ cta: "Check my rank",
3041
+ shareable_url: best ? `${DEFAULT_API_BASE}/operator/${encodeURIComponent(best.codename)}` : null,
3042
+ };
2785
3043
  }
2786
3044
 
2787
3045
  if (name === "compare_self") {
@@ -2843,6 +3101,10 @@ export async function callTool(name, args, opts = {}) {
2843
3101
 
2844
3102
  const suggestion = _improvementSuggestion(klass, yourMetrics);
2845
3103
 
3104
+ // Competitive layer per SHARED_DESIGN_DECISIONS.md §3/§4/§5
3105
+ const competitive = _competitiveLayer(yourMetrics, board);
3106
+ const competitiveSummary = _competitiveSummary(yourMetrics, board);
3107
+
2846
3108
  return {
2847
3109
  your_metrics: yourMetrics,
2848
3110
  power_user_assessment: powerUserAssessment,
@@ -2850,8 +3112,16 @@ export async function callTool(name, args, opts = {}) {
2850
3112
  your_yield_vs_avg: yieldVsAvg,
2851
3113
  your_class_meaning: classMeaning,
2852
3114
  percentile,
3115
+ rank: competitive.rank,
3116
+ total_operators: competitive.total_operators,
3117
+ class_tier: competitive.class_tier,
3118
+ delta_from_average: competitive.delta_from_average,
3119
+ delta_from_top: competitive.delta_from_top,
2853
3120
  },
3121
+ competitive_summary: competitiveSummary,
3122
+ shareable_url: competitive.shareable_url,
2854
3123
  suggestion,
3124
+ cta: "See where I stand",
2855
3125
  };
2856
3126
  }
2857
3127
 
@@ -2863,9 +3133,10 @@ export async function callTool(name, args, opts = {}) {
2863
3133
  "compare_operators requires both `codename_a` and `codename_b`.",
2864
3134
  );
2865
3135
 
2866
- const [opA, opB] = await Promise.all([
3136
+ const [opA, opB, board] = await Promise.all([
2867
3137
  fetchJson(`/api/v1/operators/${encodeURIComponent(nameA)}`),
2868
3138
  fetchJson(`/api/v1/operators/${encodeURIComponent(nameB)}`),
3139
+ fetchJson("/api/v1/leaderboard?metric=yield_"),
2869
3140
  ]);
2870
3141
 
2871
3142
  const yieldA = opA.yield_ || 0;
@@ -2884,6 +3155,7 @@ export async function callTool(name, args, opts = {}) {
2884
3155
  velocity: opA.velocity,
2885
3156
  class: opA.class,
2886
3157
  rank: opA.rank,
3158
+ competitive: _competitiveLayer(opA, board),
2887
3159
  },
2888
3160
  operator_b: {
2889
3161
  codename: opB.codename,
@@ -2892,9 +3164,11 @@ export async function callTool(name, args, opts = {}) {
2892
3164
  velocity: opB.velocity,
2893
3165
  class: opB.class,
2894
3166
  rank: opB.rank,
3167
+ competitive: _competitiveLayer(opB, board),
2895
3168
  },
2896
3169
  verdict,
2897
3170
  yield_delta: delta,
3171
+ cta: "Compare me to others",
2898
3172
  };
2899
3173
  }
2900
3174
 
@@ -2915,6 +3189,8 @@ export async function callTool(name, args, opts = {}) {
2915
3189
  { class: "Burner", meaning: "Early-stage — tokens burned more than compounded. Low leverage, low velocity. The shift: reuse prior context." },
2916
3190
  ],
2917
3191
  link: "https://signalaf.com/score — check your class tier and yield",
3192
+ shareable_url: `${DEFAULT_API_BASE}/score`,
3193
+ cta: "Learn the scoring",
2918
3194
  };
2919
3195
  }
2920
3196
 
@@ -2986,6 +3262,11 @@ export async function callTool(name, args, opts = {}) {
2986
3262
 
2987
3263
  const summary = `Your Υ Yield is ${y.toLocaleString()} (${klass}). ${_improvementSuggestion(klass, metrics)}`;
2988
3264
 
3265
+ // Competitive layer per SHARED_DESIGN_DECISIONS.md §3/§4/§5
3266
+ const board = await fetchJson("/api/v1/leaderboard?metric=yield_");
3267
+ const competitive = _competitiveLayer(metrics, board);
3268
+ const competitiveSummary = _competitiveSummary(metrics, board);
3269
+
2989
3270
  return {
2990
3271
  your_metrics: {
2991
3272
  yield_: y,
@@ -2993,8 +3274,19 @@ export async function callTool(name, args, opts = {}) {
2993
3274
  velocity: v,
2994
3275
  class: klass,
2995
3276
  },
3277
+ competitive: {
3278
+ rank: competitive.rank,
3279
+ total_operators: competitive.total_operators,
3280
+ percentile: competitive.percentile,
3281
+ class_tier: competitive.class_tier,
3282
+ delta_from_average: competitive.delta_from_average,
3283
+ delta_from_top: competitive.delta_from_top,
3284
+ },
3285
+ competitive_summary: competitiveSummary,
3286
+ shareable_url: competitive.shareable_url,
2996
3287
  suggestions,
2997
3288
  summary,
3289
+ cta: "Improve my score",
2998
3290
  };
2999
3291
  }
3000
3292
 
@@ -3003,6 +3295,90 @@ export async function callTool(name, args, opts = {}) {
3003
3295
 
3004
3296
  // ── Intent tool helpers — behavioral framing in power-user language ──────────
3005
3297
 
3298
+ /**
3299
+ * Competitive layer for tool responses per SHARED_DESIGN_DECISIONS.md §3/§4/§5.
3300
+ * Every tool response that includes operator data must show:
3301
+ * rank, percentile, class_tier, delta_from_average, delta_from_top, shareable_url
3302
+ * Response style: factual + competitive ("You rank #12 of 47 operators...")
3303
+ */
3304
+ function _competitiveLayer(op, board) {
3305
+ const allOps = Array.isArray(board?.operators || board) ? (board.operators || board) : [];
3306
+ const yields = allOps.map((o) => o.yield_ || 0).sort((a, b) => a - b);
3307
+ const yourYield = op.yield_ || 0;
3308
+ const total = allOps.length;
3309
+
3310
+ // Rank: find operator's position (1-based)
3311
+ let rank = op.rank || null;
3312
+ if (!rank && total > 0) {
3313
+ const sorted = [...allOps].sort((a, b) => (b.yield_ || 0) - (a.yield_ || 0));
3314
+ const idx = sorted.findIndex((o) => o.codename === op.codename);
3315
+ rank = idx >= 0 ? idx + 1 : null;
3316
+ }
3317
+
3318
+ // Percentile: % of operators with yield below this operator
3319
+ const percentile = total > 0
3320
+ ? Math.round((yields.filter((y) => y < yourYield).length / total) * 100)
3321
+ : 0;
3322
+
3323
+ // Delta from average
3324
+ const avgYield = total > 0 ? yields.reduce((s, y) => s + y, 0) / total : 0;
3325
+ const deltaFromAvg = avgYield > 0
3326
+ ? { absolute: Math.round(yourYield - avgYield),
3327
+ percent: Math.round(((yourYield - avgYield) / avgYield) * 100) }
3328
+ : { absolute: 0, percent: 0 };
3329
+
3330
+ // Delta from top operator
3331
+ const topYield = total > 0 ? Math.max(...yields) : 0;
3332
+ const deltaFromTop = topYield > 0
3333
+ ? { absolute: Math.round(topYield - yourYield),
3334
+ percent: Math.round(((topYield - yourYield) / topYield) * 100) }
3335
+ : { absolute: 0, percent: 0 };
3336
+
3337
+ // Shareable URL
3338
+ const shareableUrl = op.codename && op.codename !== "you (local)"
3339
+ ? `${DEFAULT_API_BASE}/operator/${encodeURIComponent(op.codename)}`
3340
+ : null;
3341
+
3342
+ return {
3343
+ rank,
3344
+ total_operators: total,
3345
+ percentile,
3346
+ class_tier: op.class || "Burner",
3347
+ delta_from_average: deltaFromAvg,
3348
+ delta_from_top: deltaFromTop,
3349
+ shareable_url: shareableUrl,
3350
+ };
3351
+ }
3352
+
3353
+ /** Factual + competitive summary line per SHARED_DESIGN_DECISIONS.md §4 */
3354
+ function _competitiveSummary(op, board) {
3355
+ const cl = _competitiveLayer(op, board);
3356
+ const parts = [];
3357
+
3358
+ if (cl.rank && cl.total_operators > 0) {
3359
+ parts.push(`You rank #${cl.rank} of ${cl.total_operators} operators.`);
3360
+ }
3361
+
3362
+ const topOp = (board?.operators || board || []).reduce(
3363
+ (best, o) => ((o.yield_ || 0) > (best?.yield_ || 0) ? o : best),
3364
+ null,
3365
+ );
3366
+ if (topOp && topOp.codename) {
3367
+ parts.push(`Top operator is ${topOp.codename} with Υ ${(topOp.yield_ || 0).toLocaleString()}.`);
3368
+ }
3369
+
3370
+ if (cl.delta_from_average.percent !== 0) {
3371
+ const dir = cl.delta_from_average.percent > 0 ? "above" : "below";
3372
+ parts.push(`You're ${Math.abs(cl.delta_from_average.percent)}% ${dir} average.`);
3373
+ }
3374
+
3375
+ if (cl.delta_from_top.percent > 0) {
3376
+ parts.push(`${cl.delta_from_top.percent}% below top.`);
3377
+ }
3378
+
3379
+ return parts.join(" ");
3380
+ }
3381
+
3006
3382
  function _behavioralFraming(op) {
3007
3383
  const y = op.yield_ || 0;
3008
3384
  const l = op.leverage || 0;
package/tui.mjs CHANGED
@@ -2192,9 +2192,11 @@ export async function runTui({
2192
2192
  "sign-in is temporarily unavailable — try again shortly.",
2193
2193
  };
2194
2194
  connectMsg = `${red("✗")} ${reasons[out.reason] || `sign-in failed (${out.reason || "unknown"}).`}`;
2195
+ codeBuf = ""; // auto-clear on failure — the code is consumed/invalid
2195
2196
  }
2196
2197
  } catch (e) {
2197
2198
  connectMsg = `${red("✗")} ${e.message}`;
2199
+ codeBuf = ""; // auto-clear on error too
2198
2200
  }
2199
2201
  await redraw();
2200
2202
  return;
@@ -2225,13 +2227,13 @@ export async function runTui({
2225
2227
  await redraw();
2226
2228
  return;
2227
2229
  } // backspace
2228
- // [X] sign out / reset — when the code field is empty (so an 'x' typed mid-code
2229
- // still feeds the buffer). Drops the local credential so a server-revoked OR
2230
- // server-enrolled-but-locally-unbound device stops holding a stale device_id,
2231
- // and the next enroll provisions a fresh device_id (escapes the Frankenstein-
2232
- // identity / 409-re-enroll deadlock). Works whether or not isSignedIn() —
2233
- // otherwise a device enrolled server-side but unbound locally is un-escapable
2234
- // (can't sign in: device_already_enrolled; can't sign out: X gated off).
2230
+ // [X] sign out / reset — only when the code field is empty (so 'x'/'X' typed
2231
+ // mid-code still feeds the buffer — codes can contain X). The auto-clear on
2232
+ // sign-in failure (above) ensures the buffer is empty after a failed attempt,
2233
+ // so X works immediately when the user wants to reset. Drops the local
2234
+ // credential so a server-revoked OR server-enrolled-but-locally-unbound device
2235
+ // stops holding a stale device_id, and the next enroll provisions a fresh
2236
+ // device_id (escapes the Frankenstein-identity / 409-re-enroll deadlock).
2235
2237
  if (k === "x" && !codeBuf) {
2236
2238
  const wasSignedIn = isSignedIn(loadIdentity());
2237
2239
  clearIdentity();