sigrank 0.19.0 → 1.0.1

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