sigrank 0.18.5 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +158 -0
  2. package/MCP_SERVER_DESCRIPTION.md +172 -0
  3. package/README.md +94 -41
  4. package/adapters/index.mjs +1100 -0
  5. package/adapters/tokenpull.mjs +568 -0
  6. package/adapters.mjs +1 -857
  7. package/analytics/cascade.mjs +338 -0
  8. package/cascade.mjs +1 -274
  9. package/cli.mjs +1 -1853
  10. package/connect.mjs +1 -13
  11. package/identity/connect.mjs +13 -0
  12. package/identity/keystore.mjs +231 -0
  13. package/identity/sign.mjs +106 -0
  14. package/index.mjs +47 -162
  15. package/keystore.mjs +1 -242
  16. package/lib/constants.mjs +137 -0
  17. package/lib/pkg-version.mjs +59 -0
  18. package/manifest.json +46 -0
  19. package/narrate.mjs +1 -77
  20. package/package.json +41 -16
  21. package/preflight.mjs +1 -191
  22. package/presentation/cli.mjs +1926 -0
  23. package/presentation/narrate.mjs +219 -0
  24. package/presentation/tui-themes.mjs +189 -0
  25. package/presentation/tui.mjs +3316 -0
  26. package/prompts/check-my-efficiency.md +1 -0
  27. package/prompts/compare-with-leader.md +1 -0
  28. package/prompts/simulate-improvement.md +1 -0
  29. package/proxy.mjs +492 -0
  30. package/resources/class-tiers.md +81 -0
  31. package/resources/data-policy.md +31 -0
  32. package/resources/install-guide.md +59 -0
  33. package/resources/privacy-model.md +44 -0
  34. package/resources/scoring-formula.md +24 -0
  35. package/review.mjs +263 -0
  36. package/sign.mjs +1 -106
  37. package/submit/index.mjs +297 -0
  38. package/submit/preflight.mjs +191 -0
  39. package/submit.mjs +1 -303
  40. package/tokenpull.mjs +1 -576
  41. package/tokscale_analytics.mjs +864 -0
  42. package/tools/_framing.mjs +217 -0
  43. package/tools/_helpers.mjs +253 -0
  44. package/tools/_peer-matching.mjs +361 -0
  45. package/tools/_report.mjs +262 -0
  46. package/tools/_schemas.mjs +492 -0
  47. package/tools/_verifiers.mjs +148 -0
  48. package/tools/compare-operators.mjs +80 -0
  49. package/tools/compare-self.mjs +124 -0
  50. package/tools/describe-power-user.mjs +78 -0
  51. package/tools/diagnose-cascade.mjs +192 -0
  52. package/tools/discover-peers.mjs +198 -0
  53. package/tools/enroll.mjs +119 -0
  54. package/tools/get-best-operator.mjs +59 -0
  55. package/tools/get-leaderboard.mjs +27 -0
  56. package/tools/get-operator.mjs +33 -0
  57. package/tools/index.mjs +229 -0
  58. package/tools/optimize-efficiency.mjs +170 -0
  59. package/tools/rank-paste.mjs +51 -0
  60. package/tools/rank-windows.mjs +120 -0
  61. package/tools/self-improve.mjs +423 -0
  62. package/tools/simulate-change.mjs +207 -0
  63. package/tools/submit-paste.mjs +121 -0
  64. package/tools/submit-verified.mjs +153 -0
  65. package/tools/suggest-improvements.mjs +168 -0
  66. package/tools/tokenpull-compare.mjs +123 -0
  67. package/tools/tokenpull-submit.mjs +235 -0
  68. package/tools/tokenpull.mjs +113 -0
  69. package/tools/tokscale-breakdown.mjs +119 -0
  70. package/tools/tokscale-competitive-intel.mjs +45 -0
  71. package/tools/tokscale-cost-analysis.mjs +31 -0
  72. package/tools/tokscale-developer-profile.mjs +36 -0
  73. package/tools/tokscale-device-profile.mjs +35 -0
  74. package/tools/tokscale-market-share.mjs +38 -0
  75. package/tools/tokscale-mcp-usage.mjs +33 -0
  76. package/tools/tokscale-model-trends.mjs +32 -0
  77. package/tools/watch-tokenpull.mjs +134 -0
  78. package/tools.mjs +1 -3897
  79. package/tui.mjs +4 -2650
  80. package/banner.svg +0 -7
@@ -0,0 +1,217 @@
1
+ /**
2
+ * tools/_framing.mjs — behavioral framing helpers for intent-based tools.
3
+ *
4
+ * Power-user language + competitive layer shared by get_best_operator,
5
+ * compare_self, compare_operators, and optimize_efficiency.
6
+ *
7
+ * Class-tier language is keyed off the canonical 8-tier dev10x taxonomy
8
+ * (analytics/cascade.mjs CLASS_TIERS) + UNCLASSED. A prior version branched
9
+ * on the legacy 3-tier Burner/Builder/10xer names, which the cascade
10
+ * classifier never emits — every operator was falling through to the
11
+ * "Burner" branch and getting "you are not yet a power user" framing
12
+ * regardless of their actual tier. The framing now groups the 8 tiers into
13
+ * power-user bands (apex / high / mid / early / entry / no-data) and writes
14
+ * one message per band.
15
+ */
16
+
17
+ import { DEFAULT_API_BASE } from "./_helpers.mjs";
18
+ import { CLASS_TIERS, UNCLASSED, tierOf } from "../analytics/cascade.mjs";
19
+
20
+ /**
21
+ * Map a class tier to a power-user band. The 8 experience tiers collapse into 5
22
+ * behavioral bands + UNCLASSED so the framing stays short without writing 8
23
+ * bespoke messages. Band order matches CLASS_TIERS cut order (descending).
24
+ *
25
+ * ARCH+, ARCH → high (deep experience, power-user archetype)
26
+ * POWER, BASE → mid (building momentum)
27
+ * SEEKER, REFINER → early (accumulating volume, finding patterns)
28
+ * BEARER, IGNITER → entry (first volume, or dormant)
29
+ * UNCLASSED / unknown → nodata
30
+ */
31
+ const BAND = {
32
+ "ARCH+": "high",
33
+ ARCH: "high",
34
+ POWER: "mid",
35
+ BASE: "mid",
36
+ SEEKER: "early",
37
+ REFINER: "early",
38
+ BEARER: "entry",
39
+ IGNITER: "entry",
40
+ [UNCLASSED]: "nodata",
41
+ };
42
+
43
+ /** Default fallback when a metrics object carries no class — UNCLASSED, not
44
+ * the legacy "Burner". Callers that did `metrics.class || "Burner"` were
45
+ * silently relabeling every no-data operator as the bottom legacy tier. */
46
+ export const FALLBACK_CLASS = UNCLASSED;
47
+
48
+ /** Map a class tier (full sub-stage string or base tier) to its power-user
49
+ * band. Exported so tool handlers can branch on band without re-deriving the
50
+ * tier→band mapping. Uses tierOf() to extract the base tier from sub-stage
51
+ * strings (e.g. "REFINER II" → "REFINER" → "early"). */
52
+ export function bandOf(klass) {
53
+ return BAND[tierOf(klass)] ?? "nodata";
54
+ }
55
+
56
+ /**
57
+ * Competitive layer for tool responses per SHARED_DESIGN_DECISIONS.md §3/§4/§5.
58
+ * Every tool response that includes operator data must show:
59
+ * rank, percentile, class_tier, delta_from_average, delta_from_top, shareable_url
60
+ * Response style: factual + competitive ("You rank #12 of 47 operators...")
61
+ */
62
+ export function _competitiveLayer(op, board) {
63
+ const allOps = Array.isArray(board?.operators || board)
64
+ ? board.operators || board
65
+ : [];
66
+ const yields = allOps.map((o) => o.yield_ || 0).sort((a, b) => a - b);
67
+ const yourYield = op.yield_ || 0;
68
+ const total = allOps.length;
69
+
70
+ // Rank: find operator's position (1-based)
71
+ let rank = op.rank || null;
72
+ if (!rank && total > 0) {
73
+ const sorted = [...allOps].sort((a, b) => (b.yield_ || 0) - (a.yield_ || 0));
74
+ const idx = sorted.findIndex((o) => o.codename === op.codename);
75
+ rank = idx >= 0 ? idx + 1 : null;
76
+ }
77
+
78
+ // Percentile: % of operators with yield below this operator
79
+ const percentile = total > 0
80
+ ? Math.round((yields.filter((y) => y < yourYield).length / total) * 100)
81
+ : 0;
82
+
83
+ // Delta from average
84
+ const avgYield = total > 0 ? yields.reduce((s, y) => s + y, 0) / total : 0;
85
+ const deltaFromAvg = avgYield > 0
86
+ ? { absolute: Math.round(yourYield - avgYield),
87
+ percent: Math.round(((yourYield - avgYield) / avgYield) * 100) }
88
+ : { absolute: 0, percent: 0 };
89
+
90
+ // Delta from top operator
91
+ const topYield = total > 0 ? Math.max(...yields) : 0;
92
+ const deltaFromTop = topYield > 0
93
+ ? { absolute: Math.round(topYield - yourYield),
94
+ percent: Math.round(((topYield - yourYield) / topYield) * 100) }
95
+ : { absolute: 0, percent: 0 };
96
+
97
+ // Shareable URL
98
+ const shareableUrl = op.codename && op.codename !== "you (local)"
99
+ ? `${DEFAULT_API_BASE}/operator/${encodeURIComponent(op.codename)}`
100
+ : null;
101
+
102
+ return {
103
+ rank,
104
+ total_operators: total,
105
+ percentile,
106
+ class_tier: op.class || FALLBACK_CLASS,
107
+ delta_from_average: deltaFromAvg,
108
+ delta_from_top: deltaFromTop,
109
+ shareable_url: shareableUrl,
110
+ };
111
+ }
112
+
113
+ /** Factual + competitive summary line per SHARED_DESIGN_DECISIONS.md §4 */
114
+ export function _competitiveSummary(op, board) {
115
+ const cl = _competitiveLayer(op, board);
116
+ const parts = [];
117
+
118
+ if (cl.rank && cl.total_operators > 0) {
119
+ parts.push(`You rank #${cl.rank} of ${cl.total_operators} operators.`);
120
+ }
121
+
122
+ const topOp = (board?.operators || board || []).reduce(
123
+ (best, o) => ((o.yield_ || 0) > (best?.yield_ || 0) ? o : best),
124
+ null,
125
+ );
126
+ if (topOp && topOp.codename) {
127
+ parts.push(`Top operator is ${topOp.codename} with Υ ${(topOp.yield_ || 0).toLocaleString()}.`);
128
+ }
129
+
130
+ if (cl.delta_from_average.percent !== 0) {
131
+ const dir = cl.delta_from_average.percent > 0 ? "above" : "below";
132
+ parts.push(`You're ${Math.abs(cl.delta_from_average.percent)}% ${dir} average.`);
133
+ }
134
+
135
+ if (cl.delta_from_top.percent > 0) {
136
+ parts.push(`${cl.delta_from_top.percent}% below top.`);
137
+ }
138
+
139
+ return parts.join(" ");
140
+ }
141
+
142
+ export function _behavioralFraming(op) {
143
+ const y = op.yield_ || 0;
144
+ const l = op.leverage || 0;
145
+ const v = op.velocity || 0;
146
+ const klass = op.class || FALLBACK_CLASS;
147
+ const band = bandOf(klass);
148
+
149
+ if (band === "apex")
150
+ return `Closed-loop operator: ${l.toFixed(1)}× leverage over fresh input with ${v.toFixed(2)} output velocity. Both axes held at once — the rare apex profile the leverage/velocity tradeoff says shouldn't exist.`;
151
+ if (band === "high")
152
+ return `Disciplined, system-level reuse: ${l.toFixed(1)}× leverage means heavy cache reuse over fresh input, ${v.toFixed(2)} velocity means more output per token spent. This is the AI power-user archetype.`;
153
+ if (band === "mid")
154
+ return `Building cascade momentum: moderate cache reuse (${l.toFixed(1)}× leverage) with ${v.toFixed(2)} output velocity. Approaching power-user patterns — increase cache reuse to push into the ARCH band.`;
155
+ if (band === "early")
156
+ return `Compounding, not yet compounding well: ${v.toFixed(2)} output velocity with ${l.toFixed(1)}× leverage. Cache reuse is starting but input still dominates. Reuse prior context (templates, prompts, workflows) to build leverage.`;
157
+ if (band === "entry")
158
+ return `Early-stage cascade: ${v.toFixed(2)} output velocity with ${l.toFixed(1)}× leverage. Tokens are being burned more than compounded. Focus on reusing prior context (templates, prompts, workflows) to build leverage.`;
159
+ return `No cascade data yet — run a few sessions and re-rank to see your tier. Yield is computed from four token pillars (input, output, cacheCreate, cacheRead).`;
160
+ }
161
+
162
+ export function _powerUserAssessment(klass, metrics) {
163
+ const l = metrics.leverage || 0;
164
+ const v = metrics.velocity || 0;
165
+ const band = bandOf(klass);
166
+
167
+ if (band === "apex")
168
+ return `You are an apex AI power user. Your SigRank class (${klass}) indicates you reuse prior work heavily (${l.toFixed(1)}× leverage), get more out of each token (${v.toFixed(2)} velocity), and keep input lean — both leverage and velocity held at once.`;
169
+ if (band === "high")
170
+ return `You are an AI power user. Your SigRank class (${klass}) indicates you reuse prior work heavily (${l.toFixed(1)}× leverage), get more out of each token (${v.toFixed(2)} velocity), and keep input lean. This is consistent with AI power-user behavior: iterative, efficient, multi-use patterns.`;
171
+ if (band === "mid")
172
+ return `You are becoming an AI power user. Your ${klass} class shows growing cache reuse (${l.toFixed(1)}× leverage) and ${v.toFixed(2)} output velocity. You're building the habits — increase context reuse to push into the ARCH band.`;
173
+ if (band === "early")
174
+ return `You are starting to compound. Your ${klass} class shows ${l.toFixed(1)}× leverage and ${v.toFixed(2)} velocity — cache reuse is beginning but input still dominates. The power-user shift: reuse prior context instead of starting fresh each time.`;
175
+ if (band === "entry")
176
+ return `You are not yet an AI power user. Your ${klass} class means tokens are being spent without compounding. The power-user shift: reuse prior context (prompts, templates, cached results) instead of starting fresh each time. Your leverage (${l.toFixed(1)}×) is the key metric to improve.`;
177
+ return `No cascade data yet — your tier is ${UNCLASSED}. Run a few sessions, re-rank, and the assessment will reflect your actual leverage and velocity.`;
178
+ }
179
+
180
+ export function _classMeaning(klass) {
181
+ const band = bandOf(klass);
182
+ if (band === "apex")
183
+ return "Apex power user — both leverage and velocity held at once. The rare closed-loop operator.";
184
+ if (band === "high")
185
+ return "AI power user archetype — disciplined, system-level reuse, high output per input.";
186
+ if (band === "mid")
187
+ return "Building momentum — moderate reuse, approaching power-user patterns.";
188
+ if (band === "early")
189
+ return "Compounding, not yet compounding well — cache reuse starting, input still dominates.";
190
+ if (band === "entry")
191
+ return "Early-stage — tokens burned more than compounded. Focus on cache reuse.";
192
+ return "No data — run sessions and re-rank to see your tier.";
193
+ }
194
+
195
+ export function _improvementSuggestion(klass, metrics) {
196
+ const l = metrics.leverage || 0;
197
+ const v = metrics.velocity || 0;
198
+ const band = bandOf(klass);
199
+
200
+ if (band === "apex")
201
+ return v < 1
202
+ ? "Your leverage is exceptional but velocity is under 1.0 — you're reading more cache than producing output. Push for more output per session."
203
+ : "You're at the apex. Maintain your cache architecture and experiment with longer sessions to compound yield further.";
204
+ if (band === "high")
205
+ return v < 1
206
+ ? "Your leverage is excellent but velocity is under 1.0 — you're reading more cache than producing output. Push for more output per session."
207
+ : "You're at the top tier. Maintain your cache architecture and experiment with longer sessions to compound yield further.";
208
+ if (band === "mid")
209
+ return l < 5
210
+ ? "Increase cache reuse: reuse prompts, templates, and workflows instead of starting from scratch. Each reused token multiplies your yield."
211
+ : "Your leverage is solid. Focus on output velocity — produce more per session to push your yield up.";
212
+ if (band === "early")
213
+ return "Start by reusing prior context. Instead of fresh prompts each time, build on cached results. Even a 2× increase in cache_read will dramatically improve your yield because input² is in the denominator.";
214
+ if (band === "entry")
215
+ return "Start by reusing prior context. Instead of fresh prompts each time, build on cached results. Even a 2× increase in cache_read will dramatically improve your yield because input² is in the denominator.";
216
+ return "No cascade data yet. Run a few sessions so the four token pillars are populated, then re-rank for an actionable suggestion.";
217
+ }
@@ -0,0 +1,253 @@
1
+ /**
2
+ * tools/_helpers.mjs — shared helpers for the SigRank MCP tool suite.
3
+ *
4
+ * Transport-free utilities used across multiple tool handlers:
5
+ * exec wrapper, curl fallback, fetch builder, upload stamp, platform pull,
6
+ * parse-warning merge, and watch-tokenpull cooldown state.
7
+ */
8
+
9
+ import { createHash } from "node:crypto";
10
+ import { execFile, execFileSync } from "node:child_process";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import { pkgVersion } from "../lib/pkg-version.mjs";
15
+ import {
16
+ tokenpull as pullLocal,
17
+ tokenpullCodex as pullCodex,
18
+ tokenpullAny,
19
+ } from "../adapters/tokenpull.mjs";
20
+
21
+ // Resolve local node_modules/.bin for bundled deps (ccusage, tokscale)
22
+ const _pkgRoot = path.dirname(fileURLToPath(import.meta.url));
23
+ const _localBin = path.join(_pkgRoot, "..", "node_modules", ".bin");
24
+ const _envPath = `${_localBin}${process.env.PATH ? ":" + process.env.PATH : ""}`;
25
+
26
+ export const DEFAULT_API_BASE =
27
+ process.env.SIGRANK_API_BASE || "https://signalaf.com";
28
+ /** Default network timeout in ms (override via opts.fetchTimeout or SIGRANK_FETCH_TIMEOUT). */
29
+ export const DEFAULT_FETCH_TIMEOUT =
30
+ Number(process.env.SIGRANK_FETCH_TIMEOUT) || 10_000;
31
+ /** Max accepted length for a single paste/string arg (chars). Token counts are tiny; anything
32
+ * past this is malformed or abusive — reject cleanly before parsing/POSTing (E2 hardening). */
33
+ export const MAX_INPUT = 1_000_000;
34
+
35
+ // E3: client-side auto-submit cooldown for watch_tokenpull. The server already dedups
36
+ // identical snapshots (exact hash → 422), but a noisy poll loop with submit:true could still
37
+ // churn the network/board with near-identical rows. Cap auto-submit to once per WATCH_SUBMIT_COOLDOWN_MS
38
+ // per platform+window (in-memory, per process). Keyed by platform:window so different
39
+ // platforms/windows don't block each other, and only armed on a non-error submit so a
40
+ // network failure doesn't lock out retries for 5 minutes.
41
+ export const WATCH_SUBMIT_COOLDOWN_MS = 5 * 60 * 1000;
42
+ export const _lastWatchSubmitAt = new Map();
43
+
44
+ // ASYNC FIX (2026-06-27): execFile wrapped in a Promise — replaces execSync for
45
+ // defense-in-depth (shell injection prevention + non-blocking). The platform param
46
+ // is enum-validated at the MCP schema level, but execFile also prevents shell
47
+ // interpolation attacks by passing args as an array (no shell parsing).
48
+ // BIN FIX (2026-06-27): PATH includes local node_modules/.bin so bundled deps
49
+ // are found even when sigrank isn't globally installed.
50
+ export function execFileAsync(cmd, args, timeoutMs) {
51
+ return new Promise((resolve, reject) => {
52
+ // NOTE: execFile does not accept a `stdio` option (it always pipes + buffers
53
+ // stdout/stderr against maxBuffer) — a previous `stdio` key here was silently ignored.
54
+ execFile(
55
+ cmd,
56
+ args,
57
+ {
58
+ timeout: timeoutMs,
59
+ maxBuffer: 10 * 1024 * 1024,
60
+ env: { ...process.env, PATH: _envPath },
61
+ },
62
+ (err, stdout) => {
63
+ if (err) reject(err);
64
+ else resolve(stdout.toString());
65
+ },
66
+ );
67
+ });
68
+ }
69
+
70
+ /** Resolve this package's version for the User-Agent stamp (best-effort).
71
+ * Thin wrapper over the shared lib/pkg-version.mjs helper so there is exactly
72
+ * one version-resolution implementation in the repo. */
73
+ export function agentVersionStamp() {
74
+ return pkgVersion();
75
+ }
76
+
77
+ /**
78
+ * curl-based fetch fallback for when Node's fetch is blocked by Vercel's bot
79
+ * protection (TLS fingerprinting). Returns a Response-like object with .ok,
80
+ * .status, .json(), .text(). Uses execFileSync for synchronous curl execution.
81
+ */
82
+ export function curlFetch(url, init = {}, timeoutMs = 10_000) {
83
+ const args = [
84
+ "-s",
85
+ "-S",
86
+ "-w",
87
+ "\n__HTTP_STATUS__%{http_code}",
88
+ "--max-time",
89
+ String(Math.ceil(timeoutMs / 1000)),
90
+ ];
91
+ if (init.method) args.push("-X", init.method);
92
+ const headers = init.headers || {};
93
+ for (const [k, v] of Object.entries(headers)) {
94
+ args.push("-H", `${k}: ${v}`);
95
+ }
96
+ if (init.body) {
97
+ args.push("-d", init.body);
98
+ }
99
+ args.push(url);
100
+ let stdout;
101
+ try {
102
+ stdout = execFileSync("curl", args, {
103
+ encoding: "utf-8",
104
+ maxBuffer: 10 * 1024 * 1024,
105
+ timeout: timeoutMs + 2000,
106
+ });
107
+ } catch (e) {
108
+ throw new Error(`curl transport failed: ${e.message}`);
109
+ }
110
+ // Parse the status code from the trailer
111
+ const statusMatch = stdout.match(/__HTTP_STATUS__(\d+)$/);
112
+ const status = statusMatch ? parseInt(statusMatch[1], 10) : 0;
113
+ const bodyText = statusMatch ? stdout.slice(0, statusMatch.index) : stdout;
114
+ return {
115
+ ok: status >= 200 && status < 300,
116
+ status,
117
+ json: async () => {
118
+ try {
119
+ return JSON.parse(bodyText);
120
+ } catch {
121
+ return {};
122
+ }
123
+ },
124
+ text: async () => bodyText,
125
+ };
126
+ }
127
+
128
+ // Every board upload from the MCP is hashed + timestamped (ddmmyy) — provenance + dedup.
129
+ export function uploadStamp(content) {
130
+ const hash = createHash("sha256")
131
+ .update(JSON.stringify(content))
132
+ .digest("hex");
133
+ const d = new Date();
134
+ const p = (n) => String(n).padStart(2, "0");
135
+ const ddmmyy =
136
+ p(d.getUTCDate()) +
137
+ p(d.getUTCMonth() + 1) +
138
+ String(d.getUTCFullYear()).slice(-2);
139
+ return {
140
+ content_hash: hash,
141
+ submitted_ddmmyy: ddmmyy,
142
+ submitted_at: d.toISOString(),
143
+ };
144
+ }
145
+
146
+ // Pull a platform's local usage → 4 windows of canonical pillars. Routes through
147
+ // tokenpullAny() which handles Claude (native), Codex (estimated io_ratio), and all
148
+ // other adapters from the registry. opts.adapter overrides for tests.
149
+ export async function pullByPlatform(platform, opts = {}) {
150
+ if (opts.adapter) {
151
+ // Test injection: bypass registry and use the mock adapter directly
152
+ if (platform === "codex") {
153
+ let ioRatio = 2.0;
154
+ try {
155
+ const c = await pullLocal({});
156
+ const all = c.windows.find((w) => w.window === "all");
157
+ if (all && all.pillars.output > 0)
158
+ ioRatio = all.pillars.input / all.pillars.output;
159
+ } catch {
160
+ /* no Claude data → Alpha 2.0 */
161
+ }
162
+ return pullCodex({ ioRatio, adapter: opts.adapter, now: opts.now });
163
+ }
164
+ return pullLocal({ adapter: opts.adapter, now: opts.now });
165
+ }
166
+ return tokenpullAny(platform || "claude", opts);
167
+ }
168
+
169
+ /**
170
+ * Build a fetch wrapper with timeout + curl fallback + User-Agent stamp.
171
+ * Returns { doFetch, fetchJson } bound to the given opts.
172
+ */
173
+ export function buildFetch(opts = {}) {
174
+ const apiBase = opts.apiBase || DEFAULT_API_BASE;
175
+ const timeoutMs = opts.fetchTimeout ?? DEFAULT_FETCH_TIMEOUT;
176
+ const rawFetch = opts.fetchImpl || fetch;
177
+
178
+ const doFetch = async (url, init = {}) => {
179
+ const ac = new AbortController();
180
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
181
+ const headers = {
182
+ "user-agent": `node/${process.version} sigrank-mcp/${agentVersionStamp()}`,
183
+ ...(init.headers || {}),
184
+ };
185
+ try {
186
+ const res = await rawFetch(url, { ...init, headers, signal: ac.signal });
187
+ // Detect Vercel security checkpoint (403 + HTML body, not JSON)
188
+ if (res.status === 403) {
189
+ const text = await res.text();
190
+ if (
191
+ text.includes("Vercel Security Checkpoint") ||
192
+ text.includes("x-vercel-challenge")
193
+ ) {
194
+ // Fall back to curl transport
195
+ return curlFetch(url, init, timeoutMs);
196
+ }
197
+ // Real 403 from the API — return a mock response object
198
+ return {
199
+ ok: false,
200
+ status: 403,
201
+ json: async () => {
202
+ try {
203
+ return JSON.parse(text);
204
+ } catch {
205
+ return {};
206
+ }
207
+ },
208
+ text: async () => text,
209
+ };
210
+ }
211
+ return res;
212
+ } catch (e) {
213
+ if (e.name === "AbortError") throw e;
214
+ // Network error — try curl as fallback
215
+ try {
216
+ return await curlFetch(url, init, timeoutMs);
217
+ } catch {
218
+ throw e;
219
+ }
220
+ } finally {
221
+ clearTimeout(timer);
222
+ }
223
+ };
224
+
225
+ const fetchJson = async (path) => {
226
+ const res = await doFetch(`${apiBase}${path}`, {
227
+ headers: { accept: "application/json" },
228
+ });
229
+ if (!res.ok) throw new Error(`SigRank API ${path} → HTTP ${res.status}`);
230
+ return res.json();
231
+ };
232
+
233
+ return { apiBase, doFetch, fetchJson };
234
+ }
235
+
236
+ // Helper: attach _parseWarnings from pillars onto the cascade result so they
237
+ // are always visible in the tool output for review.
238
+ export function withParseWarnings(pillars, cascadeResult) {
239
+ if (pillars._parseWarnings && pillars._parseWarnings.length > 0) {
240
+ const existing = cascadeResult.warnings || [];
241
+ return {
242
+ ...cascadeResult,
243
+ warnings: [
244
+ ...existing,
245
+ ...pillars._parseWarnings.map((w) => `parse:${w}`),
246
+ ],
247
+ };
248
+ }
249
+ return cascadeResult;
250
+ }
251
+
252
+ // tokenpull window key → the board's window_type enum.
253
+ export const WINDOW_TYPE = { "7d": "7d", "30d": "30d", "90d": "90d", all: "all_time" };