sigrank 0.11.4

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,610 @@
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 { ensureIdentity, recordEnrollment } from './keystore.mjs'
16
+ import { submitSignedWindow } from './submit.mjs'
17
+ import { createHash } from 'node:crypto'
18
+ import { execSync } from 'node:child_process'
19
+ import { existsSync, readFileSync } from 'node:fs'
20
+ import os from 'node:os'
21
+ import path from 'node:path'
22
+
23
+ // ── Verifier readers (sync, on-device, token-only) ────────────────────────────
24
+ // These mirror the implementations in cli.mjs / tui.mjs without the circular import.
25
+
26
+ function _ccusagePillars(platform = 'claude') {
27
+ try {
28
+ const raw = execSync(`ccusage ${platform} daily --json`, { timeout: 15000, stdio: ['ignore','pipe','ignore'] }).toString()
29
+ const rows = JSON.parse(raw)?.daily ?? JSON.parse(raw)
30
+ const now = Date.now()
31
+ const result = {}
32
+ for (const [win, days] of Object.entries({ '7d': 7, '30d': 30, '90d': 90 })) {
33
+ const since = new Date(now - days * 86400000)
34
+ let i=0,o=0,cw=0,cr=0
35
+ for (const r of rows) {
36
+ if (new Date(r.date ?? r.day ?? '1970') >= since) {
37
+ i += r.inputTokens ?? r.input_tokens ?? 0
38
+ o += r.outputTokens ?? r.output_tokens ?? 0
39
+ cw += r.cacheCreationTokens ?? r.cache_create_tokens ?? 0
40
+ cr += r.cacheReadTokens ?? r.cache_read_tokens ?? 0
41
+ }
42
+ }
43
+ result[win] = { input: i, output: o, cacheCreate: cw, cacheRead: cr }
44
+ }
45
+ let i=0,o=0,cw=0,cr=0
46
+ for (const r of rows) {
47
+ i+=r.inputTokens??0; o+=r.outputTokens??0; cw+=r.cacheCreationTokens??0; cr+=r.cacheReadTokens??0
48
+ }
49
+ result['all'] = { input: i, output: o, cacheCreate: cw, cacheRead: cr }
50
+ return result
51
+ } catch { return null }
52
+ }
53
+
54
+ function _tokenDashPillars() {
55
+ const dbPath = path.join(os.homedir(), '.claude', 'token-dashboard.db')
56
+ if (!existsSync(dbPath)) return null
57
+ try {
58
+ const raw = execSync(
59
+ `sqlite3 "${dbPath}" "SELECT SUM(input_tokens),SUM(output_tokens),SUM(cache_create_5m_tokens)+SUM(cache_create_1h_tokens),SUM(cache_read_tokens) FROM messages"`,
60
+ { timeout: 5000, stdio: ['ignore','pipe','ignore'] }
61
+ ).toString().trim()
62
+ const [i,o,cw,cr] = raw.split('|').map(Number)
63
+ return { all: { input: i||0, output: o||0, cacheCreate: cw||0, cacheRead: cr||0 } }
64
+ } catch { return null }
65
+ }
66
+
67
+ function _tokscalePillars(platform = 'claude') {
68
+ const p = path.join(os.homedir(), 'tokscale_report.json')
69
+ if (!existsSync(p)) return null
70
+ try {
71
+ const data = JSON.parse(readFileSync(p, 'utf8'))
72
+ const rows = (data.entries ?? []).filter(e =>
73
+ e.client === platform && e.model !== '<synthetic>' && e.model !== 'unknown' && (e.input > 0 || e.output > 0)
74
+ )
75
+ if (!rows.length) return null
76
+ const acc = rows.reduce((a,e) => ({
77
+ input: a.input+(e.input??0), output: a.output+(e.output??0),
78
+ cacheCreate: a.cacheCreate+(e.cacheWrite??0), cacheRead: a.cacheRead+(e.cacheRead??0),
79
+ }), { input:0, output:0, cacheCreate:0, cacheRead:0 })
80
+ return { all: acc }
81
+ } catch { return null }
82
+ }
83
+
84
+ // Every board upload from the MCP is hashed + timestamped (ddmmyy) — provenance + dedup.
85
+ function uploadStamp(content) {
86
+ const hash = createHash('sha256').update(JSON.stringify(content)).digest('hex')
87
+ const d = new Date()
88
+ const p = (n) => String(n).padStart(2, '0')
89
+ const ddmmyy = p(d.getUTCDate()) + p(d.getUTCMonth() + 1) + String(d.getUTCFullYear()).slice(-2)
90
+ return { content_hash: hash, submitted_ddmmyy: ddmmyy, submitted_at: d.toISOString() }
91
+ }
92
+
93
+ // Pull a platform's local usage → 4 windows of canonical pillars. Routes through
94
+ // tokenpullAny() which handles Claude (native), Codex (estimated io_ratio), and all
95
+ // other adapters from the registry. opts.adapter overrides for tests.
96
+ async function pullByPlatform(platform, opts = {}) {
97
+ if (opts.adapter) {
98
+ // Test injection: bypass registry and use the mock adapter directly
99
+ if (platform === 'codex') {
100
+ let ioRatio = 2.0
101
+ try {
102
+ const c = await pullLocal({})
103
+ const all = c.windows.find((w) => w.window === 'all')
104
+ if (all && all.pillars.output > 0) ioRatio = all.pillars.input / all.pillars.output
105
+ } catch { /* no Claude data → Alpha 2.0 */ }
106
+ return pullCodex({ ioRatio, adapter: opts.adapter, now: opts.now })
107
+ }
108
+ return pullLocal({ adapter: opts.adapter, now: opts.now })
109
+ }
110
+ return tokenpullAny(platform || 'claude', opts)
111
+ }
112
+
113
+ export const DEFAULT_API_BASE = process.env.SIGRANK_API_BASE || 'https://signalaf.com'
114
+ /** Default network timeout in ms (override via opts.fetchTimeout or SIGRANK_FETCH_TIMEOUT). */
115
+ export const DEFAULT_FETCH_TIMEOUT = Number(process.env.SIGRANK_FETCH_TIMEOUT) || 10_000
116
+
117
+ export const TOOLS = [
118
+ {
119
+ name: 'rank_paste',
120
+ description:
121
+ '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.',
122
+ inputSchema: { type: 'object', properties: { text: { type: 'string', description: 'ccusage JSON or "input output cacheCreate cacheRead"' } }, required: ['text'] },
123
+ },
124
+ {
125
+ name: 'get_leaderboard',
126
+ description: 'The live public SigRank board (signalaf.com) — operators ranked by yield.',
127
+ inputSchema: { type: 'object', properties: {} },
128
+ },
129
+ {
130
+ name: 'get_operator',
131
+ description: "One operator's live profile by codename.",
132
+ inputSchema: { type: 'object', properties: { codename: { type: 'string' } }, required: ['codename'] },
133
+ },
134
+ {
135
+ name: 'submit_paste',
136
+ description:
137
+ '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.',
138
+ inputSchema: {
139
+ type: 'object',
140
+ properties: {
141
+ text: { type: 'string', description: 'ccusage JSON or "input output cacheCreate cacheRead"' },
142
+ codename: { type: 'string', description: 'operator codename to publish under (required to submit; omit for local preview only)' },
143
+ },
144
+ required: ['text'],
145
+ },
146
+ },
147
+ {
148
+ name: 'tokenpull',
149
+ description:
150
+ "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.",
151
+ inputSchema: {
152
+ type: 'object',
153
+ properties: {
154
+ platform: {
155
+ type: 'string',
156
+ enum: ALL_PLATFORMS,
157
+ 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).`,
158
+ },
159
+ },
160
+ },
161
+ },
162
+ {
163
+ name: 'tokenpull_submit',
164
+ description:
165
+ "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.",
166
+ inputSchema: {
167
+ type: 'object',
168
+ properties: {
169
+ codename: { type: 'string', description: 'operator codename to publish under (omit for preview-only)' },
170
+ window: { type: 'string', enum: ['7d', '30d', '90d', 'all'], description: 'submit only this window (default: all 4)' },
171
+ platform: { type: 'string', enum: ALL_PLATFORMS, description: `source platform (default: claude). Supported: ${ALL_PLATFORMS.join(', ')}` },
172
+ },
173
+ },
174
+ },
175
+ {
176
+ name: 'rank_windows',
177
+ description:
178
+ '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.',
179
+ inputSchema: {
180
+ type: 'object',
181
+ properties: {
182
+ '7d': { type: 'string', description: 'ccusage/tokscale paste or JSON for the 7-day window (optional)' },
183
+ '30d': { type: 'string', description: 'ccusage/tokscale paste or JSON for the 30-day window (optional)' },
184
+ '90d': { type: 'string', description: 'ccusage/tokscale paste or JSON for the 90-day window (optional)' },
185
+ all: { type: 'string', description: 'ccusage/tokscale paste or JSON for the all-time window (optional)' },
186
+ source_tool: { type: 'string', enum: ['ccusage', 'tokscale', 'claude_max', 'token_dashboard', 'other'], description: 'which token reader produced the paste (for cross-tool variance tracking)' },
187
+ },
188
+ },
189
+ },
190
+ {
191
+ name: 'watch_tokenpull',
192
+ description:
193
+ '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) and returns the updated cascade. With submit:true (and an enrolled device) it also signs + publishes the watched window to the board each poll; default is preview-only (no submit).',
194
+ inputSchema: {
195
+ type: 'object',
196
+ properties: {
197
+ platform: { type: 'string', enum: ALL_PLATFORMS, description: 'platform to watch (default: claude)' },
198
+ interval_s: { type: 'number', description: 'poll interval in seconds (default: 60, min: 10)' },
199
+ window: { type: 'string', enum: ['7d', '30d', '90d', 'all'], description: 'which window to watch (default: 7d — most sensitive to recent activity)' },
200
+ submit: { type: 'boolean', description: 'auto-submit the watched window to the board as a VERIFIED operator each poll (requires `enroll`; default false = preview only)' },
201
+ },
202
+ },
203
+ },
204
+ {
205
+ name: 'tokenpull_compare',
206
+ description:
207
+ 'Pull token usage from ALL four local sources in parallel — tokenpull (JSONL canon), ccusage CLI, token-dashboard SQLite, and tokscale report — and return them side-by-side with delta % vs tokenpull as the baseline. Also computes the cascade (Υ, SNR, Leverage, class) for each source so you can see how each verifier scores. Useful for validating your numbers before submitting, or understanding discrepancies between tools. Claude only for token-dash; codex and others use tokenpull + ccusage + tokscale. Token-only, on-device.',
208
+ inputSchema: {
209
+ type: 'object',
210
+ properties: {
211
+ platform: {
212
+ type: 'string',
213
+ enum: ALL_PLATFORMS,
214
+ description: 'platform to compare (default: claude). token-dash and App only available for claude.',
215
+ },
216
+ },
217
+ },
218
+ },
219
+ {
220
+ name: 'enroll',
221
+ description:
222
+ 'Bind THIS device to your SigRank operator so your signed token runs cascade to the live board. Paste the key from signalaf.com → Settings → "New key" (or "Generate connect code"). On first run it generates + stores a local ed25519 keypair (~/.sigrank-mcp/identity.json); only the PUBLIC key is ever sent. Need a new key? Click "New key" at signalaf.com → Settings, then paste it here.',
223
+ inputSchema: {
224
+ type: 'object',
225
+ properties: {
226
+ code: { type: 'string', description: 'the key / connect code (SIGR-XXXXX-XXXXX-XXXXX) from Settings → New key (or Generate connect code)' },
227
+ device_label: { type: 'string', description: 'optional label for this device (default: hostname · agent version)' },
228
+ },
229
+ required: ['code'],
230
+ },
231
+ },
232
+ {
233
+ name: 'submit_verified',
234
+ description:
235
+ 'Publish your LOCAL token runs to the SigRank board as a VERIFIED operator — the enrolled, signed path. Reads your pillars (tokenpull), builds the canonical Schema 1.0 snapshot per window, ed25519-signs it with your device key, and POSTs to /api/v1/snapshots. Requires `npx sigrank-mcp enroll` first (a bound device). Only signed submissions from a trusted device rank on the board. Token-only; the private key never leaves your machine.',
236
+ inputSchema: {
237
+ type: 'object',
238
+ properties: {
239
+ window: { type: 'string', enum: ['7d', '30d', '90d', 'all'], description: 'submit only this window (default: all 4)' },
240
+ platform: { type: 'string', enum: ALL_PLATFORMS, description: 'source platform (default: claude)' },
241
+ },
242
+ },
243
+ },
244
+ ]
245
+
246
+ // tokenpull window key → the board's window_type enum.
247
+ const WINDOW_TYPE = { '7d': '7d', '30d': '30d', '90d': '90d', all: 'all_time' }
248
+
249
+ export async function callTool(name, args, opts = {}) {
250
+ const apiBase = opts.apiBase || DEFAULT_API_BASE
251
+ const timeoutMs = opts.fetchTimeout ?? DEFAULT_FETCH_TIMEOUT
252
+ const rawFetch = opts.fetchImpl || fetch
253
+
254
+ // Wrap every fetch with an AbortController timeout so a hung network call never
255
+ // blocks the MCP client indefinitely.
256
+ const doFetch = (url, init = {}) => {
257
+ const ac = new AbortController()
258
+ const timer = setTimeout(() => ac.abort(), timeoutMs)
259
+ return rawFetch(url, { ...init, signal: ac.signal }).finally(() => clearTimeout(timer))
260
+ }
261
+
262
+ const fetchJson = async (path) => {
263
+ const res = await doFetch(`${apiBase}${path}`, { headers: { accept: 'application/json' } })
264
+ if (!res.ok) throw new Error(`SigRank API ${path} → HTTP ${res.status}`)
265
+ return res.json()
266
+ }
267
+
268
+ // Helper: attach _parseWarnings from pillars onto the cascade result so they
269
+ // are always visible in the tool output for review.
270
+ const withParseWarnings = (pillars, cascadeResult) => {
271
+ if (pillars._parseWarnings && pillars._parseWarnings.length > 0) {
272
+ const existing = cascadeResult.warnings || []
273
+ return { ...cascadeResult, warnings: [...existing, ...pillars._parseWarnings.map((w) => `parse:${w}`)] }
274
+ }
275
+ return cascadeResult
276
+ }
277
+
278
+ if (name === 'rank_paste') {
279
+ if (!args?.text) throw new Error('rank_paste requires a non-empty `text` argument.')
280
+ const pillars = parsePillars(args.text)
281
+ const c = withParseWarnings(pillars, cascade(pillars))
282
+ return { ...c, card: narrate(c) }
283
+ }
284
+ if (name === 'get_leaderboard') return fetchJson('/api/v1/leaderboard?metric=yield_')
285
+ if (name === 'get_operator') {
286
+ const codename = String(args?.codename || '').trim()
287
+ if (!codename) throw new Error('get_operator requires a non-empty `codename` argument.')
288
+ return fetchJson(`/api/v1/operators/${encodeURIComponent(codename)}`)
289
+ }
290
+
291
+ if (name === 'enroll') {
292
+ // Redeem a web connect code → bind this device. Generates/loads the local keypair;
293
+ // sends ONLY the public key. operator binding happens server-side from the code row.
294
+ const code = String(args?.code || '').trim()
295
+ if (!code) throw new Error('enroll requires a `code` — paste your connect code from signalaf.com → Settings → Connect a device.')
296
+ const id = opts.identity || ensureIdentity()
297
+ const deviceLabel = String(args?.device_label || `${os.hostname()} · ${id.agent_version}`).slice(0, 120)
298
+ const res = await doFetch(`${apiBase}/api/v1/devices/enroll`, {
299
+ method: 'POST',
300
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
301
+ body: JSON.stringify({
302
+ code,
303
+ device_id: id.device_id,
304
+ public_key: id.public_key,
305
+ device_label: deviceLabel,
306
+ agent_version: id.agent_version,
307
+ }),
308
+ })
309
+ let ack
310
+ try { ack = await res.json() } catch { ack = {} }
311
+ if (res.status === 201 && ack.status === 'enrolled') {
312
+ // Persist the binding locally (skipped when a test injects opts.identity → no keystore write).
313
+ if (!opts.identity) recordEnrollment({ codename: ack.codename, operator_id: ack.operator_id })
314
+ return {
315
+ status: 'enrolled',
316
+ codename: ack.codename ?? null,
317
+ operator_id: ack.operator_id ?? null,
318
+ device_id: id.device_id,
319
+ trust_status: ack.trust_status ?? 'trusted',
320
+ }
321
+ }
322
+ return { status: 'error', httpStatus: res.status, reason: ack.reason || ack.status || `http_${res.status}`, detail: ack.detail ?? null }
323
+ }
324
+
325
+ if (name === 'submit_verified') {
326
+ // The enrolled, signed publish path → /api/v1/snapshots (only verified rows rank).
327
+ const id = opts.identity || ensureIdentity()
328
+ if (!id.codename || !id.operator_id) {
329
+ return { status: 'not_enrolled', detail: 'Run `npx sigrank-mcp enroll` to bind this device first.' }
330
+ }
331
+ const platform = args?.platform || 'claude'
332
+
333
+ // MULTI: the combined cross-platform cascade. The dashboard already SUMS every
334
+ // active platform's pillars (a "claude+codex" row) but never submitted it — this
335
+ // is that missing submission. Aggregate every locally-detected platform's pillars
336
+ // per window and publish as platform='multi' = the operator's TOTAL usage. Empty
337
+ // windows are skipped so a no-activity window never lands as a degenerate row.
338
+ if (platform === 'multi') {
339
+ const detected = []
340
+ for (const p of ALL_PLATFORMS) {
341
+ const r = await pullByPlatform(p, opts).catch(() => null)
342
+ const live = r && (r.windows || []).some(
343
+ (w) => (w.pillars.input + w.pillars.output + w.pillars.cacheCreate + w.pillars.cacheRead) > 0,
344
+ )
345
+ if (live) detected.push(r)
346
+ }
347
+ if (detected.length < 2) {
348
+ return {
349
+ platform: 'multi', codename: id.codename, operator_id: id.operator_id,
350
+ status: 'skipped', reason: 'need_2_platforms',
351
+ detail: `multi needs 2+ active platforms; found ${detected.length} (${detected.map((d) => d.platform).join(', ') || 'none'}).`,
352
+ windows: [],
353
+ }
354
+ }
355
+ const winKeys = args?.window ? [args.window] : ['7d', '30d', '90d', 'all']
356
+ const out = []
357
+ for (const wk of winKeys) {
358
+ const sum = { input: 0, output: 0, cacheCreate: 0, cacheRead: 0 }
359
+ let msgs = 0
360
+ for (const d of detected) {
361
+ const w = (d.windows || []).find((x) => x.window === wk)
362
+ if (!w) continue
363
+ sum.input += w.pillars.input || 0
364
+ sum.output += w.pillars.output || 0
365
+ sum.cacheCreate += w.pillars.cacheCreate || 0
366
+ sum.cacheRead += w.pillars.cacheRead || 0
367
+ msgs += w.messages || 0
368
+ }
369
+ if (sum.input + sum.output + sum.cacheCreate + sum.cacheRead <= 0) continue // skip empty window
370
+ const r = await submitSignedWindow(wk, sum, msgs, id, { apiBase, fetchImpl: doFetch, platform: 'multi', now: opts.now })
371
+ out.push({ window: wk, pillars: sum, ...r })
372
+ }
373
+ return { platform: 'multi', codename: id.codename, operator_id: id.operator_id, sources: detected.map((d) => d.platform), windows: out }
374
+ }
375
+
376
+ const pulled = await pullByPlatform(platform, opts)
377
+ const targets = args?.window ? pulled.windows.filter((w) => w.window === args.window) : pulled.windows
378
+ const out = []
379
+ for (const w of targets) {
380
+ const r = await submitSignedWindow(w.window, w.pillars, w.messages, id, {
381
+ apiBase,
382
+ fetchImpl: doFetch,
383
+ platform: pulled.platform,
384
+ now: opts.now,
385
+ })
386
+ out.push({ window: w.window, pillars: w.pillars, ...r })
387
+ }
388
+ return { platform: pulled.platform, codename: id.codename, operator_id: id.operator_id, generatedAt: pulled.generatedAt, windows: out }
389
+ }
390
+
391
+ if (name === 'submit_paste') {
392
+ if (!args?.text) throw new Error('submit_paste requires a non-empty `text` argument.')
393
+ // Local preview first — also validates the paste is parseable before any POST.
394
+ const pillars = parsePillars(args.text)
395
+ const c = withParseWarnings(pillars, cascade(pillars))
396
+ const codename = String(args?.codename || '').trim()
397
+ const card = narrate(c, codename || 'This operator')
398
+
399
+ // No codename → cannot publish (the board endpoint requires it). Fail fast at the
400
+ // tool boundary with a clear message instead of an opaque server 400.
401
+ if (!codename) {
402
+ return {
403
+ ...c,
404
+ card,
405
+ submission: { status: 'not_submitted', reason: 'codename_required', detail: 'Pass a codename to publish to the board. Showing local preview only.' },
406
+ }
407
+ }
408
+
409
+ // Submit the RAW paste so the server re-parses + re-scores authoritatively — the
410
+ // MCP's local cascade is only a preview; the board stays the single source of truth.
411
+ const stamp = uploadStamp({ codename, pillars: c.pillars, source: 'web_paste' })
412
+ const res = await doFetch(`${apiBase}/api/v1/ingest-paste`, {
413
+ method: 'POST',
414
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
415
+ body: JSON.stringify({ codename, raw_paste: String(args?.text || ''), ...stamp }),
416
+ })
417
+ let ack
418
+ try { ack = await res.json() } catch { ack = { status: 'error', detail: `HTTP ${res.status} (non-JSON response)` } }
419
+ return { ...c, card, submission: { ...stamp, httpStatus: res.status, ...ack } }
420
+ }
421
+
422
+ if (name === 'tokenpull') {
423
+ // Local read → 4 windows of pillars → cascade each. Token-only, on-device.
424
+ const platform = args?.platform || 'claude'
425
+ const pulled = await pullByPlatform(platform, opts)
426
+ const windows = pulled.windows.map((w) => {
427
+ const c = cascade(w.pillars)
428
+ return { window: w.window, messages: w.messages, pillars: w.pillars, cascade: c, card: narrate(c, `${w.window} ${platform}`) }
429
+ })
430
+ return { platform: pulled.platform, estimated: pulled.estimated || false, ...(pulled.ioRatio ? { ioRatio: pulled.ioRatio } : {}), generatedAt: pulled.generatedAt, files: pulled.files, totalMessages: pulled.totalMessages, windows }
431
+ }
432
+
433
+ if (name === 'tokenpull_submit') {
434
+ // Pull local usage, then publish each window's CANONICAL pillars to the board
435
+ // (server re-scores). The board stays platform-agnostic via the 4 pillars; the
436
+ // source platform rides along as a tag. Conversion already happened in the adapter.
437
+ const codename = String(args?.codename || '').trim()
438
+ const pulled = await pullByPlatform(args?.platform || 'claude', opts)
439
+ const targets = args?.window ? pulled.windows.filter((w) => w.window === args.window) : pulled.windows
440
+ const out = []
441
+ for (const w of targets) {
442
+ const c = cascade(w.pillars)
443
+ const card = narrate(c, `${w.window} window`)
444
+ if (!codename) {
445
+ out.push({ window: w.window, pillars: w.pillars, cascade: c, card, submission: { status: 'not_submitted', reason: 'codename_required' } })
446
+ continue
447
+ }
448
+ // canonical pillars → "input output cacheCreate cacheRead" (the parser's 4-bare-number form)
449
+ const rawPaste = `${w.pillars.input} ${w.pillars.output} ${w.pillars.cacheCreate} ${w.pillars.cacheRead}`
450
+ const windowType = WINDOW_TYPE[w.window] || w.window
451
+ const stamp = uploadStamp({ codename, window: windowType, pillars: w.pillars, platform: pulled.platform })
452
+ const res = await doFetch(`${apiBase}/api/v1/ingest-paste`, {
453
+ method: 'POST',
454
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
455
+ body: JSON.stringify({ codename, raw_paste: rawPaste, window_type: windowType, telemetry: { platform: { primary: pulled.platform } }, ...stamp }),
456
+ })
457
+ let ack
458
+ try { ack = await res.json() } catch { ack = { status: 'error', detail: `HTTP ${res.status} (non-JSON)` } }
459
+ out.push({ window: w.window, pillars: w.pillars, cascade: c, card, submission: { ...stamp, httpStatus: res.status, ...ack } })
460
+ }
461
+ return { platform: pulled.platform, codename: codename || null, generatedAt: pulled.generatedAt, windows: out }
462
+ }
463
+
464
+ if (name === 'rank_windows') {
465
+ // Score up to 4 named window pastes independently. Named-key parsing only —
466
+ // positional is unsafe here because dashboards list cache_read before cache_create
467
+ // (the WINDOWED_PROFILES swap gotcha). Each window goes through parsePillars →
468
+ // cascade → narrate individually; results are collected into a windows[] array
469
+ // in the same shape as tokenpull output for easy follow-up with tokenpull_submit.
470
+ const WINDOW_KEYS = ['7d', '30d', '90d', 'all']
471
+ const sourceTool = args?.source_tool || null
472
+ const windows = []
473
+ for (const wk of WINDOW_KEYS) {
474
+ const text = args?.[wk]
475
+ if (!text || typeof text !== 'string' || !text.trim()) continue
476
+ const pillars = parsePillars(text)
477
+ const c = withParseWarnings(pillars, cascade(pillars))
478
+ const card = narrate(c, `${wk} window`)
479
+ windows.push({ window: wk, pillars, cascade: c, card })
480
+ }
481
+ if (windows.length === 0) {
482
+ throw new Error('rank_windows requires at least one window paste (7d, 30d, 90d, or all).')
483
+ }
484
+ return {
485
+ windows,
486
+ source_tool: sourceTool,
487
+ note: 'Local preview only — use tokenpull_submit to publish to the board.',
488
+ }
489
+ }
490
+
491
+ if (name === 'watch_tokenpull') {
492
+ // Poll the local token logs at a configurable interval and return the cascade
493
+ // diff whenever new sessions appear. One poll cycle per MCP call — the client
494
+ // is responsible for re-calling at the desired cadence (MCP tools are stateless;
495
+ // a persistent background watcher lives outside the tool boundary).
496
+ //
497
+ // With submit:true + an enrolled device, this also signs + POSTs the watched window
498
+ // to the verified ingest path each poll (the server dedups identical re-submits).
499
+ const platform = args?.platform || 'claude'
500
+ const watchWindow = args?.window || '7d'
501
+ const intervalS = Math.max(10, Number(args?.interval_s) || 60)
502
+
503
+ const pulled = await pullByPlatform(platform, opts)
504
+ const win = pulled.windows.find((w) => w.window === watchWindow)
505
+ if (!win) throw new Error(`watch_tokenpull: window '${watchWindow}' not found in pull result.`)
506
+
507
+ const c = cascade(win.pillars)
508
+ const card = narrate(c, `${watchWindow} ${platform}`)
509
+
510
+ // AUTH.WIRE (D7 §7): when submit is on AND the device is enrolled, sign + POST the
511
+ // watched window to the verified ingest path. Default OFF = preview only. The server
512
+ // dedups identical re-submits (exact snapshot_hash → 422), so re-calling is safe.
513
+ let auth_submit = null
514
+ if (args?.submit === true) {
515
+ const id = opts.identity || ensureIdentity()
516
+ if (id.codename && id.operator_id && id.private_key_pkcs8_b64) {
517
+ auth_submit = await submitSignedWindow(watchWindow, win.pillars, win.messages, id, {
518
+ apiBase,
519
+ fetchImpl: doFetch,
520
+ platform: pulled.platform,
521
+ now: opts.now,
522
+ })
523
+ } else {
524
+ auth_submit = { status: 'not_enrolled', detail: 'Run `npx sigrank-mcp enroll` to auto-submit verified runs.' }
525
+ }
526
+ }
527
+ return {
528
+ platform: pulled.platform,
529
+ window: watchWindow,
530
+ pillars: win.pillars,
531
+ messages: win.messages,
532
+ cascade: c,
533
+ card,
534
+ generatedAt: pulled.generatedAt,
535
+ poll_interval_s: intervalS,
536
+ auth_submit,
537
+ note: 'One snapshot per call — re-call at your poll interval to detect changes.',
538
+ }
539
+ }
540
+
541
+ if (name === 'tokenpull_compare') {
542
+ const platform = args?.platform || 'claude'
543
+ const WINS = ['7d', '30d', '90d', 'all']
544
+
545
+ // Pull all four sources in parallel (verifiers are sync, wrap in Promise.resolve)
546
+ const [tpResult, ccPillars, tdPillars, tsPillars] = await Promise.all([
547
+ pullByPlatform(platform, opts).catch(() => null),
548
+ Promise.resolve(_ccusagePillars(platform)),
549
+ Promise.resolve(platform === 'claude' ? _tokenDashPillars() : null),
550
+ Promise.resolve(_tokscalePillars(platform)),
551
+ ])
552
+
553
+ // Build tokenpull window lookup
554
+ const tpByWin = {}
555
+ for (const w of (tpResult?.windows ?? [])) tpByWin[w.window] = w.pillars
556
+
557
+ // Helper: delta % vs tokenpull baseline
558
+ const delta = (val, base) => base > 0 ? +((( val - base) / base * 100).toFixed(1)) : null
559
+
560
+ // Build per-source per-window comparison
561
+ const SOURCES = [
562
+ { source: 'tokenpull', note: 'JSONL deduped by msg id — canon source', byWin: tpByWin },
563
+ { source: 'ccusage', note: 'ccusage CLI — monthly only', byWin: ccPillars ?? {} },
564
+ { source: 'token-dash', note: 'token-dashboard SQLite — all-time only', byWin: tdPillars ?? {} },
565
+ { source: 'tokscale', note: 'tokscale_report.json — all-time only', byWin: tsPillars ?? {} },
566
+ ]
567
+
568
+ const comparison = {}
569
+ for (const win of WINS) {
570
+ const baseP = tpByWin[win]
571
+ comparison[win] = SOURCES
572
+ .filter(s => s.byWin[win] != null)
573
+ .map(s => {
574
+ const p = s.byWin[win]
575
+ const cas = cascade(p)
576
+ const entry = {
577
+ source: s.source,
578
+ note: s.note,
579
+ pillars: p,
580
+ cascade: { yield: cas.yield, snr: cas.snr, leverage: cas.leverage, velocity: cas.velocity, dev10x: cas.dev10x, class: cas.class },
581
+ }
582
+ if (s.source !== 'tokenpull' && baseP) {
583
+ entry.delta_vs_tokenpull = {
584
+ input: delta(p.input, baseP.input),
585
+ output: delta(p.output, baseP.output),
586
+ cacheCreate: delta(p.cacheCreate, baseP.cacheCreate),
587
+ cacheRead: delta(p.cacheRead, baseP.cacheRead),
588
+ }
589
+ }
590
+ return entry
591
+ })
592
+ }
593
+
594
+ // Sources available summary
595
+ const available = SOURCES
596
+ .filter(s => Object.keys(s.byWin).length > 0)
597
+ .map(s => s.source)
598
+
599
+ return {
600
+ platform,
601
+ estimated: tpResult?.estimated ?? false,
602
+ generatedAt: tpResult?.generatedAt ?? new Date().toISOString(),
603
+ sources_available: available,
604
+ sources_missing: SOURCES.map(s => s.source).filter(s => !available.includes(s)),
605
+ comparison,
606
+ }
607
+ }
608
+
609
+ throw new Error(`Unknown tool: ${name}`)
610
+ }