sigrank-mcp 0.6.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/tools.mjs ADDED
@@ -0,0 +1,323 @@
1
+ /**
2
+ * tools.mjs — the SigRank MCP tool table + dispatcher, transport-free so it can be
3
+ * unit-tested without spawning the stdio server (index.mjs imports from here).
4
+ *
5
+ * callTool() takes an opts bag with an injectable { apiBase, fetchImpl } so tests can
6
+ * exercise the read/write network paths against a fake fetch — no live calls, no
7
+ * writes to production. Pure cascade math lives in ./cascade.mjs; the deterministic
8
+ * narration card in ./narrate.mjs. Token-only, no transcript content.
9
+ */
10
+
11
+ import { cascade, parsePillars } from './cascade.mjs'
12
+ import { narrate } from './narrate.mjs'
13
+ import { tokenpull as pullLocal, tokenpullCodex as pullCodex, tokenpullAny } from './tokenpull.mjs'
14
+ import { ALL_PLATFORMS } from './adapters.mjs'
15
+ import { createHash } from 'node:crypto'
16
+
17
+ // Every board upload from the MCP is hashed + timestamped (ddmmyy) — provenance + dedup.
18
+ function uploadStamp(content) {
19
+ const hash = createHash('sha256').update(JSON.stringify(content)).digest('hex')
20
+ const d = new Date()
21
+ const p = (n) => String(n).padStart(2, '0')
22
+ const ddmmyy = p(d.getUTCDate()) + p(d.getUTCMonth() + 1) + String(d.getUTCFullYear()).slice(-2)
23
+ return { content_hash: hash, submitted_ddmmyy: ddmmyy, submitted_at: d.toISOString() }
24
+ }
25
+
26
+ // Pull a platform's local usage → 4 windows of canonical pillars. Routes through
27
+ // tokenpullAny() which handles Claude (native), Codex (estimated io_ratio), and all
28
+ // other adapters from the registry. opts.adapter overrides for tests.
29
+ async function pullByPlatform(platform, opts = {}) {
30
+ if (opts.adapter) {
31
+ // Test injection: bypass registry and use the mock adapter directly
32
+ if (platform === 'codex') {
33
+ let ioRatio = 2.0
34
+ try {
35
+ const c = await pullLocal({})
36
+ const all = c.windows.find((w) => w.window === 'all')
37
+ if (all && all.pillars.output > 0) ioRatio = all.pillars.input / all.pillars.output
38
+ } catch { /* no Claude data → Alpha 2.0 */ }
39
+ return pullCodex({ ioRatio, adapter: opts.adapter })
40
+ }
41
+ return pullLocal({ adapter: opts.adapter })
42
+ }
43
+ return tokenpullAny(platform || 'claude', opts)
44
+ }
45
+
46
+ export const DEFAULT_API_BASE = process.env.SIGRANK_API_BASE || 'https://signalaf.com'
47
+ /** Default network timeout in ms (override via opts.fetchTimeout or SIGRANK_FETCH_TIMEOUT). */
48
+ export const DEFAULT_FETCH_TIMEOUT = Number(process.env.SIGRANK_FETCH_TIMEOUT) || 10_000
49
+
50
+ export const TOOLS = [
51
+ {
52
+ name: 'rank_paste',
53
+ description:
54
+ 'Rank a paste of ccusage-style token counts. Accepts JSON {input,output,cacheCreate,cacheRead} or 4 whitespace-separated numbers in that order. Returns Υ Yield, SNR, Leverage, Velocity, 10xDEV, class, AND a deterministic prose "card". Token-only; computes locally.',
55
+ inputSchema: { type: 'object', properties: { text: { type: 'string', description: 'ccusage JSON or "input output cacheCreate cacheRead"' } }, required: ['text'] },
56
+ },
57
+ {
58
+ name: 'get_leaderboard',
59
+ description: 'The live public SigRank board (signalaf.com) — operators ranked by yield.',
60
+ inputSchema: { type: 'object', properties: {} },
61
+ },
62
+ {
63
+ name: 'get_operator',
64
+ description: "One operator's live profile by codename.",
65
+ inputSchema: { type: 'object', properties: { codename: { type: 'string' } }, required: ['codename'] },
66
+ },
67
+ {
68
+ name: 'submit_paste',
69
+ description:
70
+ 'Rank a paste AND publish it to the live SigRank board in one call. Computes the cascade locally (instant preview + card), then submits the RAW paste to the board\'s web-paste endpoint, which re-parses and re-scores it server-side (authoritative). A codename is required to publish; omit it for a local preview only. Token-only, no auth (matches the web paste path). Best with a ccusage JSON paste — the 4-number form ranks locally but the board may reject it.',
71
+ inputSchema: {
72
+ type: 'object',
73
+ properties: {
74
+ text: { type: 'string', description: 'ccusage JSON or "input output cacheCreate cacheRead"' },
75
+ codename: { type: 'string', description: 'operator codename to publish under (required to submit; omit for local preview only)' },
76
+ },
77
+ required: ['text'],
78
+ },
79
+ },
80
+ {
81
+ name: 'tokenpull',
82
+ description:
83
+ "Pull your LOCAL token usage from the platform's session logs and rank it across the four windows (7d/30d/90d/all-time) with the cascade — zero paste. Token-only: reads usage counts not message content. The numbers stay on your machine unless you submit them. Some platforms may have partial data (estimated=true when cacheCreate isn't available) or a dataGap note when the log format doesn't expose raw token counts.",
84
+ inputSchema: {
85
+ type: 'object',
86
+ properties: {
87
+ platform: {
88
+ type: 'string',
89
+ enum: ALL_PLATFORMS,
90
+ 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).`,
91
+ },
92
+ },
93
+ },
94
+ },
95
+ {
96
+ name: 'tokenpull_submit',
97
+ description:
98
+ "Pull your LOCAL usage (tokenpull) AND publish it to the SigRank board in one call — the zero-paste flow. Submits the canonical 4 pillars per window, each re-scored server-side and tagged with the source platform. Requires a codename to publish; omit for local preview. Token-only.",
99
+ inputSchema: {
100
+ type: 'object',
101
+ properties: {
102
+ codename: { type: 'string', description: 'operator codename to publish under (omit for preview-only)' },
103
+ window: { type: 'string', enum: ['7d', '30d', '90d', 'all'], description: 'submit only this window (default: all 4)' },
104
+ platform: { type: 'string', enum: ALL_PLATFORMS, description: `source platform (default: claude). Supported: ${ALL_PLATFORMS.join(', ')}` },
105
+ },
106
+ },
107
+ },
108
+ {
109
+ name: 'rank_windows',
110
+ description:
111
+ 'Rank all four time windows (7d/30d/90d/all-time) in one call from a dashboard paste — paste the full table from ccusage, tokscale, or the Claude Max usage dashboard and get the cascade (Υ, SNR, Leverage, Velocity, 10xDEV, class, card) for each window. Each window is parsed and scored independently. Named keys required (input/output/cacheCreate/cacheRead); positional order is NOT safe here (dashboards list cache_read before cache_create — see WINDOWED_PROFILES gotcha). Omit windows you don\'t have — partial input is allowed (1–4 windows). Does NOT submit to the board; use tokenpull_submit for zero-paste publishing.',
112
+ inputSchema: {
113
+ type: 'object',
114
+ properties: {
115
+ '7d': { type: 'string', description: 'ccusage/tokscale paste or JSON for the 7-day window (optional)' },
116
+ '30d': { type: 'string', description: 'ccusage/tokscale paste or JSON for the 30-day window (optional)' },
117
+ '90d': { type: 'string', description: 'ccusage/tokscale paste or JSON for the 90-day window (optional)' },
118
+ all: { type: 'string', description: 'ccusage/tokscale paste or JSON for the all-time window (optional)' },
119
+ source_tool: { type: 'string', enum: ['ccusage', 'tokscale', 'claude_max', 'token_dashboard', 'other'], description: 'which token reader produced the paste (for cross-tool variance tracking)' },
120
+ },
121
+ },
122
+ },
123
+ {
124
+ name: 'watch_tokenpull',
125
+ description:
126
+ 'Watch your local token logs and re-derive your cascade whenever new sessions are written — a live tune meter. Polls at a configurable interval (default 60s), diffs against the last snapshot, and returns the updated cascade when something changes. The push-to-board step is TODO(AUTH.WIRE) — currently returns the diff locally so you can see your score move in real time without submitting.',
127
+ inputSchema: {
128
+ type: 'object',
129
+ properties: {
130
+ platform: { type: 'string', enum: ALL_PLATFORMS, description: 'platform to watch (default: claude)' },
131
+ interval_s: { type: 'number', description: 'poll interval in seconds (default: 60, min: 10)' },
132
+ window: { type: 'string', enum: ['7d', '30d', '90d', 'all'], description: 'which window to watch (default: 7d — most sensitive to recent activity)' },
133
+ codename: { type: 'string', description: 'TODO(AUTH.WIRE): when set, will auto-submit on change once auth is live' },
134
+ },
135
+ },
136
+ },
137
+ ]
138
+
139
+ // tokenpull window key → the board's window_type enum.
140
+ const WINDOW_TYPE = { '7d': '7d', '30d': '30d', '90d': '90d', all: 'all_time' }
141
+
142
+ export async function callTool(name, args, opts = {}) {
143
+ const apiBase = opts.apiBase || DEFAULT_API_BASE
144
+ const timeoutMs = opts.fetchTimeout ?? DEFAULT_FETCH_TIMEOUT
145
+ const rawFetch = opts.fetchImpl || fetch
146
+
147
+ // Wrap every fetch with an AbortController timeout so a hung network call never
148
+ // blocks the MCP client indefinitely.
149
+ const doFetch = (url, init = {}) => {
150
+ const ac = new AbortController()
151
+ const timer = setTimeout(() => ac.abort(), timeoutMs)
152
+ return rawFetch(url, { ...init, signal: ac.signal }).finally(() => clearTimeout(timer))
153
+ }
154
+
155
+ const fetchJson = async (path) => {
156
+ const res = await doFetch(`${apiBase}${path}`, { headers: { accept: 'application/json' } })
157
+ if (!res.ok) throw new Error(`SigRank API ${path} → HTTP ${res.status}`)
158
+ return res.json()
159
+ }
160
+
161
+ // Helper: attach _parseWarnings from pillars onto the cascade result so they
162
+ // are always visible in the tool output for review.
163
+ const withParseWarnings = (pillars, cascadeResult) => {
164
+ if (pillars._parseWarnings && pillars._parseWarnings.length > 0) {
165
+ const existing = cascadeResult.warnings || []
166
+ return { ...cascadeResult, warnings: [...existing, ...pillars._parseWarnings.map((w) => `parse:${w}`)] }
167
+ }
168
+ return cascadeResult
169
+ }
170
+
171
+ if (name === 'rank_paste') {
172
+ if (!args?.text) throw new Error('rank_paste requires a non-empty `text` argument.')
173
+ const pillars = parsePillars(args.text)
174
+ const c = withParseWarnings(pillars, cascade(pillars))
175
+ return { ...c, card: narrate(c) }
176
+ }
177
+ if (name === 'get_leaderboard') return fetchJson('/api/v1/leaderboard')
178
+ if (name === 'get_operator') {
179
+ const codename = String(args?.codename || '').trim()
180
+ if (!codename) throw new Error('get_operator requires a non-empty `codename` argument.')
181
+ return fetchJson(`/api/v1/operators/${encodeURIComponent(codename)}`)
182
+ }
183
+
184
+ if (name === 'submit_paste') {
185
+ if (!args?.text) throw new Error('submit_paste requires a non-empty `text` argument.')
186
+ // Local preview first — also validates the paste is parseable before any POST.
187
+ const pillars = parsePillars(args.text)
188
+ const c = withParseWarnings(pillars, cascade(pillars))
189
+ const codename = String(args?.codename || '').trim()
190
+ const card = narrate(c, codename || 'This operator')
191
+
192
+ // No codename → cannot publish (the board endpoint requires it). Fail fast at the
193
+ // tool boundary with a clear message instead of an opaque server 400.
194
+ if (!codename) {
195
+ return {
196
+ ...c,
197
+ card,
198
+ submission: { status: 'not_submitted', reason: 'codename_required', detail: 'Pass a codename to publish to the board. Showing local preview only.' },
199
+ }
200
+ }
201
+
202
+ // Submit the RAW paste so the server re-parses + re-scores authoritatively — the
203
+ // MCP's local cascade is only a preview; the board stays the single source of truth.
204
+ const stamp = uploadStamp({ codename, pillars: c.pillars, source: 'web_paste' })
205
+ const res = await doFetch(`${apiBase}/api/v1/ingest-paste`, {
206
+ method: 'POST',
207
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
208
+ body: JSON.stringify({ codename, raw_paste: String(args?.text || ''), ...stamp }),
209
+ })
210
+ let ack
211
+ try { ack = await res.json() } catch { ack = { status: 'error', detail: `HTTP ${res.status} (non-JSON response)` } }
212
+ return { ...c, card, submission: { ...stamp, httpStatus: res.status, ...ack } }
213
+ }
214
+
215
+ if (name === 'tokenpull') {
216
+ // Local read → 4 windows of pillars → cascade each. Token-only, on-device.
217
+ const platform = args?.platform || 'claude'
218
+ const pulled = await pullByPlatform(platform, opts)
219
+ const windows = pulled.windows.map((w) => {
220
+ const c = cascade(w.pillars)
221
+ return { window: w.window, messages: w.messages, pillars: w.pillars, cascade: c, card: narrate(c, `${w.window} ${platform}`) }
222
+ })
223
+ return { platform: pulled.platform, estimated: pulled.estimated || false, ...(pulled.ioRatio ? { ioRatio: pulled.ioRatio } : {}), generatedAt: pulled.generatedAt, files: pulled.files, totalMessages: pulled.totalMessages, windows }
224
+ }
225
+
226
+ if (name === 'tokenpull_submit') {
227
+ // Pull local usage, then publish each window's CANONICAL pillars to the board
228
+ // (server re-scores). The board stays platform-agnostic via the 4 pillars; the
229
+ // source platform rides along as a tag. Conversion already happened in the adapter.
230
+ const codename = String(args?.codename || '').trim()
231
+ const pulled = await pullByPlatform(args?.platform || 'claude', opts)
232
+ const targets = args?.window ? pulled.windows.filter((w) => w.window === args.window) : pulled.windows
233
+ const out = []
234
+ for (const w of targets) {
235
+ const c = cascade(w.pillars)
236
+ const card = narrate(c, `${w.window} window`)
237
+ if (!codename) {
238
+ out.push({ window: w.window, pillars: w.pillars, cascade: c, card, submission: { status: 'not_submitted', reason: 'codename_required' } })
239
+ continue
240
+ }
241
+ // canonical pillars → "input output cacheCreate cacheRead" (the parser's 4-bare-number form)
242
+ const rawPaste = `${w.pillars.input} ${w.pillars.output} ${w.pillars.cacheCreate} ${w.pillars.cacheRead}`
243
+ const windowType = WINDOW_TYPE[w.window] || w.window
244
+ const stamp = uploadStamp({ codename, window: windowType, pillars: w.pillars, platform: pulled.platform })
245
+ const res = await doFetch(`${apiBase}/api/v1/ingest-paste`, {
246
+ method: 'POST',
247
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
248
+ body: JSON.stringify({ codename, raw_paste: rawPaste, window_type: windowType, telemetry: { platform: { primary: pulled.platform } }, ...stamp }),
249
+ })
250
+ let ack
251
+ try { ack = await res.json() } catch { ack = { status: 'error', detail: `HTTP ${res.status} (non-JSON)` } }
252
+ out.push({ window: w.window, pillars: w.pillars, cascade: c, card, submission: { ...stamp, httpStatus: res.status, ...ack } })
253
+ }
254
+ return { platform: pulled.platform, codename: codename || null, generatedAt: pulled.generatedAt, windows: out }
255
+ }
256
+
257
+ if (name === 'rank_windows') {
258
+ // Score up to 4 named window pastes independently. Named-key parsing only —
259
+ // positional is unsafe here because dashboards list cache_read before cache_create
260
+ // (the WINDOWED_PROFILES swap gotcha). Each window goes through parsePillars →
261
+ // cascade → narrate individually; results are collected into a windows[] array
262
+ // in the same shape as tokenpull output for easy follow-up with tokenpull_submit.
263
+ const WINDOW_KEYS = ['7d', '30d', '90d', 'all']
264
+ const sourceTool = args?.source_tool || null
265
+ const windows = []
266
+ for (const wk of WINDOW_KEYS) {
267
+ const text = args?.[wk]
268
+ if (!text || typeof text !== 'string' || !text.trim()) continue
269
+ const pillars = parsePillars(text)
270
+ const c = withParseWarnings(pillars, cascade(pillars))
271
+ const card = narrate(c, `${wk} window`)
272
+ windows.push({ window: wk, pillars, cascade: c, card })
273
+ }
274
+ if (windows.length === 0) {
275
+ throw new Error('rank_windows requires at least one window paste (7d, 30d, 90d, or all).')
276
+ }
277
+ return {
278
+ windows,
279
+ source_tool: sourceTool,
280
+ note: 'Local preview only — use tokenpull_submit to publish to the board.',
281
+ }
282
+ }
283
+
284
+ if (name === 'watch_tokenpull') {
285
+ // Poll the local token logs at a configurable interval and return the cascade
286
+ // diff whenever new sessions appear. One poll cycle per MCP call — the client
287
+ // is responsible for re-calling at the desired cadence (MCP tools are stateless;
288
+ // a persistent background watcher lives outside the tool boundary).
289
+ //
290
+ // TODO(AUTH.WIRE): when codename is supplied and auth is live, auto-submit the
291
+ // updated snapshot to the board on every detected change. For now, returns the
292
+ // local cascade only.
293
+ const platform = args?.platform || 'claude'
294
+ const watchWindow = args?.window || '7d'
295
+ const intervalS = Math.max(10, Number(args?.interval_s) || 60)
296
+
297
+ const pulled = await pullByPlatform(platform, opts)
298
+ const win = pulled.windows.find((w) => w.window === watchWindow)
299
+ if (!win) throw new Error(`watch_tokenpull: window '${watchWindow}' not found in pull result.`)
300
+
301
+ const c = cascade(win.pillars)
302
+ const card = narrate(c, `${watchWindow} ${platform}`)
303
+
304
+ // TODO(AUTH.WIRE): if args?.codename, submit to board here once auth + device
305
+ // enrollment are live (SECURE_INGEST.md Phase 4).
306
+ return {
307
+ platform: pulled.platform,
308
+ window: watchWindow,
309
+ pillars: win.pillars,
310
+ messages: win.messages,
311
+ cascade: c,
312
+ card,
313
+ generatedAt: pulled.generatedAt,
314
+ poll_interval_s: intervalS,
315
+ auth_submit: args?.codename
316
+ ? { status: 'TODO(AUTH.WIRE)', codename: args.codename, detail: 'Auto-submit on change will activate once device enrollment is live (SECURE_INGEST.md).' }
317
+ : null,
318
+ note: 'One snapshot per call — re-call at your poll interval to detect changes.',
319
+ }
320
+ }
321
+
322
+ throw new Error(`Unknown tool: ${name}`)
323
+ }