sigrank 1.0.39 → 1.0.41

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/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.0.37](https://github.com/SunrisesIllNeverSee/sigrank-mcp/releases/tag/v1.0.37) (2026-09-08)
4
+
5
+ ### Fixes
6
+
7
+ * TUI: remove "multi" from Lifetime Tokens display (combined total, not a platform)
8
+ * TUI: Four Degrees "Top" column uses live operator cascade metrics instead of static snapshot
9
+ * TUI: "all" cascade row uses server batched data (from 1.0.36)
10
+ * Added Devin as first-class platform (P.09) across MCP + app
11
+
12
+ ## [1.0.36](https://github.com/SunrisesIllNeverSee/sigrank-mcp/releases/tag/v1.0.36) (2026-09-08)
13
+
14
+ ### Fixes
15
+
16
+ * TUI: "all" cascade row override uses server batched data to match Lifetime Tokens
17
+
18
+ ## [1.0.35](https://github.com/SunrisesIllNeverSee/sigrank-mcp/releases/tag/v1.0.35) (2026-09-07)
19
+
20
+ ### Fixes
21
+
22
+ * TUI presentation updates and audit fixes
23
+
3
24
  ## [0.0.203](https://github.com/SunrisesIllNeverSee/sigrank-mcp/compare/v0.0.202...v0.0.203) (2026-08-12)
4
25
 
5
26
  ### Chores
package/README.md CHANGED
@@ -161,7 +161,7 @@ smithery tool call sigrank rank_paste '{"text": "1000000 500000 50000 800000"}'
161
161
  ## Commands
162
162
 
163
163
  ```
164
- ⊙ SigRank SignalAF CLI v0.0.177
164
+ ⊙ SigRank SignalAF CLI v1.0.37
165
165
 
166
166
  Default (no args)
167
167
  sigrank unified dashboard: cascade + token pillars + board
@@ -407,7 +407,7 @@ All adapters are token-only (no message content, no cost fields, no credentials)
407
407
  ## Privacy
408
408
 
409
409
  - **Token-only persistence and submission.** Local-log adapters read usage metadata only. The optional proxy necessarily handles provider-bound request and response bytes in memory, but never persists their content; it writes only token counts, model/backend metadata, and timestamps. Only token telemetry is submitted to SigRank SignalAF.
410
- - **Local by default.** `tokenpull` reads only `~/.claude/projects` (Claude) or `~/.codex` (Codex) on your device. Numbers stay on your machine unless you explicitly submit with a codename.
410
+ - **Local by default.** `tokenpull` reads only the selected or detected adapter paths on your device (e.g. `~/.claude/projects` for Claude, `~/.codex` for Codex, plus additional paths for Amp, Devin, Gemini, Goose, and other supported platforms). Numbers stay on your machine unless you explicitly submit with a codename.
411
411
  - **Background tooling excluded.** Memory plugins, observers, summarizers (e.g. `claude-mem`, `mem0`, `observer-sessions`) are filtered from both Claude and Codex reads. `subagents/` are kept — they represent real operator work.
412
412
  - **Board reads are anonymous.** No account needed to browse, compare, or watch.
413
413
  - **Ranked submissions are signed, not trusted.** `sigrank submit` requires a one-time `enroll` (device-bound ed25519 key — the private key never leaves your machine). Verify what's sent with `sigrank submit --dry-run`: the payload is four token counts, ratios, and a signature.
@@ -42,13 +42,16 @@
42
42
  */
43
43
 
44
44
  import { readdir, readFile } from "node:fs/promises";
45
+ import { existsSync } from "node:fs";
45
46
  import { join, dirname } from "node:path";
46
47
  import { homedir } from "node:os";
47
48
  import { execFile as execFileCb } from "node:child_process";
48
49
  import { promisify } from "node:util";
50
+ import { fileURLToPath } from "node:url";
49
51
  import { cachedOmpScan, ompCacheEnabled } from "../omp-cache.mjs";
50
52
 
51
53
  const execFileP = promisify(execFileCb);
54
+ const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
52
55
  const DAY_MS = 86_400_000; // shared with tokenpull.mjs but kept local to avoid circular import
53
56
 
54
57
  // ── File-system helpers ───────────────────────────────────────────────────────
@@ -1075,6 +1078,96 @@ export const proxyAdapter = {
1075
1078
  },
1076
1079
  };
1077
1080
 
1081
+
1082
+ // ── ccusage-backed adapters ──────────────────────────────────────────────────
1083
+ // ccusage maintains source-specific readers for these platforms and normalizes
1084
+ // its daily JSON output into the same four token buckets SigRank requires.
1085
+ // Keep these as thin adapters instead of duplicating upstream parsers here.
1086
+ // The bundled dependency is preferred; PATH is a fallback for development.
1087
+ async function execCcusage(args) {
1088
+ const localEntrypoint = join(
1089
+ PACKAGE_ROOT,
1090
+ "node_modules",
1091
+ "ccusage",
1092
+ "src",
1093
+ "cli.js",
1094
+ );
1095
+ const options = {
1096
+ timeout: 30_000,
1097
+ maxBuffer: 64 * 1024 * 1024,
1098
+ };
1099
+ if (existsSync(localEntrypoint)) {
1100
+ return execFileP(process.execPath, [localEntrypoint, ...args], options);
1101
+ }
1102
+ return execFileP("ccusage", args, options);
1103
+ }
1104
+
1105
+ function makeCcusageAdapter(platform) {
1106
+ return {
1107
+ platform,
1108
+ defaultRoot: () => homedir(),
1109
+ async *messages() {
1110
+ let data;
1111
+ try {
1112
+ const { stdout } = await execCcusage([
1113
+ platform,
1114
+ "daily",
1115
+ "--json",
1116
+ "--no-cost",
1117
+ ]);
1118
+ data = JSON.parse(stdout || "{}");
1119
+ } catch {
1120
+ return;
1121
+ }
1122
+
1123
+ const rows = Array.isArray(data?.daily)
1124
+ ? data.daily
1125
+ : Array.isArray(data?.data)
1126
+ ? data.data
1127
+ : Array.isArray(data)
1128
+ ? data
1129
+ : [];
1130
+
1131
+ for (const row of rows) {
1132
+ if (!row || typeof row !== "object") continue;
1133
+ const date = row.date || row.day;
1134
+ if (!date) continue;
1135
+ const input = Number(row.inputTokens ?? row.input_tokens ?? 0);
1136
+ const output = Number(row.outputTokens ?? row.output_tokens ?? 0);
1137
+ const cacheCreate = Number(
1138
+ row.cacheCreationTokens ?? row.cache_create_tokens ?? 0,
1139
+ );
1140
+ const cacheRead = Number(
1141
+ row.cacheReadTokens ?? row.cache_read_tokens ?? 0,
1142
+ );
1143
+ if (
1144
+ ![input, output, cacheCreate, cacheRead].every(
1145
+ (n) => Number.isFinite(n) && n >= 0,
1146
+ )
1147
+ ) {
1148
+ continue;
1149
+ }
1150
+ if (input + output + cacheCreate + cacheRead === 0) continue;
1151
+
1152
+ yield {
1153
+ id: `${platform}:${date}`,
1154
+ sid: null,
1155
+ ts: `${date}T12:00:00.000Z`,
1156
+ input,
1157
+ output,
1158
+ cacheCreate,
1159
+ cacheRead,
1160
+ file: `ccusage:${platform}`,
1161
+ };
1162
+ }
1163
+ },
1164
+ };
1165
+ }
1166
+
1167
+ export const antigravityAdapter = makeCcusageAdapter("antigravity");
1168
+ export const grokAdapter = makeCcusageAdapter("grok");
1169
+ export const zcodeAdapter = makeCcusageAdapter("zcode");
1170
+
1078
1171
  // ── Registry ──────────────────────────────────────────────────────────────────
1079
1172
  /** All non-Claude, non-Codex adapters keyed by platform ID. */
1080
1173
  export const ADAPTERS = {
@@ -1095,6 +1188,9 @@ export const ADAPTERS = {
1095
1188
  other: otherAdapter,
1096
1189
  omp: ompAdapter,
1097
1190
  proxy: proxyAdapter,
1191
+ antigravity: antigravityAdapter,
1192
+ grok: grokAdapter,
1193
+ zcode: zcodeAdapter,
1098
1194
  };
1099
1195
 
1100
- export const ALL_PLATFORMS = Object.keys(ADAPTERS).concat(["claude", "codex", "grok"]);
1196
+ export const ALL_PLATFORMS = Object.keys(ADAPTERS).concat(["claude", "codex"]);
@@ -18,19 +18,20 @@
18
18
  import { readdir, readFile } from "node:fs/promises";
19
19
  import { existsSync } from "node:fs";
20
20
  import { execFile } from "node:child_process";
21
- import { join, dirname } from "node:path";
21
+ import { join, dirname, delimiter } from "node:path";
22
22
  import { homedir } from "node:os";
23
23
  import { fileURLToPath } from "node:url";
24
24
  import { ADAPTERS, walkFiles } from "./index.mjs";
25
+ import { TOKSCALE_CLIENT_MAP } from "../lib/constants.mjs";
25
26
 
26
27
  const DAY_MS = 86_400_000;
27
28
 
28
29
  // Resolve the package root for finding bundled binaries (ccusage, tokscale, etc.)
29
- const _pkgRoot = join(dirname(fileURLToPath(import.meta.url)));
30
+ const _pkgRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
30
31
  const _localBin = join(_pkgRoot, "node_modules", ".bin");
31
32
  // Prepend local node_modules/.bin to PATH so bundled deps are found even when
32
33
  // not globally installed (e.g., npx sigrank, local dev).
33
- const _envPath = `${_localBin}${process.env.PATH ? ":" + process.env.PATH : ""}`;
34
+ const _envPath = `${_localBin}${process.env.PATH ? delimiter + process.env.PATH : ""}`;
34
35
 
35
36
  // ASYNC FIX (2026-06-27): execFile wrapped in a Promise — replaces execSync in the
36
37
  // fresh verifier readers. execSync blocks the entire Node event loop (no key handling,
@@ -522,14 +523,17 @@ async function _freshTokscale(platform = "claude") {
522
523
  : Array.isArray(data)
523
524
  ? data
524
525
  : [];
525
- const rows = entries.filter(
526
- (e) =>
527
- e &&
528
- e.client === platform &&
529
- e.model !== "<synthetic>" &&
530
- e.model !== "unknown" &&
531
- ((Number(e.input) || 0) > 0 || (Number(e.output) || 0) > 0),
532
- );
526
+ const rows = entries.filter((e) => {
527
+ if (!e || e.model === "<synthetic>" || e.model === "unknown") return false;
528
+ const mapped = Object.prototype.hasOwnProperty.call(
529
+ TOKSCALE_CLIENT_MAP,
530
+ e.client,
531
+ )
532
+ ? TOKSCALE_CLIENT_MAP[e.client]
533
+ : e.client;
534
+ if (mapped !== platform) return false;
535
+ return (Number(e.input) || 0) > 0 || (Number(e.output) || 0) > 0;
536
+ });
533
537
  if (!rows.length) return null;
534
538
  const acc = rows.reduce(
535
539
  (a, e) => ({
@@ -149,7 +149,7 @@ export function ensureIdentity() {
149
149
  // Regeneration path: a partial/corrupt record. The binding is tied to device_id —
150
150
  // if a new device_id is generated, the old codename/operator MUST NOT carry over
151
151
  // (bindingForFreshIdentity drops them). Only a reused device_id keeps its binding.
152
- const fresh = generateIdentity({ device_id: existing?.device_id });
152
+ const fresh = generateIdentity();
153
153
  const binding = bindingForFreshIdentity(existing, fresh);
154
154
  fresh.codename = binding.codename;
155
155
  fresh.operator_id = binding.operator_id;
@@ -173,7 +173,13 @@ export function recordEnrollment({ codename, operator_id }) {
173
173
  /** Clear the local identity (sign out). Next enroll provisions a fresh device_id. */
174
174
  export function clearIdentity() {
175
175
  try {
176
- if (existsSync(PATH)) unlinkSync(PATH);
176
+ if (existsSync(DIR)) {
177
+ for (const file of readdirSync(DIR)) {
178
+ if (file === "identity.json" || file.startsWith(BACKUP_PREFIX)) {
179
+ unlinkSync(join(DIR, file));
180
+ }
181
+ }
182
+ }
177
183
  } catch {
178
184
  /* best-effort */
179
185
  }
package/index.mjs CHANGED
@@ -54,21 +54,28 @@ function serverVersion() {
54
54
  // Prevent silent crashes — log to stderr (MCP clients read stdout; stderr is safe for
55
55
  // diagnostics). The process exits so the client can respawn with a clean slate rather
56
56
  // than hanging on a broken connection.
57
- process.on("uncaughtException", (err) => {
58
- process.stderr.write(
59
- `[sigrank-mcp] uncaughtException: ${err?.message || err}\n`,
60
- );
61
- process.exit(1);
62
- });
63
- process.on("unhandledRejection", (reason) => {
64
- const msg = reason instanceof Error ? reason.message : String(reason);
65
- process.stderr.write(
66
- `[sigrank-mcp] unhandledRejection: ${msg}\n`,
67
- );
68
- process.exit(1);
69
- });
57
+ // NOTE: These handlers are scoped to MCP mode only — see installMcpCrashHandlers()
58
+ // called inside startMcpServer(). Installing them at module load would break the
59
+ // TUI (process.exit(1) doesn't restore raw mode / alternate screen).
60
+
61
+ function installMcpCrashHandlers() {
62
+ process.on("uncaughtException", (err) => {
63
+ process.stderr.write(
64
+ `[sigrank-mcp] uncaughtException: ${err?.message || err}\n`,
65
+ );
66
+ process.exit(1);
67
+ });
68
+ process.on("unhandledRejection", (reason) => {
69
+ const msg = reason instanceof Error ? reason.message : String(reason);
70
+ process.stderr.write(
71
+ `[sigrank-mcp] unhandledRejection: ${msg}\n`,
72
+ );
73
+ process.exit(1);
74
+ });
75
+ }
70
76
 
71
77
  async function startMcpServer() {
78
+ installMcpCrashHandlers();
72
79
  const server = new Server(
73
80
  { name: "sigrank", version: serverVersion() },
74
81
  { capabilities: { tools: { listChanged: false }, prompts: { listChanged: false }, resources: { listChanged: false } } },
package/lib/constants.mjs CHANGED
@@ -68,8 +68,9 @@ export const TOKSCALE_CLIENT_MAP = {
68
68
  roocode: "other",
69
69
  mux: "other",
70
70
  crush: "other",
71
- antigravity: "other",
72
- "antigravity-cli": "other",
71
+ antigravity: "antigravity",
72
+ "antigravity-cli": "antigravity",
73
+ "antigravity-extension": "antigravity",
73
74
  zed: "other",
74
75
  kiro: "other",
75
76
  trae: "other",
@@ -77,12 +78,12 @@ export const TOKSCALE_CLIENT_MAP = {
77
78
  cline: "other",
78
79
  "9router": "other",
79
80
  gjc: "other",
80
- grok: "other",
81
+ grok: "grok",
81
82
  jcode: "other",
82
83
  commandcode: "other",
83
84
  micode: "other",
84
85
  junie: "other",
85
- zcode: "other",
86
+ zcode: "zcode",
86
87
  opencodereview: "other",
87
88
  codebuddy: "other",
88
89
  workbuddy: "other",
@@ -92,25 +93,27 @@ export const TOKSCALE_CLIENT_MAP = {
92
93
  reasonix: "other",
93
94
  "prime-agent": "other",
94
95
  freebuff: "other",
96
+ cherrystudio: "other",
97
+ dsh: "other",
98
+ mcode: "other",
99
+ fx: "other",
100
+ lmstudio: "other",
101
+ unsloth: "other",
102
+ hindsight: "other",
103
+ "micode-desktop": "other",
104
+ muse: "other",
95
105
  synthetic: null,
96
106
  };
97
107
 
98
108
  /**
99
- * Platforms whose local data tokscale CANNOT see, so `tokscale models --json`
100
- * never lists them as an active client. pullActivePlatforms() auto-detect must
101
- * probe these directly or their cascade row can never appear, even with GBs of
102
- * native local telemetry (that was exactly the omp bug: tokscale reports
103
- * claude/codex/copilot/gemini/grok/kimi/kiro/opencode/pi and nothing else, so
104
- * a tokscale-detected machine silently dropped oh-my-pi entirely).
109
+ * Platforms whose local data the pinned tokscale release cannot discover.
105
110
  *
106
- * The adapter pull itself filters out platforms with no data, so probing a
107
- * platform the operator doesn't use costs one cheap missing-directory walk.
108
- *
109
- * As of 2026-08-08: oh-my-pi. Delete an entry once tokscale reports that client
110
- * (TOKSCALE_CLIENT_MAP already maps the `omp` / `oh-my-pi` client names for
111
- * when that happens).
111
+ * Tokscale 4.17.0 includes oh-my-pi (`omp`) directly, so there are currently
112
+ * no blind platforms that need forced probing. Keep the hook because future
113
+ * platform-specific discovery gaps can still be added here without changing
114
+ * pullActivePlatforms().
112
115
  */
113
- export const TOKSCALE_BLIND_PLATFORMS = ["omp"];
116
+ export const TOKSCALE_BLIND_PLATFORMS = [];
114
117
 
115
118
  /**
116
119
  * The "actually on the board" predicate. A submission is ranked only when the
package/manifest.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://raw.githubusercontent.com/anthropics/mcpb/main/schemas/mcpb-manifest-v0.4.schema.json",
3
3
  "manifest_version": "0.4",
4
4
  "name": "sigrank",
5
- "version": "1.0.38",
5
+ "version": "1.0.40",
6
6
  "description": "Ranks AI coding operators by token cascade efficiency (Yield = cache_read \u00d7 output / input\u00b2). 25 tools including SigRank Standard record, yield calculation, local token usage pulls, signed submission, and tokscale-powered analytics (market share, developer profiles, model trends, cost analysis, device profiles, MCP usage, competitive intelligence). Privacy-first: runs locally, only four token counts leave the machine.",
7
7
  "author": {
8
8
  "name": "SunrisesIllNeverSee",
package/omp-cache.mjs CHANGED
@@ -23,7 +23,7 @@
23
23
  * obtained, falls back to uncached scan without damaging the cache.
24
24
  */
25
25
 
26
- import { execFile as execFileCb } from "node:child_process";
26
+ import { execFile as execFileCb, spawn } from "node:child_process";
27
27
  import { promisify } from "node:util";
28
28
  import { mkdir, stat, unlink } from "node:fs/promises";
29
29
  import { join, dirname } from "node:path";
@@ -31,6 +31,22 @@ import { homedir } from "node:os";
31
31
 
32
32
  const execFileP = promisify(execFileCb);
33
33
 
34
+ /** Run SQL via stdin to avoid OS argument-length limits (E2BIG on large transcripts). */
35
+ function sqliteExecStdin(dbPath, sql, timeoutMs = 30_000) {
36
+ return new Promise((resolve) => {
37
+ const child = spawn("sqlite3", [dbPath], {
38
+ stdio: ["pipe", "pipe", "pipe"],
39
+ timeout: timeoutMs,
40
+ });
41
+ let stderr = "";
42
+ child.stderr.on("data", (d) => { stderr += d; });
43
+ child.on("error", () => resolve(false));
44
+ child.on("close", (code) => resolve(code === 0));
45
+ child.stdin.write(`PRAGMA busy_timeout=${BUSY_TIMEOUT_MS};\n${sql}`);
46
+ child.stdin.end();
47
+ });
48
+ }
49
+
34
50
  const CACHE_SCHEMA_VERSION = "1";
35
51
  const CACHE_PARSER_VERSION = "omp-stage1-v1";
36
52
  const BUSY_TIMEOUT_MS = 5_000;
@@ -57,7 +73,7 @@ async function sqliteJson(dbPath, sql, timeoutMs = 10_000) {
57
73
  /** Run a SQLite command that doesn't return rows (DDL/DML). Returns success boolean. */
58
74
  async function sqliteExec(dbPath, sql, timeoutMs = 30_000) {
59
75
  try {
60
- await execFileP("sqlite3", [dbPath, sql], {
76
+ await execFileP("sqlite3", [dbPath, `PRAGMA busy_timeout=${BUSY_TIMEOUT_MS}; ${sql}`], {
61
77
  timeout: timeoutMs,
62
78
  maxBuffer: SQLITE_MAX_BUFFER,
63
79
  });
@@ -67,8 +83,13 @@ async function sqliteExec(dbPath, sql, timeoutMs = 30_000) {
67
83
  }
68
84
  }
69
85
 
70
- /** Run multiple SQL statements in a single sqlite3 call (semicolon-separated). */
86
+ /** Run multiple SQL statements in a single sqlite3 call (semicolon-separated).
87
+ * Uses stdin for large SQL to avoid OS argument-length limits. */
71
88
  async function sqliteExecBatch(dbPath, sql, timeoutMs = 60_000) {
89
+ // OS arg-length limit: ~256KB on macOS, ~2MB on Linux. Use stdin for safety.
90
+ if (sql.length > 200_000) {
91
+ return sqliteExecStdin(dbPath, sql, timeoutMs);
92
+ }
72
93
  return sqliteExec(dbPath, sql, timeoutMs);
73
94
  }
74
95
 
@@ -425,7 +446,7 @@ export async function* cachedOmpScan({
425
446
  const cachedSum = sumFields(cachedRecords, field);
426
447
  const uncachedSum = sumFields(uncachedRecords, field);
427
448
  if (cachedSum !== uncachedSum) {
428
- console.error(
449
+ throw new Error(
429
450
  `[omp-cache] PARITY DRIFT: ${field} cached=${cachedSum} uncached=${uncachedSum} (diff=${cachedSum - uncachedSum})`,
430
451
  );
431
452
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sigrank",
3
- "version": "1.0.39",
3
+ "version": "1.0.41",
4
4
  "author": "SunrisesIllNeverSee (https://github.com/SunrisesIllNeverSee)",
5
5
  "description": "Upsilon measurement engine distributed through the sigrank CLI/MCP compatibility package, with SigRank public leaderboard tools.",
6
6
  "mcpName": "io.github.SunrisesIllNeverSee/sigrank-mcp",
@@ -29,7 +29,7 @@ import { cascade, classify, CLASS_TIERS, UNCLASSED, tierOf } from "../cascade.mj
29
29
  import { ALL_PLATFORMS } from "../adapters.mjs";
30
30
  import { ensureIdentity, keystorePath } from "../keystore.mjs";
31
31
  import { submitSignedWindow } from "../submit.mjs";
32
- import { LEADERBOARD_METRIC } from "../lib/constants.mjs";
32
+ import { LEADERBOARD_METRIC, TOKSCALE_CLIENT_MAP } from "../lib/constants.mjs";
33
33
  import { execFile } from "child_process";
34
34
  import { existsSync, readFileSync } from "fs";
35
35
  import os from "os";
@@ -37,9 +37,9 @@ import path from "path";
37
37
  import { fileURLToPath } from "url";
38
38
 
39
39
  // Resolve local node_modules/.bin for bundled deps (ccusage, tokscale)
40
- const _pkgRoot = path.dirname(fileURLToPath(import.meta.url));
40
+ const _pkgRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
41
41
  const _localBin = path.join(_pkgRoot, "node_modules", ".bin");
42
- const _envPath = `${_localBin}${process.env.PATH ? ":" + process.env.PATH : ""}`;
42
+ const _envPath = `${_localBin}${process.env.PATH ? path.delimiter + process.env.PATH : ""}`;
43
43
 
44
44
  // ASYNC FIX (2026-06-27): execFile wrapped in a Promise — replaces execSync for
45
45
  // defense-in-depth (shell injection prevention). execFile passes args as an
@@ -429,14 +429,17 @@ async function tokscalePillars(platform = "claude") {
429
429
  : Array.isArray(data)
430
430
  ? data
431
431
  : [];
432
- const rows = entries.filter(
433
- (e) =>
434
- e &&
435
- e.client === platform &&
436
- e.model !== "<synthetic>" &&
437
- e.model !== "unknown" &&
438
- ((Number(e.input) || 0) > 0 || (Number(e.output) || 0) > 0),
439
- );
432
+ const rows = entries.filter((e) => {
433
+ if (!e || e.model === "<synthetic>" || e.model === "unknown") return false;
434
+ const mapped = Object.prototype.hasOwnProperty.call(
435
+ TOKSCALE_CLIENT_MAP,
436
+ e.client,
437
+ )
438
+ ? TOKSCALE_CLIENT_MAP[e.client]
439
+ : e.client;
440
+ if (mapped !== platform) return false;
441
+ return (Number(e.input) || 0) > 0 || (Number(e.output) || 0) > 0;
442
+ });
440
443
  if (rows.length) {
441
444
  const p = rows.reduce(
442
445
  (acc, e) => ({
@@ -458,13 +461,16 @@ async function tokscalePillars(platform = "claude") {
458
461
  try {
459
462
  const data = JSON.parse(readFileSync(reportPath, "utf8"));
460
463
  const entries = data.entries ?? [];
461
- const rows = entries.filter(
462
- (e) =>
463
- e.client === platform &&
464
- e.model !== "<synthetic>" &&
465
- e.model !== "unknown" &&
466
- (e.input > 0 || e.output > 0),
467
- );
464
+ const rows = entries.filter((e) => {
465
+ if (!e || e.model === "<synthetic>" || e.model === "unknown") return false;
466
+ const mapped = Object.prototype.hasOwnProperty.call(
467
+ TOKSCALE_CLIENT_MAP,
468
+ e.client,
469
+ )
470
+ ? TOKSCALE_CLIENT_MAP[e.client]
471
+ : e.client;
472
+ return mapped === platform && (e.input > 0 || e.output > 0);
473
+ });
468
474
  if (rows.length === 0) return null;
469
475
  const p = rows.reduce(
470
476
  (acc, e) => ({
@@ -21,7 +21,7 @@ import {
21
21
  // Resolve local node_modules/.bin for bundled deps (ccusage, tokscale)
22
22
  const _pkgRoot = path.dirname(fileURLToPath(import.meta.url));
23
23
  const _localBin = path.join(_pkgRoot, "..", "node_modules", ".bin");
24
- const _envPath = `${_localBin}${process.env.PATH ? ":" + process.env.PATH : ""}`;
24
+ const _envPath = `${_localBin}${process.env.PATH ? path.delimiter + process.env.PATH : ""}`;
25
25
 
26
26
  export const DEFAULT_API_BASE =
27
27
  process.env.SIGRANK_API_BASE || "https://signalaf.com";
@@ -113,7 +113,8 @@ export async function tokscaleModelBreakdown(threshold = 0.01) {
113
113
  }
114
114
 
115
115
  export async function handleTokscaleBreakdown(args) {
116
- const threshold = Number(args?.threshold ?? 0.01);
116
+ const raw = Number(args?.threshold ?? 0.01);
117
+ const threshold = Number.isFinite(raw) ? Math.max(0, raw) : 0.01;
117
118
  const platforms = await tokscaleModelBreakdown(threshold);
118
119
  return { platforms };
119
120
  }
@@ -39,7 +39,11 @@ export const TOOL_DEF = {
39
39
  export async function handleTokscaleCompetitiveIntel(args) {
40
40
  const target = String(args?.target || "").trim();
41
41
  if (!target) {
42
- throw new Error("tokscale_competitive_intel requires a non-empty `target` argument (a tokscale client slug like 'claude' or 'codex').");
42
+ return {
43
+ target: "",
44
+ found: false,
45
+ error: "tokscale_competitive_intel requires a non-empty `target` argument (a tokscale client slug like 'claude' or 'codex').",
46
+ };
43
47
  }
44
48
  return await tokscaleCompetitiveIntel(target);
45
49
  }
package/tui.mjs CHANGED
@@ -2,7 +2,8 @@ import { runTui } from './presentation/tui.mjs';
2
2
  export * from './presentation/tui.mjs';
3
3
 
4
4
  import { pathToFileURL } from 'node:url';
5
- if (import.meta.url === pathToFileURL(process.argv[1]).href) {
5
+ const entry = process.argv[1];
6
+ if (entry && import.meta.url === pathToFileURL(entry).href) {
6
7
  runTui().catch((e) => {
7
8
  console.error(e);
8
9
  process.exit(1);