sigrank 0.19.0 → 1.0.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.
Files changed (82) hide show
  1. package/CHANGELOG.md +158 -0
  2. package/MCP_SERVER_DESCRIPTION.md +172 -0
  3. package/README.md +89 -41
  4. package/adapters/index.mjs +1100 -0
  5. package/adapters/tokenpull.mjs +568 -0
  6. package/adapters.mjs +1 -865
  7. package/analytics/cascade.mjs +338 -0
  8. package/bin/sigrank.mjs +137 -0
  9. package/cascade.mjs +1 -274
  10. package/cli.mjs +1 -1853
  11. package/connect.mjs +1 -13
  12. package/identity/connect.mjs +13 -0
  13. package/identity/keystore.mjs +231 -0
  14. package/identity/sign.mjs +106 -0
  15. package/index.mjs +47 -162
  16. package/keystore.mjs +1 -242
  17. package/lib/constants.mjs +137 -0
  18. package/lib/pkg-version.mjs +59 -0
  19. package/manifest.json +46 -0
  20. package/narrate.mjs +1 -77
  21. package/package.json +44 -18
  22. package/preflight.mjs +1 -191
  23. package/presentation/cli.mjs +1926 -0
  24. package/presentation/narrate.mjs +219 -0
  25. package/presentation/tui-themes.mjs +189 -0
  26. package/presentation/tui.mjs +3316 -0
  27. package/prompts/check-my-efficiency.md +1 -0
  28. package/prompts/compare-with-leader.md +1 -0
  29. package/prompts/simulate-improvement.md +1 -0
  30. package/proxy.mjs +492 -0
  31. package/resources/class-tiers.md +81 -0
  32. package/resources/data-policy.md +31 -0
  33. package/resources/install-guide.md +59 -0
  34. package/resources/privacy-model.md +44 -0
  35. package/resources/scoring-formula.md +24 -0
  36. package/review.mjs +263 -0
  37. package/sign.mjs +1 -106
  38. package/submit/index.mjs +297 -0
  39. package/submit/preflight.mjs +191 -0
  40. package/submit.mjs +1 -303
  41. package/tokenpull.mjs +1 -576
  42. package/tokscale_analytics.mjs +864 -0
  43. package/tools/_framing.mjs +217 -0
  44. package/tools/_helpers.mjs +253 -0
  45. package/tools/_peer-matching.mjs +361 -0
  46. package/tools/_report.mjs +262 -0
  47. package/tools/_schemas.mjs +492 -0
  48. package/tools/_verifiers.mjs +148 -0
  49. package/tools/compare-operators.mjs +80 -0
  50. package/tools/compare-self.mjs +124 -0
  51. package/tools/describe-power-user.mjs +78 -0
  52. package/tools/diagnose-cascade.mjs +192 -0
  53. package/tools/discover-peers.mjs +198 -0
  54. package/tools/enroll.mjs +119 -0
  55. package/tools/get-best-operator.mjs +59 -0
  56. package/tools/get-leaderboard.mjs +27 -0
  57. package/tools/get-operator.mjs +33 -0
  58. package/tools/index.mjs +233 -0
  59. package/tools/optimize-efficiency.mjs +170 -0
  60. package/tools/rank-paste.mjs +51 -0
  61. package/tools/rank-windows.mjs +120 -0
  62. package/tools/self-improve.mjs +423 -0
  63. package/tools/simulate-change.mjs +207 -0
  64. package/tools/standard-record.mjs +108 -0
  65. package/tools/submit-paste.mjs +121 -0
  66. package/tools/submit-verified.mjs +153 -0
  67. package/tools/suggest-improvements.mjs +168 -0
  68. package/tools/tokenpull-compare.mjs +123 -0
  69. package/tools/tokenpull-submit.mjs +235 -0
  70. package/tools/tokenpull.mjs +113 -0
  71. package/tools/tokscale-breakdown.mjs +119 -0
  72. package/tools/tokscale-competitive-intel.mjs +45 -0
  73. package/tools/tokscale-cost-analysis.mjs +31 -0
  74. package/tools/tokscale-developer-profile.mjs +36 -0
  75. package/tools/tokscale-device-profile.mjs +35 -0
  76. package/tools/tokscale-market-share.mjs +38 -0
  77. package/tools/tokscale-mcp-usage.mjs +33 -0
  78. package/tools/tokscale-model-trends.mjs +32 -0
  79. package/tools/watch-tokenpull.mjs +134 -0
  80. package/tools.mjs +1 -3897
  81. package/tui.mjs +4 -2650
  82. package/banner.svg +0 -7
@@ -0,0 +1,168 @@
1
+ /**
2
+ * tools/suggest-improvements.mjs — suggest_improvements tool.
3
+ */
4
+
5
+ import { cascade, parsePillars } from "../analytics/cascade.mjs";
6
+ import { SUGGEST_OUTPUT, ANNOTATIONS } from "./_schemas.mjs";
7
+ import { MAX_INPUT, withParseWarnings } from "./_helpers.mjs";
8
+
9
+ export const TOOL_DEF = {
10
+ name: "suggest_improvements",
11
+ description:
12
+ "Generates ranked, simulated improvement suggestions for your token cascade. Takes your 4 pillars, tests multiple improvement strategies (increase cache reads, reduce fresh input, increase output, optimize cache creation), simulates each with the canonical cascade engine, and returns them ranked by Υ yield impact. Each suggestion includes: the action, which pillar to change, how much to change it, the projected Υ after the change, the yield delta, the projected class tier, and a rationale. Also returns the single highest-impact change (best_single_change). Pure local math — no network, no submission. Use this after diagnose_cascade to get actionable next steps, then use simulate_change to fine-tune before committing. Accepts the same input formats as rank_paste.",
13
+ annotations: {
14
+ title: "Suggest improvements",
15
+ ...ANNOTATIONS.readOnlyHint,
16
+ ...ANNOTATIONS.idempotentHint,
17
+ ...ANNOTATIONS.openWorldHint,
18
+ },
19
+ inputSchema: {
20
+ type: "object",
21
+ properties: {
22
+ text: {
23
+ type: "string",
24
+ description:
25
+ 'Token pillars — ccusage JSON or "input output cacheCreate cacheRead" (same format as rank_paste).',
26
+ },
27
+ },
28
+ required: ["text"],
29
+ },
30
+ outputSchema: SUGGEST_OUTPUT,
31
+ };
32
+
33
+ export async function handleSuggestImprovements(args) {
34
+ if (!args?.text)
35
+ throw new Error(
36
+ "suggest_improvements requires a non-empty `text` argument (token pillars).",
37
+ );
38
+ if (typeof args.text === "string" && args.text.length > MAX_INPUT) {
39
+ return {
40
+ status: "error",
41
+ reason: "input_too_large",
42
+ detail: `text exceeds ${MAX_INPUT} chars.`,
43
+ };
44
+ }
45
+
46
+ const p = parsePillars(args.text);
47
+ const current = withParseWarnings(p, cascade(p));
48
+ const { input: i, output: o, cacheCreate: cw, cacheRead: cr } = p;
49
+
50
+ // Generate candidate improvements, simulate each, rank by Υ impact
51
+ const candidates = [];
52
+
53
+ // Strategy 1: Increase cache reads (the #1 lever)
54
+ const crBoosts = cr > 0 ? [1.5, 2, 3, 5] : [];
55
+ for (const mult of crBoosts) {
56
+ const sim = cascade({ ...p, cacheRead: Math.round(cr * mult) });
57
+ if (sim.yield !== null) {
58
+ candidates.push({
59
+ action: `Increase cache reads by ${Math.round((mult - 1) * 100)}%`,
60
+ pillar: "cacheRead",
61
+ delta: `+${Math.round(cr * (mult - 1)).toLocaleString()}`,
62
+ simulated_yield: sim.yield,
63
+ yield_delta: Number((sim.yield - (current.yield ?? 0)).toFixed(2)),
64
+ class_after: sim.class,
65
+ rationale:
66
+ "Cache reads are the strongest Υ multiplier. More reuse = higher leverage = higher yield.",
67
+ });
68
+ }
69
+ }
70
+ // For zero-cache operators, suggest enabling caching with a starter amount
71
+ if (cr === 0 && cw === 0) {
72
+ const starterAmounts = [
73
+ Math.round(i * 10),
74
+ Math.round(i * 50),
75
+ Math.round(i * 100),
76
+ ];
77
+ for (const amt of starterAmounts) {
78
+ const sim = cascade({
79
+ ...p,
80
+ cacheCreate: Math.round(amt * 0.5),
81
+ cacheRead: amt,
82
+ });
83
+ if (sim.yield !== null && sim.yield > 0) {
84
+ candidates.push({
85
+ action: `Enable caching with ${amt.toLocaleString()} cache reads`,
86
+ pillar: "cacheRead",
87
+ delta: `+${amt.toLocaleString()}`,
88
+ simulated_yield: sim.yield,
89
+ yield_delta: Number((sim.yield - (current.yield ?? 0)).toFixed(2)),
90
+ class_after: sim.class,
91
+ rationale:
92
+ "You have no cache — enabling it unlocks the cascade. Start by reusing prior context.",
93
+ });
94
+ }
95
+ }
96
+ }
97
+
98
+ // Strategy 2: Reduce fresh input (Υ = Cr·O/I² — input is squared in the denominator)
99
+ const inputReductions = [0.9, 0.75, 0.5];
100
+ for (const mult of inputReductions) {
101
+ const newInput = Math.round(i * mult);
102
+ if (newInput < 1) continue;
103
+ const sim = cascade({ ...p, input: newInput });
104
+ if (sim.yield !== null) {
105
+ candidates.push({
106
+ action: `Reduce fresh input by ${Math.round((1 - mult) * 100)}%`,
107
+ pillar: "input",
108
+ delta: `-${Math.round(i * (1 - mult)).toLocaleString()}`,
109
+ simulated_yield: sim.yield,
110
+ yield_delta: Number((sim.yield - (current.yield ?? 0)).toFixed(2)),
111
+ class_after: sim.class,
112
+ rationale:
113
+ "Input is squared in the Υ denominator (Υ = Cr·O/I²). Reducing input has a quadratic payoff.",
114
+ });
115
+ }
116
+ }
117
+
118
+ // Strategy 3: Increase output
119
+ const outputBoosts = [1.25, 1.5, 2];
120
+ for (const mult of outputBoosts) {
121
+ const sim = cascade({ ...p, output: Math.round(o * mult) });
122
+ if (sim.yield !== null) {
123
+ candidates.push({
124
+ action: `Increase output by ${Math.round((mult - 1) * 100)}%`,
125
+ pillar: "output",
126
+ delta: `+${Math.round(o * (mult - 1)).toLocaleString()}`,
127
+ simulated_yield: sim.yield,
128
+ yield_delta: Number((sim.yield - (current.yield ?? 0)).toFixed(2)),
129
+ class_after: sim.class,
130
+ rationale:
131
+ "Output is a linear multiplier in Υ. More output per session = higher yield.",
132
+ });
133
+ }
134
+ }
135
+
136
+ // Strategy 4: Optimize cache creation (reduce if over-committing)
137
+ if (cw > o * 10) {
138
+ const sim = cascade({ ...p, cacheCreate: Math.round(o * 5) });
139
+ if (sim.yield !== null) {
140
+ candidates.push({
141
+ action: "Reduce cache creation to 5× output",
142
+ pillar: "cacheCreate",
143
+ delta: `-${Math.round(cw - o * 5).toLocaleString()}`,
144
+ simulated_yield: sim.yield,
145
+ yield_delta: Number((sim.yield - (current.yield ?? 0)).toFixed(2)),
146
+ class_after: sim.class,
147
+ rationale:
148
+ "You're over-committing cache (cw >> output). Trimming to a healthy ratio reduces cost without hurting yield.",
149
+ });
150
+ }
151
+ }
152
+
153
+ // Sort by yield_delta descending, take top 8
154
+ candidates.sort((a, b) => b.yield_delta - a.yield_delta);
155
+ const top = candidates
156
+ .slice(0, 8)
157
+ .map((c, idx) => ({ rank: idx + 1, ...c }));
158
+
159
+ const best = top[0];
160
+ return {
161
+ suggestions: top,
162
+ current_yield: current.yield,
163
+ current_class: current.class,
164
+ best_single_change: best
165
+ ? `${best.action} (Υ ${current.yield} → ${best.simulated_yield}, +${best.yield_delta} yield, class ${best.class_after})`
166
+ : "No improvements found — your cascade is already optimized.",
167
+ };
168
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * tools/tokenpull-compare.mjs — tokenpull_compare tool.
3
+ */
4
+
5
+ import { cascade } from "../analytics/cascade.mjs";
6
+ import { ALL_PLATFORMS } from "../adapters/index.mjs";
7
+ import { COMPARE_OUTPUT, ANNOTATIONS } from "./_schemas.mjs";
8
+ import { pullByPlatform } from "./_helpers.mjs";
9
+ import { _ccusagePillars, _tokenDashPillars, _tokscalePillars } from "./_verifiers.mjs";
10
+
11
+ export const TOOL_DEF = {
12
+ name: "tokenpull_compare",
13
+ description:
14
+ "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.",
15
+ annotations: { title: "Compare token sources", ...ANNOTATIONS.readOnlyHint, ...ANNOTATIONS.openWorldHint },
16
+ inputSchema: {
17
+ type: "object",
18
+ properties: {
19
+ platform: {
20
+ type: "string",
21
+ enum: ALL_PLATFORMS,
22
+ description:
23
+ "platform to compare (default: claude). token-dash and App only available for claude.",
24
+ },
25
+ },
26
+ },
27
+ outputSchema: COMPARE_OUTPUT,
28
+ };
29
+
30
+ export async function handleTokenpullCompare(args, ctx) {
31
+ const platform = args?.platform || "claude";
32
+ const WINS = ["7d", "30d", "90d", "all"];
33
+
34
+ // Pull all four sources in parallel (verifiers are now async via execFile)
35
+ const [tpResult, ccPillars, tdPillars, tsPillars] = await Promise.all([
36
+ pullByPlatform(platform, ctx.opts).catch(() => null),
37
+ _ccusagePillars(platform).catch(() => null),
38
+ (platform === "claude"
39
+ ? _tokenDashPillars()
40
+ : Promise.resolve(null)
41
+ ).catch(() => null),
42
+ _tokscalePillars(platform).catch(() => null),
43
+ ]);
44
+
45
+ // Build tokenpull window lookup
46
+ const tpByWin = {};
47
+ for (const w of tpResult?.windows ?? []) tpByWin[w.window] = w.pillars;
48
+
49
+ // Helper: delta % vs tokenpull baseline
50
+ const delta = (val, base) =>
51
+ base > 0 ? +(((val - base) / base) * 100).toFixed(1) : null;
52
+
53
+ // Build per-source per-window comparison
54
+ const SOURCES = [
55
+ {
56
+ source: "tokenpull",
57
+ note: "JSONL deduped by msg id — canon source",
58
+ byWin: tpByWin,
59
+ },
60
+ {
61
+ source: "ccusage",
62
+ note: "ccusage CLI — monthly only",
63
+ byWin: ccPillars ?? {},
64
+ },
65
+ {
66
+ source: "token-dash",
67
+ note: "token-dashboard SQLite — all-time only",
68
+ byWin: tdPillars ?? {},
69
+ },
70
+ {
71
+ source: "tokscale",
72
+ note: "tokscale_report.json — all-time only",
73
+ byWin: tsPillars ?? {},
74
+ },
75
+ ];
76
+
77
+ const comparison = {};
78
+ for (const win of WINS) {
79
+ const baseP = tpByWin[win];
80
+ comparison[win] = SOURCES.filter((s) => s.byWin[win] != null).map((s) => {
81
+ const p = s.byWin[win];
82
+ const cas = cascade(p);
83
+ const entry = {
84
+ source: s.source,
85
+ note: s.note,
86
+ pillars: p,
87
+ cascade: {
88
+ yield: cas.yield,
89
+ snr: cas.snr,
90
+ leverage: cas.leverage,
91
+ velocity: cas.velocity,
92
+ dev10x: cas.dev10x,
93
+ class: cas.class,
94
+ },
95
+ };
96
+ if (s.source !== "tokenpull" && baseP) {
97
+ entry.delta_vs_tokenpull = {
98
+ input: delta(p.input, baseP.input),
99
+ output: delta(p.output, baseP.output),
100
+ cacheCreate: delta(p.cacheCreate, baseP.cacheCreate),
101
+ cacheRead: delta(p.cacheRead, baseP.cacheRead),
102
+ };
103
+ }
104
+ return entry;
105
+ });
106
+ }
107
+
108
+ // Sources available summary
109
+ const available = SOURCES.filter(
110
+ (s) => Object.keys(s.byWin).length > 0,
111
+ ).map((s) => s.source);
112
+
113
+ return {
114
+ platform,
115
+ estimated: tpResult?.estimated ?? false,
116
+ generatedAt: tpResult?.generatedAt ?? new Date().toISOString(),
117
+ sources_available: available,
118
+ sources_missing: SOURCES.map((s) => s.source).filter(
119
+ (s) => !available.includes(s),
120
+ ),
121
+ comparison,
122
+ };
123
+ }
@@ -0,0 +1,235 @@
1
+ /**
2
+ * tools/tokenpull-submit.mjs — tokenpull_submit tool.
3
+ */
4
+
5
+ import { cascade } from "../analytics/cascade.mjs";
6
+ import { narrate } from "../presentation/narrate.mjs";
7
+ import { ALL_PLATFORMS } from "../adapters/index.mjs";
8
+ import { SUBMIT_OUTPUT, ANNOTATIONS } from "./_schemas.mjs";
9
+ import {
10
+ MAX_INPUT,
11
+ pullByPlatform,
12
+ uploadStamp,
13
+ WINDOW_TYPE,
14
+ } from "./_helpers.mjs";
15
+ import { TERMS_VERSION, PRIVACY_VERSION, isRankedAck } from "../lib/constants.mjs";
16
+
17
+ export const TOOL_DEF = {
18
+ name: "tokenpull_submit",
19
+ description:
20
+ "Pull your LOCAL token usage from session logs AND publish it to the SigRank board in one call — the zero-paste flow. Reads the four canonical pillars (input, output, cacheCreate, cacheRead) per window from your local logs, computes the cascade, and submits each window to the board where it is re-scored server-side and tagged with the source platform. Requires a codename to publish; omit for a local preview only. Token-only — no prompt content is read or transmitted.",
21
+ annotations: {
22
+ title: "Pull and submit tokens",
23
+ ...ANNOTATIONS.destructiveHint,
24
+ ...ANNOTATIONS.idempotentHint,
25
+ ...ANNOTATIONS.openWorldHint,
26
+ },
27
+ inputSchema: {
28
+ type: "object",
29
+ properties: {
30
+ codename: {
31
+ type: "string",
32
+ description:
33
+ 'Operator codename to publish under on the leaderboard (e.g. "Iron Lotus"). Required to submit — omit for local preview only.',
34
+ },
35
+ window: {
36
+ type: "string",
37
+ enum: ["7d", "30d", "90d", "all"],
38
+ description:
39
+ 'Submit only this time window (default: all 4 windows). Use "7d" for recent activity or "all" for all-time ranking.',
40
+ },
41
+ platform: {
42
+ type: "string",
43
+ enum: [...ALL_PLATFORMS, "multi"],
44
+ 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.`,
45
+ },
46
+ },
47
+ },
48
+ outputSchema: SUBMIT_OUTPUT,
49
+ };
50
+
51
+ export async function handleTokenpullSubmit(args, ctx) {
52
+ // Pull local usage, then publish each window's CANONICAL pillars to the board
53
+ // (server re-scores). The board stays platform-agnostic via the 4 pillars; the
54
+ // source platform rides along as a tag. Conversion already happened in the adapter.
55
+ const codename = String(args?.codename || "").trim();
56
+ const platform = args?.platform || "claude";
57
+
58
+ // MULTI: same combined cross-platform cascade as submit_verified. Includes Devin
59
+ // (cloud, via tokscale). Aggregate every locally-detected platform's pillars per
60
+ // window and publish as platform='multi'.
61
+ if (platform === "multi") {
62
+ const detected = [];
63
+ for (const p of ALL_PLATFORMS) {
64
+ const r = await pullByPlatform(p, ctx.opts).catch(() => null);
65
+ const live =
66
+ r &&
67
+ (r.windows || []).some(
68
+ (w) =>
69
+ w.pillars.input +
70
+ w.pillars.output +
71
+ w.pillars.cacheCreate +
72
+ w.pillars.cacheRead >
73
+ 0,
74
+ );
75
+ if (live) detected.push(r);
76
+ }
77
+ if (detected.length < 2) {
78
+ return {
79
+ platform: "multi",
80
+ codename: codename || undefined,
81
+ status: "skipped",
82
+ reason: "need_2_platforms",
83
+ detail: `multi needs 2+ active platforms; found ${detected.length} (${detected.map((d) => d.platform).join(", ") || "none"}).`,
84
+ windows: [],
85
+ };
86
+ }
87
+ const winKeys = args?.window ? [args.window] : ["7d", "30d", "90d", "all"];
88
+ const out = [];
89
+ for (const wk of winKeys) {
90
+ const sum = { input: 0, output: 0, cacheCreate: 0, cacheRead: 0 };
91
+ let msgs = 0;
92
+ for (const d of detected) {
93
+ const w = (d.windows || []).find((x) => x.window === wk);
94
+ if (!w) continue;
95
+ sum.input += w.pillars.input || 0;
96
+ sum.output += w.pillars.output || 0;
97
+ sum.cacheCreate += w.pillars.cacheCreate || 0;
98
+ sum.cacheRead += w.pillars.cacheRead || 0;
99
+ msgs += w.messages || 0;
100
+ }
101
+ if (sum.input + sum.output + sum.cacheCreate + sum.cacheRead <= 0)
102
+ continue;
103
+ const c = cascade(sum);
104
+ const card = narrate(c, `${wk} multi`);
105
+ if (!codename) {
106
+ out.push({
107
+ window: wk,
108
+ pillars: sum,
109
+ cascade: c,
110
+ card,
111
+ submission: { status: "not_submitted", reason: "codename_required" },
112
+ });
113
+ continue;
114
+ }
115
+ const rawPaste = `${sum.input} ${sum.output} ${sum.cacheCreate} ${sum.cacheRead}`;
116
+ const windowType = WINDOW_TYPE[wk] || wk;
117
+ const stamp = uploadStamp({
118
+ codename,
119
+ window: windowType,
120
+ pillars: sum,
121
+ platform: "multi",
122
+ });
123
+ const res = await ctx.doFetch(`${ctx.apiBase}/api/v1/ingest-paste`, {
124
+ method: "POST",
125
+ headers: {
126
+ "content-type": "application/json",
127
+ accept: "application/json",
128
+ },
129
+ body: JSON.stringify({
130
+ codename,
131
+ raw_paste: rawPaste,
132
+ window_type: windowType,
133
+ telemetry: { platform: { primary: "multi" } },
134
+ consent_acknowledged: true,
135
+ terms_version: TERMS_VERSION,
136
+ privacy_version: PRIVACY_VERSION,
137
+ ...stamp,
138
+ }),
139
+ });
140
+ let ack;
141
+ try {
142
+ ack = await res.json();
143
+ } catch {
144
+ ack = { status: "parse_error", httpStatus: res.status };
145
+ }
146
+ // Fix 4: was `ack?.ranked ?? ack?.accepted ?? false` — a looser predicate
147
+ // than the single-platform branch below, which would mark a non-persisted
148
+ // 202 as ranked. Now both branches use the shared isRankedAck() so every
149
+ // submit path agrees on what "ranked" means.
150
+ const ranked = isRankedAck(res, ack);
151
+ out.push({
152
+ window: wk,
153
+ pillars: sum,
154
+ cascade: c,
155
+ card,
156
+ submission: { ...stamp, httpStatus: res.status, ranked, ...ack },
157
+ });
158
+ }
159
+ return {
160
+ platform: "multi",
161
+ codename: codename || undefined,
162
+ sources: detected.map((d) => d.platform),
163
+ windows: out,
164
+ };
165
+ }
166
+
167
+ const pulled = await pullByPlatform(platform, ctx.opts);
168
+ const targets = args?.window
169
+ ? pulled.windows.filter((w) => w.window === args.window)
170
+ : pulled.windows;
171
+ const out = [];
172
+ for (const w of targets) {
173
+ const c = cascade(w.pillars);
174
+ const card = narrate(c, `${w.window} window`);
175
+ if (!codename) {
176
+ out.push({
177
+ window: w.window,
178
+ pillars: w.pillars,
179
+ cascade: c,
180
+ card,
181
+ submission: { status: "not_submitted", reason: "codename_required" },
182
+ });
183
+ continue;
184
+ }
185
+ // canonical pillars → "input output cacheCreate cacheRead" (the parser's 4-bare-number form)
186
+ const rawPaste = `${w.pillars.input} ${w.pillars.output} ${w.pillars.cacheCreate} ${w.pillars.cacheRead}`;
187
+ const windowType = WINDOW_TYPE[w.window] || w.window;
188
+ const stamp = uploadStamp({
189
+ codename,
190
+ window: windowType,
191
+ pillars: w.pillars,
192
+ platform: pulled.platform,
193
+ });
194
+ const res = await ctx.doFetch(`${ctx.apiBase}/api/v1/ingest-paste`, {
195
+ method: "POST",
196
+ headers: {
197
+ "content-type": "application/json",
198
+ accept: "application/json",
199
+ },
200
+ body: JSON.stringify({
201
+ codename,
202
+ raw_paste: rawPaste,
203
+ window_type: windowType,
204
+ telemetry: { platform: { primary: pulled.platform } },
205
+ consent_acknowledged: true,
206
+ terms_version: TERMS_VERSION,
207
+ privacy_version: PRIVACY_VERSION,
208
+ ...stamp,
209
+ }),
210
+ });
211
+ let ack;
212
+ try {
213
+ ack = await res.json();
214
+ } catch {
215
+ ack = { status: "error", detail: `HTTP ${res.status} (non-JSON)` };
216
+ }
217
+ // ranked = actually on the board (verified + persisted), not just received — parity
218
+ // with submit_verified (submit/index.mjs). An unenrolled/revoked device gets 202 but is NEVER ranked.
219
+ const ranked = isRankedAck(res, ack);
220
+ out.push({
221
+ window: w.window,
222
+ pillars: w.pillars,
223
+ cascade: c,
224
+ card,
225
+ ranked,
226
+ submission: { ...stamp, httpStatus: res.status, ranked, ...ack },
227
+ });
228
+ }
229
+ return {
230
+ platform: pulled.platform,
231
+ codename: codename || null,
232
+ generatedAt: pulled.generatedAt,
233
+ windows: out,
234
+ };
235
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * tools/tokenpull.mjs — tokenpull tool.
3
+ */
4
+
5
+ import { cascade } from "../analytics/cascade.mjs";
6
+ import { narrate } from "../presentation/narrate.mjs";
7
+ import { ALL_PLATFORMS } from "../adapters/index.mjs";
8
+ import { TOKENPULL_OUTPUT, ANNOTATIONS } from "./_schemas.mjs";
9
+ import { pullByPlatform } from "./_helpers.mjs";
10
+
11
+ export const TOOL_DEF = {
12
+ name: "tokenpull",
13
+ description:
14
+ "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.",
15
+ annotations: { title: "Pull local token usage", ...ANNOTATIONS.readOnlyHint, ...ANNOTATIONS.openWorldHint },
16
+ inputSchema: {
17
+ type: "object",
18
+ properties: {
19
+ platform: {
20
+ type: "string",
21
+ enum: [...ALL_PLATFORMS, "multi"],
22
+ 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).`,
23
+ },
24
+ },
25
+ },
26
+ outputSchema: TOKENPULL_OUTPUT,
27
+ };
28
+
29
+ export async function handleTokenpull(args, ctx) {
30
+ // Local read → 4 windows of pillars → cascade each. Token-only, on-device.
31
+ const platform = args?.platform || "claude";
32
+
33
+ // MULTI: sum every locally-detected platform's pillars per window. Same logic as
34
+ // submit_verified's multi flow — Devin (cloud, via tokscale) is included.
35
+ if (platform === "multi") {
36
+ const detected = [];
37
+ for (const p of ALL_PLATFORMS) {
38
+ const r = await pullByPlatform(p, ctx.opts).catch(() => null);
39
+ const live =
40
+ r &&
41
+ (r.windows || []).some(
42
+ (w) =>
43
+ w.pillars.input +
44
+ w.pillars.output +
45
+ w.pillars.cacheCreate +
46
+ w.pillars.cacheRead >
47
+ 0,
48
+ );
49
+ if (live) detected.push(r);
50
+ }
51
+ if (detected.length < 2) {
52
+ return {
53
+ platform: "multi",
54
+ status: "skipped",
55
+ reason: "need_2_platforms",
56
+ detail: `multi needs 2+ active platforms; found ${detected.length} (${detected.map((d) => d.platform).join(", ") || "none"}).`,
57
+ windows: [],
58
+ };
59
+ }
60
+ const winKeys = ["7d", "30d", "90d", "all"];
61
+ const windows = [];
62
+ for (const wk of winKeys) {
63
+ const sum = { input: 0, output: 0, cacheCreate: 0, cacheRead: 0 };
64
+ let msgs = 0;
65
+ for (const d of detected) {
66
+ const w = (d.windows || []).find((x) => x.window === wk);
67
+ if (!w) continue;
68
+ sum.input += w.pillars.input || 0;
69
+ sum.output += w.pillars.output || 0;
70
+ sum.cacheCreate += w.pillars.cacheCreate || 0;
71
+ sum.cacheRead += w.pillars.cacheRead || 0;
72
+ msgs += w.messages || 0;
73
+ }
74
+ if (sum.input + sum.output + sum.cacheCreate + sum.cacheRead <= 0)
75
+ continue;
76
+ const c = cascade(sum);
77
+ windows.push({
78
+ window: wk,
79
+ messages: msgs,
80
+ pillars: sum,
81
+ cascade: c,
82
+ card: narrate(c, `${wk} multi`),
83
+ });
84
+ }
85
+ return {
86
+ platform: "multi",
87
+ estimated: true,
88
+ sources: detected.map((d) => d.platform),
89
+ windows,
90
+ };
91
+ }
92
+
93
+ const pulled = await pullByPlatform(platform, ctx.opts);
94
+ const windows = pulled.windows.map((w) => {
95
+ const c = cascade(w.pillars);
96
+ return {
97
+ window: w.window,
98
+ messages: w.messages,
99
+ pillars: w.pillars,
100
+ cascade: c,
101
+ card: narrate(c, `${w.window} ${platform}`),
102
+ };
103
+ });
104
+ return {
105
+ platform: pulled.platform,
106
+ estimated: pulled.estimated || false,
107
+ ...(pulled.ioRatio ? { ioRatio: pulled.ioRatio } : {}),
108
+ generatedAt: pulled.generatedAt,
109
+ files: pulled.files,
110
+ totalMessages: pulled.totalMessages,
111
+ windows,
112
+ };
113
+ }