sigrank 0.0.179 → 0.0.180

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/index.mjs CHANGED
@@ -256,15 +256,23 @@ async function startMcpServer() {
256
256
  }
257
257
 
258
258
  // Route:
259
- // any arg → the CLI (runCli shows help + exits non-zero on unknown commands,
260
- // so a typo never silently starts a hung MCP stdio server on a TTY)
261
- // no args + TTY stdout → full tabbed TUI (interactive use)
262
- // no args + piped → MCP stdio server (AI client use)
259
+ // any arg → the CLI (runCli shows help + exits non-zero on unknown commands,
260
+ // so a typo never silently starts a hung MCP stdio server on a TTY)
261
+ // no args + TTY stdin+stdout → full tabbed TUI (interactive use — needs keyboard input)
262
+ // no args + piped (either) → MCP stdio server (AI client / proxy / container use)
263
+ // SIGRANK_MCP_SERVER=1 → force MCP stdio server (manual override)
264
+ //
265
+ // The stdin check is critical for container/proxy environments (Glama, mcp-proxy,
266
+ // Docker with -t). These may allocate a pseudo-TTY for stdout, but stdin is piped
267
+ // because the proxy writes JSON-RPC messages to it. The TUI needs keyboard input
268
+ // from stdin, so if stdin is piped the TUI would hang — force MCP mode instead.
263
269
  const cliArgs = process.argv.slice(2);
264
270
  if (cliArgs.length > 0) {
265
271
  runCli(process.argv);
266
- } else if (process.stdout.isTTY) {
267
- // Interactive terminal — launch the full tabbed TUI
272
+ } else if (process.env.SIGRANK_MCP_SERVER === "1") {
273
+ startMcpServer();
274
+ } else if (process.stdout.isTTY && process.stdin.isTTY) {
275
+ // Interactive terminal with keyboard — launch the full tabbed TUI
268
276
  const { runTui } = await import("./tui.mjs");
269
277
  runTui();
270
278
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sigrank",
3
- "version": "0.0.179",
3
+ "version": "0.0.180",
4
4
  "author": "SunrisesIllNeverSee (https://github.com/SunrisesIllNeverSee)",
5
5
  "description": "SigRank MCP server — the yield cascade + live leaderboard as MCP tools any agent can call",
6
6
  "mcpName": "io.github.SunrisesIllNeverSee/sigrank-mcp",
@@ -54,6 +54,7 @@
54
54
  "cli.mjs",
55
55
  "narrate.mjs",
56
56
  "tools.mjs",
57
+ "tokscale_analytics.mjs",
57
58
  "README.md",
58
59
  "CHANGELOG.md",
59
60
  "MCP_SERVER_DESCRIPTION.md",
@@ -78,8 +79,8 @@
78
79
  ],
79
80
  "dependencies": {
80
81
  "@modelcontextprotocol/sdk": "^1.0.0",
81
- "ccusage": "^20.0.17",
82
- "tokscale": "^4.5.2"
82
+ "ccusage": "^20.0.19",
83
+ "tokscale": "^4.7.0"
83
84
  },
84
85
  "overrides": {
85
86
  "@hono/node-server": "^2.0.5"
@@ -0,0 +1,875 @@
1
+ /**
2
+ * tokscale_analytics.mjs — seven analytics built on the tokscale CLI's JSON API.
3
+ *
4
+ * tokscale is a local, single-user token-telemetry CLI (bundled as a dep). It reads
5
+ * AI coding-tool session logs from this machine and emits JSON for several
6
+ * subcommands. This module wraps those subcommands and derives the analytics the
7
+ * SigRank MCP server exposes as tools:
8
+ *
9
+ * 1. tokscaleMarketShare() — AI tool market share (per-client share of tokens/cost/messages)
10
+ * 2. tokscaleDeveloperProfile() — per-developer usage profile across all detected tools
11
+ * 3. tokscaleModelTrends() — model adoption trends over time (month-over-month)
12
+ * 4. tokscaleCostAnalysis() — cost analysis per developer per model
13
+ * 5. tokscaleDeviceProfile() — device fingerprinting (installed tools, active days/hours, sessions)
14
+ * 6. tokscaleMcpUsage() — MCP server usage patterns
15
+ * 7. tokscaleCompetitiveIntel(target) — competitive intelligence for a given AI tool/company
16
+ *
17
+ * Design notes:
18
+ * - Self-contained execFileAsync (same pattern as tools.mjs) to avoid a circular import.
19
+ * execFile — never execSync — so the event loop stays responsive and shell injection
20
+ * is structurally impossible (args are an array, no shell parsing).
21
+ * - All filesystem paths in the output are redacted: os.homedir() → "~". tokscale's
22
+ * `clients` and `report` subcommands emit absolute session paths that would leak the
23
+ * local username; we never surface raw absolute paths.
24
+ * - Every tokscale subcommand call is wrapped in try/catch returning null on failure,
25
+ * so a single failing subcommand degrades gracefully instead of nuking the whole report.
26
+ * - `<synthetic>` and `unknown` models are filtered everywhere — they are tokscale
27
+ * bookkeeping, not real model usage.
28
+ * - Token-only: tokscale reads usage counts from log metadata, never message content.
29
+ */
30
+
31
+ import { execFile } from "node:child_process";
32
+ import { existsSync, readFileSync } from "node:fs";
33
+ import os from "node:os";
34
+ import path from "node:path";
35
+ import { fileURLToPath } from "node:url";
36
+
37
+ // Resolve local node_modules/.bin for the bundled tokscale binary.
38
+ const _pkgRoot = path.dirname(fileURLToPath(import.meta.url));
39
+ const _localBin = path.join(_pkgRoot, "node_modules", ".bin");
40
+ const _envPath = `${_localBin}${process.env.PATH ? ":" + process.env.PATH : ""}`;
41
+
42
+ // execFile wrapped in a Promise — non-blocking, no shell, args as array.
43
+ // Mirrors the helper in tools.mjs / tokenpull.mjs. maxBuffer 10 MB matches the
44
+ // existing readers; the `report` payload can be large but stays well under that.
45
+ function execFileAsync(cmd, args, timeoutMs) {
46
+ return new Promise((resolve, reject) => {
47
+ execFile(
48
+ cmd,
49
+ args,
50
+ {
51
+ timeout: timeoutMs,
52
+ maxBuffer: 10 * 1024 * 1024,
53
+ env: { ...process.env, PATH: _envPath },
54
+ },
55
+ (err, stdout) => {
56
+ if (err) reject(err);
57
+ else resolve(stdout.toString());
58
+ },
59
+ );
60
+ });
61
+ }
62
+
63
+ // tokscale subcommand timeouts. `models` is fast (~5s); `graph` and `report`
64
+ // walk every session log and can take ~30s on a heavy machine. 60s ceiling.
65
+ const T_MODELS = 60_000;
66
+ const T_MONTHLY = 60_000;
67
+ const T_CLIENTS = 30_000;
68
+ const T_GRAPH = 60_000;
69
+ const T_REPORT = 60_000;
70
+
71
+ const HOME = os.homedir();
72
+
73
+ /** Redact the local home directory prefix to "~" so no absolute path leaks. */
74
+ export function redactPath(p) {
75
+ if (typeof p !== "string" || !p) return p;
76
+ if (p === HOME) return "~";
77
+ if (p.startsWith(HOME + "/")) return "~" + p.slice(HOME.length);
78
+ if (p.startsWith(HOME + path.sep)) return "~" + p.slice(HOME.length);
79
+ return p;
80
+ }
81
+
82
+ /** Coerce a tokscale numeric field to a safe integer-ish number. */
83
+ export function num(x) {
84
+ const n = Number(x);
85
+ return Number.isFinite(n) ? n : 0;
86
+ }
87
+
88
+ // Map tokscale's client slugs to canonical platform names (mirrors tools.mjs).
89
+ // `synthetic` → null (filtered). unmapped slugs → "other".
90
+ const TOKSCALE_CLIENT_MAP = {
91
+ claude: "claude", codex: "codex", "devin-cli": "devin",
92
+ "devin-desktop": "devin", gemini: "gemini", amp: "amp",
93
+ kimi: "kimi", qwen: "qwen", goose: "goose", kilo: "kilo",
94
+ kilocode: "kilo", hermes: "hermes", droid: "droid",
95
+ codebuff: "codebuff", copilot: "copilot", opencode: "opencode",
96
+ openclaw: "openclaw", pi: "pi",
97
+ cursor: "other", roocode: "other", mux: "other", crush: "other",
98
+ antigravity: "other", "antigravity-cli": "other", zed: "other",
99
+ kiro: "other", trae: "other", warp: "other", cline: "other",
100
+ gjc: "other", grok: "other", jcode: "other", commandcode: "other",
101
+ micode: "other", junie: "other", zcode: "other",
102
+ opencodereview: "other", codebuddy: "other", workbuddy: "other",
103
+ synthetic: null,
104
+ };
105
+
106
+ /** Human-readable label for a tokscale client slug. */
107
+ const CLIENT_LABELS = {
108
+ claude: "Claude Code", codex: "Codex CLI", "devin-cli": "Devin CLI",
109
+ "devin-desktop": "Devin Desktop", gemini: "Gemini CLI", amp: "Amp",
110
+ kimi: "Kimi", qwen: "Qwen", goose: "Goose", kilo: "Kilo",
111
+ kilocode: "Kilo Code", hermes: "Hermes", droid: "Droid",
112
+ codebuff: "Codebuff", copilot: "GitHub Copilot", opencode: "OpenCode",
113
+ openclaw: "OpenClaw", pi: "Pi",
114
+ cursor: "Cursor", roocode: "Roo Code", mux: "Mux", crush: "Crush",
115
+ antigravity: "Antigravity", "antigravity-cli": "Antigravity CLI",
116
+ zed: "Zed", kiro: "Kiro", trae: "Trae", warp: "Warp", cline: "Cline",
117
+ gjc: "GJC", grok: "Grok", jcode: "JCode", commandcode: "Command Code",
118
+ micode: "MiCode", junie: "Junie", zcode: "ZCode",
119
+ opencodereview: "OpenCode Review", codebuddy: "CodeBuddy",
120
+ workbuddy: "WorkBuddy",
121
+ };
122
+
123
+ /** True for tokscale bookkeeping models that are not real model usage. */
124
+ export function isBookkeepingModel(m) {
125
+ return m === "<synthetic>" || m === "unknown" || !m;
126
+ }
127
+
128
+ // ── Raw tokscale subcommand readers (each null-safe on failure) ──────────────
129
+
130
+ async function _models() {
131
+ try {
132
+ const raw = await execFileAsync("tokscale", ["models", "--json", "--no-spinner"], T_MODELS);
133
+ const data = JSON.parse(raw);
134
+ const entries = Array.isArray(data?.entries) ? data.entries : Array.isArray(data) ? data : [];
135
+ return entries.filter((e) => e && e.client && !isBookkeepingModel(e.model));
136
+ } catch {
137
+ return null;
138
+ }
139
+ }
140
+
141
+ async function _monthly() {
142
+ try {
143
+ const raw = await execFileAsync("tokscale", ["monthly", "--json", "--no-spinner"], T_MONTHLY);
144
+ const data = JSON.parse(raw);
145
+ return Array.isArray(data?.entries) ? data.entries : [];
146
+ } catch {
147
+ return null;
148
+ }
149
+ }
150
+
151
+ async function _clients() {
152
+ try {
153
+ const raw = await execFileAsync("tokscale", ["clients", "--json"], T_CLIENTS);
154
+ return JSON.parse(raw);
155
+ } catch {
156
+ return null;
157
+ }
158
+ }
159
+
160
+ async function _graph() {
161
+ try {
162
+ const raw = await execFileAsync("tokscale", ["graph", "--no-spinner"], T_GRAPH);
163
+ return JSON.parse(raw);
164
+ } catch {
165
+ return null;
166
+ }
167
+ }
168
+
169
+ async function _report() {
170
+ try {
171
+ const raw = await execFileAsync(
172
+ "tokscale",
173
+ ["report", "--json", "--no-summarize"],
174
+ T_REPORT,
175
+ );
176
+ // tokscale prints a non-JSON status line ("N new sessions added to wiki")
177
+ // before the JSON array. Strip everything up to the first '['.
178
+ const idx = raw.indexOf("[");
179
+ const jsonText = idx >= 0 ? raw.slice(idx) : raw;
180
+ const data = JSON.parse(jsonText);
181
+ return Array.isArray(data) ? data : [];
182
+ } catch {
183
+ return null;
184
+ }
185
+ }
186
+
187
+ // ── Shared aggregation helpers ───────────────────────────────────────────────
188
+
189
+ /** Sum the four token pillars from a tokscale models entry. */
190
+ function totalTokens(e) {
191
+ return num(e.input) + num(e.output) + num(e.cacheRead) + num(e.cacheWrite) + num(e.reasoning);
192
+ }
193
+
194
+ /** Round to 2 decimals — for cost and percentage fields. */
195
+ function r2(x) {
196
+ return Math.round((Number(x) || 0) * 100) / 100;
197
+ }
198
+
199
+ /** Round to 4 decimals — for per-token cost rates. */
200
+ function r4(x) {
201
+ return Math.round((Number(x) || 0) * 10000) / 10000;
202
+ }
203
+
204
+ // ── 1. AI tool market share analysis ─────────────────────────────────────────
205
+
206
+ /**
207
+ * Aggregate tokscale `models` by client (AI tool) and compute each tool's share of
208
+ * total tokens, cost, and messages. Returns tools sorted by token share descending.
209
+ */
210
+ export async function tokscaleMarketShare() {
211
+ const entries = await _models();
212
+ if (entries === null) {
213
+ return { error: "tokscale models --json unavailable", tools: [] };
214
+ }
215
+ if (!entries.length) return { tools: [], totals: { tokens: 0, cost: 0, messages: 0 } };
216
+
217
+ const byClient = {};
218
+ let totalTok = 0, totalCost = 0, totalMsg = 0;
219
+ for (const e of entries) {
220
+ const tok = totalTokens(e);
221
+ const cost = num(e.cost);
222
+ const msg = num(e.messageCount);
223
+ if (tok === 0 && cost === 0 && msg === 0) continue;
224
+ const key = e.client;
225
+ if (!byClient[key]) {
226
+ byClient[key] = { client: key, label: CLIENT_LABELS[key] || key, tokens: 0, cost: 0, messages: 0, models: new Set() };
227
+ }
228
+ byClient[key].tokens += tok;
229
+ byClient[key].cost += cost;
230
+ byClient[key].messages += msg;
231
+ byClient[key].models.add(e.model);
232
+ totalTok += tok;
233
+ totalCost += cost;
234
+ totalMsg += msg;
235
+ }
236
+
237
+ const tools = Object.values(byClient).map((t) => ({
238
+ client: t.client,
239
+ label: t.label,
240
+ tokens: t.tokens,
241
+ cost: r2(t.cost),
242
+ messages: t.messages,
243
+ model_count: t.models.size,
244
+ share_tokens: totalTok > 0 ? r2((t.tokens / totalTok) * 100) : 0,
245
+ share_cost: totalCost > 0 ? r2((t.cost / totalCost) * 100) : 0,
246
+ share_messages: totalMsg > 0 ? r2((t.messages / totalMsg) * 100) : 0,
247
+ }));
248
+
249
+ tools.sort((a, b) => b.tokens - a.tokens);
250
+
251
+ return {
252
+ tools,
253
+ totals: {
254
+ tokens: totalTok,
255
+ cost: r2(totalCost),
256
+ messages: totalMsg,
257
+ tool_count: tools.length,
258
+ },
259
+ };
260
+ }
261
+
262
+ // ── 2. Per-developer usage profiles across 20+ tools ─────────────────────────
263
+
264
+ /**
265
+ * Build a per-developer usage profile: for each detected AI tool, the developer's
266
+ * model mix, token pillars, cost, message count, and performance. Combines
267
+ * `models` (usage) with `clients` (session counts + scan locations) and `report`
268
+ * (per-session workspace breakdown). All paths redacted.
269
+ */
270
+ export async function tokscaleDeveloperProfile() {
271
+ const [entries, clients, report] = await Promise.all([_models(), _clients(), _report()]);
272
+
273
+ if (entries === null) {
274
+ return { error: "tokscale models --json unavailable", tools: [] };
275
+ }
276
+ if (!entries.length) return { tools: [], summary: { tool_count: 0, total_cost: 0 } };
277
+
278
+ // Per-client model detail from `models`.
279
+ const byClient = {};
280
+ for (const e of entries) {
281
+ const tok = totalTokens(e);
282
+ const cost = num(e.cost);
283
+ const msg = num(e.messageCount);
284
+ if (tok === 0 && cost === 0 && msg === 0) continue;
285
+ const key = e.client;
286
+ if (!byClient[key]) {
287
+ byClient[key] = {
288
+ client: key,
289
+ label: CLIENT_LABELS[key] || key,
290
+ platform: TOKSCALE_CLIENT_MAP[key] ?? "other",
291
+ tokens: 0, cost: 0, messages: 0,
292
+ cache_read: 0, cache_write: 0, input: 0, output: 0, reasoning: 0,
293
+ models: [],
294
+ session_count: 0,
295
+ workspaces: new Set(),
296
+ };
297
+ }
298
+ const c = byClient[key];
299
+ c.tokens += tok;
300
+ c.cost += cost;
301
+ c.messages += msg;
302
+ c.input += num(e.input);
303
+ c.output += num(e.output);
304
+ c.cache_read += num(e.cacheRead);
305
+ c.cache_write += num(e.cacheWrite);
306
+ c.reasoning += num(e.reasoning);
307
+ const perf = e.performance || {};
308
+ c.models.push({
309
+ model: e.model,
310
+ provider: e.provider || null,
311
+ tokens: tok,
312
+ cost: r2(cost),
313
+ messages: msg,
314
+ input: num(e.input),
315
+ output: num(e.output),
316
+ cache_read: num(e.cacheRead),
317
+ cache_write: num(e.cacheWrite),
318
+ ms_per_1k_tokens: perf.msPer1KTokens ?? null,
319
+ token_coverage: perf.tokenCoverage ?? null,
320
+ });
321
+ }
322
+
323
+ // Enrich with session counts + scan paths from `clients`.
324
+ if (clients?.clients) {
325
+ for (const cl of clients.clients) {
326
+ const key = cl.client;
327
+ if (byClient[key]) {
328
+ byClient[key].session_count = num(cl.messageCount);
329
+ byClient[key].sessions_path = redactPath(cl.sessionsPath);
330
+ byClient[key].sessions_path_exists = !!cl.sessionsPathExists;
331
+ byClient[key].headless_supported = !!cl.headlessSupported;
332
+ }
333
+ }
334
+ }
335
+
336
+ // Enrich with workspace breakdown from `report` (per-session task data).
337
+ if (report) {
338
+ for (const s of report) {
339
+ const key = s.client;
340
+ if (byClient[key] && s.workspace_label) {
341
+ byClient[key].workspaces.add(s.workspace_label);
342
+ }
343
+ }
344
+ }
345
+
346
+ const tools = Object.values(byClient).map((c) => {
347
+ c.models.sort((a, b) => b.tokens - a.tokens);
348
+ return {
349
+ client: c.client,
350
+ label: c.label,
351
+ platform: c.platform,
352
+ tokens: c.tokens,
353
+ cost: r2(c.cost),
354
+ messages: c.messages,
355
+ input: c.input,
356
+ output: c.output,
357
+ cache_read: c.cache_read,
358
+ cache_write: c.cache_write,
359
+ reasoning: c.reasoning,
360
+ cache_read_pct: c.tokens > 0 ? r2((c.cache_read / c.tokens) * 100) : 0,
361
+ session_count: c.session_count,
362
+ sessions_path: c.sessions_path || null,
363
+ sessions_path_exists: c.sessions_path_exists ?? null,
364
+ headless_supported: c.headless_supported ?? null,
365
+ workspace_count: c.workspaces.size,
366
+ workspaces: [...c.workspaces].sort(),
367
+ models: c.models,
368
+ };
369
+ });
370
+
371
+ tools.sort((a, b) => b.tokens - a.tokens);
372
+
373
+ const totalCost = tools.reduce((s, t) => s + t.cost, 0);
374
+ const totalTok = tools.reduce((s, t) => s + t.tokens, 0);
375
+
376
+ return {
377
+ tools,
378
+ summary: {
379
+ tool_count: tools.length,
380
+ total_cost: r2(totalCost),
381
+ total_tokens: totalTok,
382
+ total_messages: tools.reduce((s, t) => s + t.messages, 0),
383
+ avg_cost_per_tool: tools.length > 0 ? r2(totalCost / tools.length) : 0,
384
+ dominant_tool: tools[0]?.client || null,
385
+ },
386
+ };
387
+ }
388
+
389
+ // ── 3. Model adoption trends over time ───────────────────────────────────────
390
+
391
+ /**
392
+ * Track model adoption month-over-month using `monthly` (per-month model lists +
393
+ * aggregates) cross-referenced with `graph` contributions (per-day per-client×model
394
+ * breakdown). Returns: per-month model adoption with first-seen / last-seen dates,
395
+ * token trajectory, and a model-level adoption timeline.
396
+ */
397
+ export async function tokscaleModelTrends() {
398
+ const [monthly, graph] = await Promise.all([_monthly(), _graph()]);
399
+
400
+ if (monthly === null && graph === null) {
401
+ return { error: "tokscale monthly/graph unavailable", months: [], models: [] };
402
+ }
403
+
404
+ // Per-month summary from `monthly`.
405
+ const months = [];
406
+ if (monthly && monthly.length) {
407
+ for (const m of monthly) {
408
+ const models = (m.models || []).filter((x) => !isBookkeepingModel(x));
409
+ months.push({
410
+ month: m.month,
411
+ models: models.sort(),
412
+ model_count: models.length,
413
+ input: num(m.input),
414
+ output: num(m.output),
415
+ cache_read: num(m.cacheRead),
416
+ cache_write: num(m.cacheWrite),
417
+ messages: num(m.messageCount),
418
+ cost: r2(m.cost),
419
+ });
420
+ }
421
+ months.sort((a, b) => (a.month < b.month ? -1 : 1));
422
+ }
423
+
424
+ // Per-model adoption timeline from `graph` contributions (per-day × client × model).
425
+ const modelTimeline = {}; // model → { firstSeen, lastSeen, days, tokens, cost, messages }
426
+ if (graph?.contributions) {
427
+ for (const day of graph.contributions) {
428
+ const date = day.date;
429
+ if (!date) continue;
430
+ for (const c of day.clients || []) {
431
+ const model = c.modelId;
432
+ if (isBookkeepingModel(model)) continue;
433
+ if (!modelTimeline[model]) {
434
+ modelTimeline[model] = {
435
+ model,
436
+ first_seen: date,
437
+ last_seen: date,
438
+ days: 0,
439
+ tokens: 0,
440
+ cost: 0,
441
+ messages: 0,
442
+ clients: new Set(),
443
+ };
444
+ }
445
+ const t = modelTimeline[model];
446
+ if (date < t.first_seen) t.first_seen = date;
447
+ if (date > t.last_seen) t.last_seen = date;
448
+ t.days += 1;
449
+ const tok = num(c.tokens?.input) + num(c.tokens?.output) + num(c.tokens?.cacheRead) + num(c.tokens?.cacheWrite) + num(c.tokens?.reasoning);
450
+ t.tokens += tok;
451
+ t.cost += num(c.cost);
452
+ t.messages += num(c.messages);
453
+ if (c.client) t.clients.add(c.client);
454
+ }
455
+ }
456
+ }
457
+
458
+ const models = Object.values(modelTimeline).map((t) => ({
459
+ model: t.model,
460
+ first_seen: t.first_seen,
461
+ last_seen: t.last_seen,
462
+ active_days: t.days,
463
+ tokens: t.tokens,
464
+ cost: r2(t.cost),
465
+ messages: t.messages,
466
+ client_count: t.clients.size,
467
+ clients: [...t.clients].sort(),
468
+ }));
469
+ models.sort((a, b) => b.tokens - a.tokens);
470
+
471
+ // Adoption curve: for each month, how many NEW models appeared (first_seen's
472
+ // year-month == this month). first_seen is a full date ("2026-02-17"); m.month
473
+ // is "2026-02", so compare the 7-char prefix.
474
+ const adoptionCurve = months.map((m) => {
475
+ const newModels = models
476
+ .filter((x) => (x.first_seen || "").slice(0, 7) === m.month)
477
+ .map((x) => x.model);
478
+ return { month: m.month, new_models: newModels, new_model_count: newModels.length, total_models: m.model_count };
479
+ });
480
+
481
+ return {
482
+ months,
483
+ models,
484
+ adoption_curve: adoptionCurve,
485
+ date_range: graph?.meta?.dateRange ?? null,
486
+ };
487
+ }
488
+
489
+ // ── 4. Cost analysis per developer per model ─────────────────────────────────
490
+
491
+ /**
492
+ * Cost analysis: per-client × per-model cost breakdown with per-token rates,
493
+ * cost share, and cost efficiency ranking. Built from `models`.
494
+ */
495
+ export async function tokscaleCostAnalysis() {
496
+ const entries = await _models();
497
+ if (entries === null) {
498
+ return { error: "tokscale models --json unavailable", entries: [], totals: {} };
499
+ }
500
+ if (!entries.length) return { entries: [], client_rollup: [], totals: { total_cost: 0 } };
501
+
502
+ const rows = entries.map((e) => {
503
+ const tok = totalTokens(e);
504
+ const cost = num(e.cost);
505
+ return {
506
+ client: e.client,
507
+ label: CLIENT_LABELS[e.client] || e.client,
508
+ model: e.model,
509
+ provider: e.provider || null,
510
+ tokens: tok,
511
+ cost: r2(cost),
512
+ messages: num(e.messageCount),
513
+ cost_per_million_tokens: tok > 0 ? r4((cost / tok) * 1_000_000) : 0,
514
+ cost_per_message: num(e.messageCount) > 0 ? r4(cost / num(e.messageCount)) : 0,
515
+ input: num(e.input),
516
+ output: num(e.output),
517
+ cache_read: num(e.cacheRead),
518
+ cache_write: num(e.cacheWrite),
519
+ };
520
+ });
521
+
522
+ rows.sort((a, b) => b.cost - a.cost);
523
+
524
+ const totalCost = rows.reduce((s, r) => s + r.cost, 0);
525
+ const totalTok = rows.reduce((s, r) => s + r.tokens, 0);
526
+ const totalMessages = rows.reduce((s, r) => s + r.messages, 0);
527
+
528
+ // Attach cost share to each row now that the total is known.
529
+ for (const r of rows) {
530
+ r.share_cost = totalCost > 0 ? r2((r.cost / totalCost) * 100) : 0;
531
+ }
532
+
533
+ // Per-client cost rollup.
534
+ const byClient = {};
535
+ for (const r of rows) {
536
+ if (!byClient[r.client]) {
537
+ byClient[r.client] = { client: r.client, label: r.label, cost: 0, tokens: 0, messages: 0, models: 0 };
538
+ }
539
+ byClient[r.client].cost += r.cost;
540
+ byClient[r.client].tokens += r.tokens;
541
+ byClient[r.client].messages += r.messages;
542
+ byClient[r.client].models += 1;
543
+ }
544
+ const clientRollup = Object.values(byClient).map((c) => ({
545
+ ...c,
546
+ cost: r2(c.cost),
547
+ cost_per_million_tokens: c.tokens > 0 ? r4((c.cost / c.tokens) * 1_000_000) : 0,
548
+ share_cost: totalCost > 0 ? r2((c.cost / totalCost) * 100) : 0,
549
+ }));
550
+ clientRollup.sort((a, b) => b.cost - a.cost);
551
+
552
+ const cheapest = rows.filter((r) => r.tokens > 0).sort((a, b) => a.cost_per_million_tokens - b.cost_per_million_tokens)[0];
553
+
554
+ return {
555
+ entries: rows,
556
+ client_rollup: clientRollup,
557
+ totals: {
558
+ total_cost: r2(totalCost),
559
+ total_tokens: totalTok,
560
+ total_messages: totalMessages,
561
+ avg_cost_per_million_tokens: totalTok > 0 ? r4((totalCost / totalTok) * 1_000_000) : 0,
562
+ most_expensive_model: rows[0] ? { model: rows[0].model, client: rows[0].client, cost: rows[0].cost } : null,
563
+ cheapest_per_token: cheapest
564
+ ? { model: cheapest.model, cost_per_million_tokens: cheapest.cost_per_million_tokens }
565
+ : null,
566
+ },
567
+ };
568
+ }
569
+
570
+ // ── 5. Device fingerprinting (installed tools, machines, active times) ──────
571
+
572
+ /**
573
+ * Device profile: which AI tools are installed on this machine, where their session
574
+ * logs live (redacted), how many sessions/messages each has, when the machine was
575
+ * active, session concurrency, and longest continuous session. Combines `clients`
576
+ * and `graph` (which already embeds timeMetrics, so no separate time-metrics call
577
+ * is needed — avoids parallel tokscale cache-lock contention). All paths redacted.
578
+ */
579
+ export async function tokscaleDeviceProfile() {
580
+ // Run sequentially: tokscale uses a shared cache and parallel invocations can
581
+ // conflict on the lock file. `graph` embeds timeMetrics so we save one call.
582
+ const clients = await _clients();
583
+ const graph = await _graph();
584
+ const timeMetrics = graph?.timeMetrics ?? null;
585
+
586
+ if (clients === null && graph === null) {
587
+ return { error: "tokscale clients/graph unavailable" };
588
+ }
589
+
590
+ const installed = [];
591
+ if (clients?.clients) {
592
+ for (const cl of clients.clients) {
593
+ installed.push({
594
+ client: cl.client,
595
+ label: CLIENT_LABELS[cl.client] || cl.client,
596
+ sessions_path: redactPath(cl.sessionsPath),
597
+ sessions_path_exists: !!cl.sessionsPathExists,
598
+ message_count: num(cl.messageCount),
599
+ headless_supported: !!cl.headlessSupported,
600
+ headless_message_count: num(cl.headlessMessageCount),
601
+ });
602
+ }
603
+ }
604
+
605
+ // Active days + per-day activity from `graph` contributions.
606
+ let activeDays = 0;
607
+ let totalDays = 0;
608
+ const dailyActivity = [];
609
+ if (graph?.contributions) {
610
+ totalDays = graph.contributions.length;
611
+ for (const day of graph.contributions) {
612
+ const tok = num(day.totals?.tokens);
613
+ const msg = num(day.totals?.messages);
614
+ if (tok > 0 || msg > 0) activeDays += 1;
615
+ dailyActivity.push({
616
+ date: day.date,
617
+ tokens: tok,
618
+ cost: r2(day.totals?.cost),
619
+ messages: msg,
620
+ active_time_ms: num(day.activeTimeMs),
621
+ client_count: (day.clients || []).length,
622
+ });
623
+ }
624
+ }
625
+
626
+ // Hourly activity heatmap from `graph` summary (which days of week / hours are hot).
627
+ // We derive day-of-week from the contribution dates.
628
+ const dowBuckets = [0, 0, 0, 0, 0, 0, 0]; // Sun-Sat
629
+ for (const d of dailyActivity) {
630
+ if (!d.date) continue;
631
+ const dow = new Date(d.date + "T00:00:00Z").getUTCDay();
632
+ if (Number.isFinite(dow)) dowBuckets[dow] += d.tokens;
633
+ }
634
+ const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
635
+ const byDayOfWeek = dayNames.map((name, i) => ({ day: name, tokens: dowBuckets[i] }));
636
+
637
+ const installedCount = installed.filter((i) => i.sessions_path_exists).length;
638
+
639
+ return {
640
+ installed_tools: installed,
641
+ installed_tool_count: installed.length,
642
+ active_tool_count: installedCount,
643
+ date_range: graph?.meta?.dateRange ?? null,
644
+ activity: {
645
+ total_days: totalDays,
646
+ active_days: activeDays,
647
+ active_day_pct: totalDays > 0 ? r2((activeDays / totalDays) * 100) : 0,
648
+ daily: dailyActivity,
649
+ by_day_of_week: byDayOfWeek,
650
+ },
651
+ sessions: timeMetrics
652
+ ? {
653
+ session_count: num(timeMetrics.sessionCount),
654
+ total_active_time_ms: num(timeMetrics.totalActiveTimeMs),
655
+ total_active_time_hours: r2(num(timeMetrics.totalActiveTimeMs) / 3_600_000),
656
+ longest_continuous_ms: num(timeMetrics.longestContinuousMs),
657
+ longest_continuous_hours: r2(num(timeMetrics.longestContinuousMs) / 3_600_000),
658
+ max_concurrent_sessions: num(timeMetrics.maxConcurrentSessions),
659
+ }
660
+ : null,
661
+ summary: graph?.summary
662
+ ? {
663
+ total_tokens: num(graph.summary.totalTokens),
664
+ total_cost: r2(graph.summary.totalCost),
665
+ average_cost_per_day: r2(graph.summary.averagePerDay),
666
+ max_cost_single_day: r2(graph.summary.maxCostInSingleDay),
667
+ }
668
+ : null,
669
+ };
670
+ }
671
+
672
+ // ── 6. MCP server usage patterns ─────────────────────────────────────────────
673
+
674
+ /**
675
+ * MCP server usage: which MCP servers tokscale detected, plus a cross-reference of
676
+ * when they were active (derived from `graph` mcpServers + contributions timeline).
677
+ * tokscale's `graph` exposes mcpServers as a flat list of detected server names.
678
+ */
679
+ export async function tokscaleMcpUsage() {
680
+ const graph = await _graph();
681
+ if (graph === null) {
682
+ return { error: "tokscale graph unavailable", servers: [] };
683
+ }
684
+
685
+ const servers = Array.isArray(graph.mcpServers) ? graph.mcpServers : [];
686
+ const contributions = graph.contributions || [];
687
+
688
+ // tokscale does not currently tag individual contributions with the MCP server
689
+ // that was active per session, so we report the detected set + the overall
690
+ // activity window during which MCP servers were present.
691
+ const dateRange = graph.meta?.dateRange ?? null;
692
+ const activeDays = contributions.filter(
693
+ (c) => num(c.totals?.tokens) > 0 || num(c.totals?.messages) > 0,
694
+ ).length;
695
+
696
+ return {
697
+ servers: servers.map((name) => ({ name, detected: true })),
698
+ server_count: servers.length,
699
+ detection_window: dateRange,
700
+ active_days_in_window: activeDays,
701
+ note: servers.length === 0
702
+ ? "No MCP servers detected by tokscale. MCP server usage tracking requires a tokscale version that records per-session MCP server attribution."
703
+ : "tokscale reports detected MCP servers as a set. Per-session MCP server attribution is not yet exposed in the graph payload; the detection window reflects the full activity span.",
704
+ };
705
+ }
706
+
707
+ // ── 7. Competitive intelligence for any AI tool company ──────────────────────
708
+
709
+ /**
710
+ * Competitive intelligence for a single AI tool (by tokscale client slug or
711
+ * canonical platform name). Returns the target tool's market share, model mix,
712
+ * cost efficiency, performance, and a head-to-head comparison against every other
713
+ * detected tool. Built from `models`.
714
+ *
715
+ * @param {string} target — tokscale client slug (e.g. "claude", "codex",
716
+ * "devin-cli") or canonical platform name (e.g. "devin", "other").
717
+ */
718
+ export async function tokscaleCompetitiveIntel(target) {
719
+ const entries = await _models();
720
+ if (entries === null) {
721
+ return { error: "tokscale models --json unavailable" };
722
+ }
723
+ if (!entries.length) return { target: target || null, found: false, competitors: [], detected_clients: [] };
724
+
725
+ // Normalize the target: accept either a raw tokscale slug or a canonical
726
+ // platform name from TOKSCALE_CLIENT_MAP.
727
+ const targetSlug = String(target || "").trim().toLowerCase();
728
+ if (!targetSlug) {
729
+ return { error: "target is required (a tokscale client slug like 'claude' or 'codex')" };
730
+ }
731
+
732
+ // Build a reverse map: canonical platform → tokscale slugs.
733
+ const platformToSlugs = {};
734
+ for (const [slug, platform] of Object.entries(TOKSCALE_CLIENT_MAP)) {
735
+ if (platform === null) continue;
736
+ if (!platformToSlugs[platform]) platformToSlugs[platform] = [];
737
+ platformToSlugs[platform].push(slug);
738
+ }
739
+
740
+ // Resolve which tokscale slugs match the target.
741
+ let targetSlugs = [];
742
+ if (TOKSCALE_CLIENT_MAP[targetSlug] !== undefined && TOKSCALE_CLIENT_MAP[targetSlug] !== null) {
743
+ // Target is a canonical platform name → match all slugs mapping to it.
744
+ targetSlugs = platformToSlugs[TOKSCALE_CLIENT_MAP[targetSlug]] || [];
745
+ } else {
746
+ // Target is a raw slug (may or may not be in the map).
747
+ targetSlugs = [targetSlug];
748
+ }
749
+
750
+ // Aggregate per-client.
751
+ const byClient = {};
752
+ let totalTok = 0, totalCost = 0, totalMsg = 0;
753
+ for (const e of entries) {
754
+ const tok = totalTokens(e);
755
+ const cost = num(e.cost);
756
+ const msg = num(e.messageCount);
757
+ if (tok === 0 && cost === 0 && msg === 0) continue;
758
+ const key = e.client;
759
+ if (!byClient[key]) {
760
+ byClient[key] = {
761
+ client: key,
762
+ label: CLIENT_LABELS[key] || key,
763
+ tokens: 0, cost: 0, messages: 0,
764
+ input: 0, output: 0, cache_read: 0, cache_write: 0,
765
+ models: new Map(),
766
+ };
767
+ }
768
+ const c = byClient[key];
769
+ c.tokens += tok; c.cost += cost; c.messages += msg;
770
+ c.input += num(e.input); c.output += num(e.output);
771
+ c.cache_read += num(e.cacheRead); c.cache_write += num(e.cacheWrite);
772
+ const m = c.models.get(e.model) || { model: e.model, tokens: 0, cost: 0, messages: 0 };
773
+ m.tokens += tok; m.cost += cost; m.messages += msg;
774
+ c.models.set(e.model, m);
775
+ totalTok += tok; totalCost += cost; totalMsg += msg;
776
+ }
777
+
778
+ const targetEntries = targetSlugs
779
+ .map((s) => byClient[s])
780
+ .filter(Boolean);
781
+
782
+ if (!targetEntries.length) {
783
+ const allClients = Object.keys(byClient).sort();
784
+ return {
785
+ target: targetSlug,
786
+ found: false,
787
+ note: `No usage found for "${targetSlug}". Detected clients: ${allClients.join(", ")}`,
788
+ detected_clients: allClients,
789
+ };
790
+ }
791
+
792
+ // Merge target slugs into one aggregate.
793
+ const targetAgg = {
794
+ client: targetSlugs.join("|"),
795
+ label: CLIENT_LABELS[targetSlugs[0]] || targetSlugs[0],
796
+ tokens: targetEntries.reduce((s, c) => s + c.tokens, 0),
797
+ cost: targetEntries.reduce((s, c) => s + c.cost, 0),
798
+ messages: targetEntries.reduce((s, c) => s + c.messages, 0),
799
+ input: targetEntries.reduce((s, c) => s + c.input, 0),
800
+ output: targetEntries.reduce((s, c) => s + c.output, 0),
801
+ cache_read: targetEntries.reduce((s, c) => s + c.cache_read, 0),
802
+ cache_write: targetEntries.reduce((s, c) => s + c.cache_write, 0),
803
+ models: new Map(),
804
+ };
805
+ for (const c of targetEntries) {
806
+ for (const m of c.models.values()) {
807
+ const ex = targetAgg.models.get(m.model) || { model: m.model, tokens: 0, cost: 0, messages: 0 };
808
+ ex.tokens += m.tokens; ex.cost += m.cost; ex.messages += m.messages;
809
+ targetAgg.models.set(m.model, ex);
810
+ }
811
+ }
812
+
813
+ const targetModels = [...targetAgg.models.values()].map((m) => ({
814
+ ...m,
815
+ cost: r2(m.cost),
816
+ share_of_target_tokens: targetAgg.tokens > 0 ? r2((m.tokens / targetAgg.tokens) * 100) : 0,
817
+ }));
818
+ targetModels.sort((a, b) => b.tokens - a.tokens);
819
+
820
+ const competitors = Object.values(byClient)
821
+ .filter((c) => !targetSlugs.includes(c.client))
822
+ .map((c) => ({
823
+ client: c.client,
824
+ label: c.label,
825
+ tokens: c.tokens,
826
+ cost: r2(c.cost),
827
+ messages: c.messages,
828
+ model_count: c.models.size,
829
+ share_tokens: totalTok > 0 ? r2((c.tokens / totalTok) * 100) : 0,
830
+ share_cost: totalCost > 0 ? r2((c.cost / totalCost) * 100) : 0,
831
+ cost_per_million_tokens: c.tokens > 0 ? r4((c.cost / c.tokens) * 1_000_000) : 0,
832
+ }));
833
+ competitors.sort((a, b) => b.tokens - a.tokens);
834
+
835
+ const targetCostPerM = targetAgg.tokens > 0 ? r4((targetAgg.cost / targetAgg.tokens) * 1_000_000) : 0;
836
+ const targetCachePct = targetAgg.tokens > 0 ? r2((targetAgg.cache_read / targetAgg.tokens) * 100) : 0;
837
+
838
+ // Rank the target against competitors.
839
+ const allRanked = [
840
+ { client: targetAgg.client, label: targetAgg.label, tokens: targetAgg.tokens, cost: targetAgg.cost },
841
+ ...competitors.map((c) => ({ client: c.client, label: c.label, tokens: c.tokens, cost: c.cost })),
842
+ ].sort((a, b) => b.tokens - a.tokens);
843
+ const targetRank = allRanked.findIndex((c) => c.client === targetAgg.client) + 1;
844
+
845
+ return {
846
+ target: targetAgg.client,
847
+ label: targetAgg.label,
848
+ found: true,
849
+ rank_by_tokens: targetRank,
850
+ total_tools_detected: allRanked.length,
851
+ target_profile: {
852
+ tokens: targetAgg.tokens,
853
+ cost: r2(targetAgg.cost),
854
+ messages: targetAgg.messages,
855
+ input: targetAgg.input,
856
+ output: targetAgg.output,
857
+ cache_read: targetAgg.cache_read,
858
+ cache_write: targetAgg.cache_write,
859
+ cache_read_pct: targetCachePct,
860
+ model_count: targetAgg.models.size,
861
+ cost_per_million_tokens: targetCostPerM,
862
+ share_tokens: totalTok > 0 ? r2((targetAgg.tokens / totalTok) * 100) : 0,
863
+ share_cost: totalCost > 0 ? r2((targetAgg.cost / totalCost) * 100) : 0,
864
+ share_messages: totalMsg > 0 ? r2((targetAgg.messages / totalMsg) * 100) : 0,
865
+ models: targetModels,
866
+ },
867
+ competitors,
868
+ market_totals: {
869
+ tokens: totalTok,
870
+ cost: r2(totalCost),
871
+ messages: totalMsg,
872
+ tool_count: Object.keys(byClient).length,
873
+ },
874
+ };
875
+ }
package/tools/index.mjs CHANGED
@@ -25,11 +25,11 @@ import { TOOL_DEF as simulateChangeTool, handleSimulateChange } from "./simulate
25
25
  import { TOOL_DEF as diagnoseCascadeTool, handleDiagnoseCascade } from "./diagnose-cascade.mjs";
26
26
  import { TOOL_DEF as suggestImprovementsTool, handleSuggestImprovements } from "./suggest-improvements.mjs";
27
27
  import { TOOL_DEF as selfImproveTool, handleSelfImprove } from "./self-improve.mjs";
28
- import { TOOL_DEF as getBestOperatorTool, handleGetBestOperator } from "./get-best-operator.mjs";
29
- import { TOOL_DEF as compareSelfTool, handleCompareSelf } from "./compare-self.mjs";
30
- import { TOOL_DEF as compareOperatorsTool, handleCompareOperators } from "./compare-operators.mjs";
31
- import { TOOL_DEF as describePowerUserTool, handleDescribePowerUser } from "./describe-power-user.mjs";
32
- import { TOOL_DEF as optimizeEfficiencyTool, handleOptimizeEfficiency } from "./optimize-efficiency.mjs";
28
+ // NOTE: get_best_operator, compare_self, compare_operators, describe_power_user,
29
+ // and optimize_efficiency were moved to bestuser-router-mcp (the marketing layer).
30
+ // They are duplicated there with richer params (metric sorting, platform filters,
31
+ // local text scoring). sigrank-mcp stays focused on local pulling + scoring +
32
+ // submission + tokscale analytics.
33
33
  import { TOOL_DEF as tokscaleBreakdownTool, handleTokscaleBreakdown } from "./tokscale-breakdown.mjs";
34
34
  import { TOOL_DEF as tokscaleMarketShareTool, handleTokscaleMarketShare } from "./tokscale-market-share.mjs";
35
35
  import { TOOL_DEF as tokscaleDeveloperProfileTool, handleTokscaleDeveloperProfile } from "./tokscale-developer-profile.mjs";
@@ -57,11 +57,8 @@ export const TOOLS = [
57
57
  diagnoseCascadeTool,
58
58
  suggestImprovementsTool,
59
59
  selfImproveTool,
60
- getBestOperatorTool,
61
- compareSelfTool,
62
- compareOperatorsTool,
63
- describePowerUserTool,
64
- optimizeEfficiencyTool,
60
+ // get_best_operator, compare_self, compare_operators, describe_power_user,
61
+ // optimize_efficiency → moved to bestuser-router-mcp
65
62
  tokscaleBreakdownTool,
66
63
  tokscaleMarketShareTool,
67
64
  tokscaleDeveloperProfileTool,
@@ -225,16 +222,8 @@ export async function callTool(name, args, opts = {}) {
225
222
  return handleSuggestImprovements(args);
226
223
  case "self_improve":
227
224
  return handleSelfImprove(args, ctx);
228
- case "get_best_operator":
229
- return handleGetBestOperator(args, ctx);
230
- case "compare_self":
231
- return handleCompareSelf(args, ctx);
232
- case "compare_operators":
233
- return handleCompareOperators(args, ctx);
234
- case "describe_power_user":
235
- return handleDescribePowerUser();
236
- case "optimize_efficiency":
237
- return handleOptimizeEfficiency(args, ctx);
225
+ // get_best_operator, compare_self, compare_operators, describe_power_user,
226
+ // optimize_efficiency → moved to bestuser-router-mcp
238
227
  case "tokscale_breakdown":
239
228
  return handleTokscaleBreakdown(args);
240
229
  case "tokscale_market_share":