sigrank 0.0.195 → 0.0.197

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/README.md CHANGED
@@ -173,12 +173,15 @@ Commands
173
173
  watch live tune meter — ALL active platforms × all windows, every 30s
174
174
  watch --platform codex watch only one platform (optional filter)
175
175
  watch --window 7d watch only one window (optional filter)
176
+ proxy opt-in local Anthropic/OpenAI usage proxy
177
+ proxy --port 9000 run the proxy on a custom loopback port
176
178
 
177
179
  Options
178
180
  --window 7d · 30d · 90d · all (default: 30d for board; all windows for watch)
179
181
  --platform claude · codex · amp · gemini · opencode · goose · …
180
182
  --refresh poll interval in seconds (default: 30)
181
183
  --once print once and exit (board only)
184
+ --port proxy port (default: 8787)
182
185
 
183
186
  For AI clients (not typeable)
184
187
  In a piped/non-TTY context, sigrank is an MCP stdio server.
@@ -194,6 +197,34 @@ Examples
194
197
  sigrank board --window all --once
195
198
  ```
196
199
 
200
+ ### Optional API usage proxy
201
+
202
+ Some desktop coding agents receive provider usage in API responses but do not
203
+ persist it in their local session files. SigRank can capture those
204
+ provider-reported counts through a manually started loopback proxy:
205
+
206
+ ```bash
207
+ sigrank proxy # http://localhost:8787
208
+ sigrank proxy --port 9000 # custom port
209
+ ```
210
+
211
+ Then point a compatible tool's API base URL at the displayed local URL. The
212
+ first release supports Anthropic Messages (`/v1/messages`), OpenAI Chat
213
+ Completions (`/v1/chat/completions`), and OpenAI Responses (`/v1/responses`).
214
+ The tool must support a custom API base URL; this is not guaranteed for every
215
+ desktop client.
216
+
217
+ The proxy is **off by default**: it opens no port and observes no traffic unless
218
+ you explicitly run `sigrank proxy`. It binds only to loopback and stops when the
219
+ command exits. Request and response content, API keys, and tool calls are
220
+ forwarded transiently but never written to disk. Only usage metadata is appended
221
+ to `~/.sigrank-mcp/proxy-sessions.jsonl` (directory `0700`, file `0600`).
222
+
223
+ Anthropic and OpenAI calls are currently grouped under one `proxy` platform row.
224
+ For streamed Chat Completions, SigRank sets OpenAI's
225
+ `stream_options.include_usage=true` so the provider includes the final usage
226
+ chunk; response chunks are still forwarded immediately.
227
+
197
228
  ### The TUI is the whole app
198
229
 
199
230
  Launch it and sign in inside it:
@@ -354,6 +385,7 @@ All adapters are token-only (no message content, no cost fields, no credentials)
354
385
  | Gemini CLI | ✅ `~/.gemini/tmp` | Estimated (`cacheCreate=0`); `cacheRead` from `cached` field; `thought`→output; `input` = `input−cached` (fresh) |
355
386
  | GitHub Copilot CLI | ✅ `~/.copilot/otel` | Native 4-pillar (OTel spans: `llm.token_count.{prompt,completion,cache_creation,cache_read}`); requires `COPILOT_OTEL_ENABLED=true` + `COPILOT_OTEL_EXPORTER_TYPE=file` set **before** session start |
356
387
  | OpenCode | ⚠️ `~/.local/share/opencode` | Data gap — logs store `cost:0` and derive tokens via LiteLLM at runtime; raw token counts not persisted. No pillars readable with current format |
388
+ | SigRank proxy | ✅ `~/.sigrank-mcp/proxy-sessions.jsonl` | Opt-in native 4-pillar usage reported by Anthropic/OpenAI; same-timestamp records keep the last call; OpenAI cached input is separated from fresh input |
357
389
  | Other (user JSON) | ✅ `$SIGRANK_OTHER_PATH` | User-supplied JSON `{ "windows": { "all": {input,output,cacheCreate,cacheRead} } }`; all-time only (no timestamps) |
358
390
  | Cursor | 🔜 | Chat log path TBD |
359
391
  | Windsurf | 🔜 | Session logs at `~/.codeium/windsurf/` |
@@ -364,7 +396,7 @@ All adapters are token-only (no message content, no cost fields, no credentials)
364
396
 
365
397
  ## Privacy
366
398
 
367
- - **Token-only, always.** No message content is ever read, logged, or transmitted — only token counts (`input`, `output`, `cache_creation`, `cache_read`), message IDs, and timestamps.
399
+ - **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.
368
400
  - **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.
369
401
  - **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.
370
402
  - **Board reads are anonymous.** No account needed to browse, compare, or watch.
@@ -384,7 +416,7 @@ All adapters are token-only (no message content, no cost fields, no credentials)
384
416
  ## Dev / test
385
417
 
386
418
  ```bash
387
- node test.mjs # 14 test groups, 313 assertions (no network; fs writes confined to OS-tmpdir fixtures)
419
+ node test.mjs # 313-assertion baseline + proxy tests (local mocks only; temp filesystem)
388
420
  node sign.test.mjs # ed25519 signing + canon parity
389
421
  node index.mjs # stdio MCP server directly (pipe to MCP client)
390
422
  ```
@@ -396,7 +428,8 @@ Tests verify (14 groups, 313 assertions):
396
428
  - `tokenpull` dedup, window slicing, 4-window pillars (mock adapter)
397
429
  - `tokenpull_submit` all 4 windows POST, sha256 hash, ddmmyy stamp
398
430
  - `tokenpullCodex` io_ratio conversion per-window
399
- - Adapter registry (16 platforms) + per-adapter shape contracts
431
+ - Adapter registry (17 platforms) + per-adapter shape contracts
432
+ - Local proxy: Anthropic/OpenAI JSON + fragmented SSE, live pass-through, secure JSONL, error forwarding
400
433
  - `rank_windows` 4-window paste scoring, partial input, no-network
401
434
  - `watch_tokenpull` cascade snapshot, interval_s, submit path
402
435
  - `enroll` posts identity (public key only), maps 201 enrolled + 410 code_invalid
@@ -412,6 +445,7 @@ Tests verify (14 groups, 313 assertions):
412
445
  | File | Responsibility |
413
446
  | --------------- | ----------------------------------------------------------------------- |
414
447
  | `index.mjs` | Entry point — TTY detection, routes to CLI or MCP server |
448
+ | `proxy.mjs` | Opt-in loopback Anthropic/OpenAI proxy and usage capture |
415
449
  | `cli.mjs` | CLI commands: board, compare, watch, enroll, submit, help |
416
450
  | `tui.mjs` | Full tabbed TUI: Dashboard / Trends / Compare / Board / Watch / Connect |
417
451
  | `cascade.mjs` | Pure cascade math (Υ, SNR, leverage, velocity, 10xDEV, class) |
@@ -46,6 +46,7 @@ import { join, dirname } from "node:path";
46
46
  import { homedir } from "node:os";
47
47
  import { execFile as execFileCb } from "node:child_process";
48
48
  import { promisify } from "node:util";
49
+ import { cachedOmpScan, ompCacheEnabled } from "../omp-cache.mjs";
49
50
 
50
51
  const execFileP = promisify(execFileCb);
51
52
  const DAY_MS = 86_400_000; // shared with tokenpull.mjs but kept local to avoid circular import
@@ -77,7 +78,7 @@ export async function* walkFiles(dir, pred, counter = { n: 0 }, max = 10_000) {
77
78
  }
78
79
  }
79
80
 
80
- const isJsonl = (n) =>
81
+ export const isJsonl = (n) =>
81
82
  n.endsWith(".jsonl") ||
82
83
  n.endsWith(".jsonl.deleted") ||
83
84
  n.match(/\.jsonl\.reset\.\d+$/);
@@ -744,15 +745,16 @@ export const hermesAdapter = {
744
745
 
745
746
  // ── 14. Devin CLI ────────────────────────────────────────────────────────────
746
747
  // SQLite: ~/.local/share/devin/cli/sessions.db
747
- // Native 4-pillar: metrics.input_tokens is FRESH input (does NOT include cache
748
- // read or cache creation — verified against live data where input=3 alongside
749
- // cache_creation_tokens=23500). cache_read_tokens and cache_creation_tokens are
750
- // separate fields (nullable → treat null as 0). output_tokens includes any
751
- // reasoning tokens (no separate reasoning field in metrics). No estimation needed.
748
+ // Same combined-input problem as Codex: input_tokens INCLUDES cache write, so we
749
+ // yield { ts, output, cacheRead, uncached } and let tokenpullCodex() do the
750
+ // ioRatio split (input = output × ioRatio, cacheCreate = uncached − input).
751
+ // ioRatio comes from Claude (Beta = operator's Claude input/output ratio) or
752
+ // the Alpha 2.0 default (matches Codex; the "7:1:2 average → 0.5" note in a
753
+ // prior revision was wrong — tokenpullAny defaults ioRatio to 2.0 for both).
752
754
  export const devinAdapter = {
753
755
  platform: "devin",
754
756
  defaultRoot: () => join(homedir(), ".local", "share", "devin", "cli"),
755
- async *messages(root) {
757
+ async *records(root) {
756
758
  for (const r of roots("DEVIN_HOME", root)) {
757
759
  const dbPath = join(r, "sessions.db");
758
760
  const rows = await sqliteJson(
@@ -761,7 +763,6 @@ export const devinAdapter = {
761
763
  json_extract(chat_message, '$.metadata.metrics.input_tokens') as input_tokens,
762
764
  json_extract(chat_message, '$.metadata.metrics.output_tokens') as output_tokens,
763
765
  json_extract(chat_message, '$.metadata.metrics.cache_read_tokens') as cache_read_tokens,
764
- json_extract(chat_message, '$.metadata.metrics.cache_creation_tokens') as cache_creation_tokens,
765
766
  json_extract(chat_message, '$.metadata.created_at') as created_at
766
767
  FROM message_nodes
767
768
  WHERE json_extract(chat_message, '$.role') = 'assistant'
@@ -770,19 +771,15 @@ export const devinAdapter = {
770
771
  60_000,
771
772
  );
772
773
  for (const row of rows) {
773
- const input = Number(row.input_tokens || 0);
774
+ const inputIncl = Number(row.input_tokens || 0);
775
+ const cached = Number(row.cache_read_tokens || 0);
774
776
  const output = Number(row.output_tokens || 0);
775
- const cacheRead = Number(row.cache_read_tokens || 0);
776
- const cacheCreate = Number(row.cache_creation_tokens || 0);
777
- if (input + output + cacheRead + cacheCreate === 0) continue;
777
+ if (inputIncl + output + cached === 0) continue;
778
778
  yield {
779
- id: `devin:${row.row_id}`,
780
- sid: row.session_id || null,
781
779
  ts: row.created_at || null,
782
- input,
783
780
  output,
784
- cacheCreate,
785
- cacheRead,
781
+ cacheRead: cached,
782
+ uncached: Math.max(0, inputIncl - cached),
786
783
  file: dbPath,
787
784
  };
788
785
  }
@@ -928,8 +925,9 @@ function ompLineCouldMatter(line) {
928
925
  }
929
926
 
930
927
  /** Parse a single omp transcript file and yield usage records.
931
- * Isolated so it can be called concurrently from the bounded pool. */
932
- function* parseOmpFile(text, path) {
928
+ * Isolated so it can be called concurrently from the bounded pool.
929
+ * Exported for omp-cache.mjs and tests. */
930
+ export function* parseOmpFile(text, path) {
933
931
  if (!text) return;
934
932
  let sid = null;
935
933
  for (const line of text.split("\n")) {
@@ -975,35 +973,104 @@ export const ompAdapter = {
975
973
  defaultRoot: () => join(homedir(), ".omp", "agent", "sessions"),
976
974
  async *messages(root) {
977
975
  for (const r of roots("OMP_DATA_DIR", root)) {
978
- // Collect all file paths first (readdir is 0.8% of scan time — negligible)
979
- const paths = [];
980
- for await (const path of walkFiles(r, isJsonl, { n: 0 }, OMP_MAX_FILES)) {
981
- paths.push(path);
976
+ // Stage 2: if SIGRANK_OMP_CACHE is set, use the incremental SQLite cache.
977
+ // The cache wraps the Stage 1 scanner — on a warm run with no changed
978
+ // files, it serves records from SQLite without reading transcripts.
979
+ // Falls back to uncached on any error.
980
+ if (ompCacheEnabled()) {
981
+ yield* cachedOmpScan({
982
+ rootDir: r,
983
+ uncachedScan: async function* () {
984
+ yield* ompUncachedScan(r);
985
+ },
986
+ parseOmpFile,
987
+ readUtf8,
988
+ walkFiles,
989
+ isJsonl,
990
+ maxFiles: OMP_MAX_FILES,
991
+ });
992
+ continue;
982
993
  }
983
994
 
984
- // Bounded-concurrency read+parse: George-RD measured c=8 as optimal
985
- // (1.29x speedup, 45s → 35s). Order-safe: all 504 duplicate keys carry
986
- // byte-identical pillar tuples, so completion order can't change totals.
987
- const concurrency = OMP_CONCURRENCY;
988
- const inflight = new Set();
989
- const queue = [...paths];
995
+ // Default: uncached Stage 1 scan
996
+ yield* ompUncachedScan(r);
997
+ }
998
+ },
999
+ };
990
1000
 
991
- while (queue.length > 0 || inflight.size > 0) {
992
- while (inflight.size < concurrency && queue.length > 0) {
993
- const path = queue.shift();
994
- const p = (async () => {
995
- const text = await readUtf8(path);
996
- return { path, records: [...parseOmpFile(text, path)] };
997
- })();
998
- inflight.add(p);
999
- p.finally(() => inflight.delete(p));
1000
- }
1001
- const done = await Promise.race(inflight);
1002
- inflight.delete(done);
1003
- for (const record of done.records) {
1004
- yield record;
1005
- }
1001
+ /** Stage 1 uncached scan — bounded concurrency + line guard.
1002
+ * Isolated so the cache wrapper can call it as the fallback/parity reference. */
1003
+ async function* ompUncachedScan(rootDir) {
1004
+ // Collect all file paths first (readdir is 0.8% of scan time — negligible)
1005
+ const paths = [];
1006
+ for await (const path of walkFiles(rootDir, isJsonl, { n: 0 }, OMP_MAX_FILES)) {
1007
+ paths.push(path);
1008
+ }
1009
+
1010
+ // Bounded-concurrency read+parse: George-RD measured c=8 as optimal
1011
+ // (1.29x speedup, 45s → 35s). Order-safe: all 504 duplicate keys carry
1012
+ // byte-identical pillar tuples, so completion order can't change totals.
1013
+ const concurrency = OMP_CONCURRENCY;
1014
+ const inflight = new Set();
1015
+ const queue = [...paths];
1016
+
1017
+ while (queue.length > 0 || inflight.size > 0) {
1018
+ while (inflight.size < concurrency && queue.length > 0) {
1019
+ const path = queue.shift();
1020
+ const p = (async () => {
1021
+ const text = await readUtf8(path);
1022
+ return { path, records: [...parseOmpFile(text, path)] };
1023
+ })();
1024
+ inflight.add(p);
1025
+ p.finally(() => inflight.delete(p));
1026
+ }
1027
+ const done = await Promise.race(inflight);
1028
+ inflight.delete(done);
1029
+ for (const record of done.records) {
1030
+ yield record;
1031
+ }
1032
+ }
1033
+ }
1034
+
1035
+ // ── SigRank local API proxy ──────────────────────────────────────────────────
1036
+ // ~/.sigrank-mcp/proxy-sessions.jsonl — one provider-reported usage record per
1037
+ // API call. Native 4-pillar data; OpenAI's inclusive input count is normalized
1038
+ // by proxy.mjs before it reaches this adapter.
1039
+ export const proxyAdapter = {
1040
+ platform: "proxy",
1041
+ defaultRoot: () =>
1042
+ join(homedir(), ".sigrank-mcp", "proxy-sessions.jsonl"),
1043
+ async *messages(root) {
1044
+ const path = root || this.defaultRoot();
1045
+ const text = await readUtf8(path);
1046
+ for (const [ev] of parseJsonl(text, path)) {
1047
+ if (!ev || typeof ev !== "object") continue;
1048
+ const input = Number(ev.input);
1049
+ const output = Number(ev.output);
1050
+ const cacheCreate = Number(ev.cacheCreate);
1051
+ const cacheRead = Number(ev.cacheRead);
1052
+ if (
1053
+ ![input, output, cacheCreate, cacheRead].every(
1054
+ (n) => Number.isFinite(n) && n >= 0,
1055
+ )
1056
+ ) {
1057
+ continue;
1006
1058
  }
1059
+ if (input + output + cacheCreate + cacheRead === 0) continue;
1060
+ yield {
1061
+ // tokenpull() keeps the final record for a duplicate id, which gives
1062
+ // this adapter the requested same-timestamp keep-last behavior.
1063
+ id: typeof ev.ts === "string" && ev.ts ? ev.ts : null,
1064
+ sid: null,
1065
+ ts: typeof ev.ts === "string" ? ev.ts : null,
1066
+ input,
1067
+ output,
1068
+ cacheCreate,
1069
+ cacheRead,
1070
+ model: typeof ev.model === "string" ? ev.model : null,
1071
+ backend: typeof ev.backend === "string" ? ev.backend : null,
1072
+ file: path,
1073
+ };
1007
1074
  }
1008
1075
  },
1009
1076
  };
@@ -1027,6 +1094,7 @@ export const ADAPTERS = {
1027
1094
  devin: devinAdapter,
1028
1095
  other: otherAdapter,
1029
1096
  omp: ompAdapter,
1097
+ proxy: proxyAdapter,
1030
1098
  };
1031
1099
 
1032
1100
  export const ALL_PLATFORMS = Object.keys(ADAPTERS).concat(["claude", "codex"]);
@@ -359,12 +359,9 @@ export async function tokenpullCodex({
359
359
  export async function tokenpullAny(platform, opts = {}) {
360
360
  if (!platform || platform === "claude")
361
361
  return tokenpull({ adapter: claudeAdapter, ...opts });
362
- // Codex: input_tokens includes cache write, so it goes through tokenpullCodex()
363
- // which splits via ioRatio (Beta from Claude, Alpha 2.0). Devin was previously
364
- // routed here too, but its metrics object carries native cache_creation_tokens
365
- // and cache_read_tokens as separate fields with input_tokens as fresh input —
366
- // no estimation needed. Devin now goes through the standard tokenpull() path.
367
- if (platform === "codex") {
362
+ // Codex + Devin: both combine input + cache write in input_tokens, so both go
363
+ // through tokenpullCodex() which splits via ioRatio (Beta from Claude, Alpha 2.0).
364
+ if (platform === "codex" || platform === "devin") {
368
365
  let ioRatio = opts.ioRatio || 2.0;
369
366
  if (!opts.ioRatio) {
370
367
  try {
package/index.mjs CHANGED
@@ -11,6 +11,7 @@
11
11
  * npx sigrank enroll sign in: redeem a connect code from signalaf.com
12
12
  * npx sigrank submit publish your verified runs to the board
13
13
  * npx sigrank board | compare | watch read / publish helpers
14
+ * npx sigrank proxy opt-in local Anthropic/OpenAI usage proxy
14
15
  * npx sigrank --help full reference
15
16
  *
16
17
  * For AI clients (NOT human commands): in a piped/non-TTY context this starts an MCP
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sigrank",
3
- "version": "0.0.195",
3
+ "version": "0.0.197",
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",
@@ -32,6 +32,7 @@
32
32
  },
33
33
  "files": [
34
34
  "index.mjs",
35
+ "proxy.mjs",
35
36
  "lib/",
36
37
  "tools/",
37
38
  "analytics/",
@@ -13,6 +13,8 @@
13
13
  * npx sigrank watch RT tune meter — ALL active platforms × all windows
14
14
  * npx sigrank watch --platform X watch one platform only (optional filter)
15
15
  * npx sigrank watch --window 7d watch one window only (optional filter)
16
+ * npx sigrank proxy opt-in Anthropic/OpenAI usage proxy
17
+ * npx sigrank proxy --port 9000 proxy on a custom loopback port
16
18
  *
17
19
  * Color palette mirrors the SigRank web dark theme:
18
20
  * gold = rank #1 headline
@@ -1585,6 +1587,12 @@ async function showHelp() {
1585
1587
  writeln(
1586
1588
  ` ${cyan("watch --window 7d")} watch only one window (optional filter)`,
1587
1589
  );
1590
+ writeln(
1591
+ ` ${cyan("proxy")} opt-in local Anthropic/OpenAI usage proxy`,
1592
+ );
1593
+ writeln(
1594
+ ` ${cyan("proxy --port 9000")} run the proxy on a custom loopback port`,
1595
+ );
1588
1596
  writeln();
1589
1597
  writeln(` ${bold("Options")}`);
1590
1598
  writeln(
@@ -1595,6 +1603,7 @@ async function showHelp() {
1595
1603
  );
1596
1604
  writeln(` ${dim("--refresh")} poll interval in seconds (default: 30)`);
1597
1605
  writeln(` ${dim("--once")} print once and exit (board only)`);
1606
+ writeln(` ${dim("--port")} proxy port (default: 8787)`);
1598
1607
  writeln();
1599
1608
  writeln(` ${bold("For AI clients (not typeable)")}`);
1600
1609
  writeln(
@@ -1614,7 +1623,39 @@ async function showHelp() {
1614
1623
  writeln(` ${dim("sigrank compare --platform codex")}`);
1615
1624
  writeln(` ${dim("sigrank watch --window 7d --refresh 60")}`);
1616
1625
  writeln(` ${dim("sigrank board --window all --once")}`);
1626
+ writeln(` ${dim("sigrank proxy --port 9000")}`);
1627
+ writeln();
1628
+ }
1629
+
1630
+ async function runProxy({ port = 8787 } = {}) {
1631
+ const { startProxy, defaultProxyLogPath, parseProxyPort } = await import(
1632
+ "../proxy.mjs"
1633
+ );
1634
+ const proxy = await startProxy({ port: parseProxyPort(port) });
1617
1635
  writeln();
1636
+ writeln(`SigRank proxy running on http://localhost:${proxy.port}`);
1637
+ writeln();
1638
+ writeln("Set your tool's API base URL to:");
1639
+ writeln(` Anthropic: http://localhost:${proxy.port}`);
1640
+ writeln(` OpenAI: http://localhost:${proxy.port}`);
1641
+ writeln();
1642
+ writeln(`Usage data will be captured to ${defaultProxyLogPath()}`);
1643
+ writeln();
1644
+ writeln(
1645
+ dim("The proxy is active only while this command is running. Press Ctrl-C to stop."),
1646
+ );
1647
+
1648
+ let stopping = false;
1649
+ const stop = async () => {
1650
+ if (stopping) return;
1651
+ stopping = true;
1652
+ await proxy.close();
1653
+ writeln("\nSigRank proxy stopped.");
1654
+ process.exit(0);
1655
+ };
1656
+ process.once("SIGINT", stop);
1657
+ process.once("SIGTERM", stop);
1658
+ return proxy;
1618
1659
  }
1619
1660
 
1620
1661
  // ── ENTRY POINT ───────────────────────────────────────────────────────────────
@@ -1790,6 +1831,7 @@ export async function runCli(argv) {
1790
1831
  "output",
1791
1832
  "cache-read",
1792
1833
  "combined-input",
1834
+ "port",
1793
1835
  ]);
1794
1836
  const flags = {};
1795
1837
  for (let i = 1; i < args.length; i++) {
@@ -1845,6 +1887,8 @@ export async function runCli(argv) {
1845
1887
  refresh: Number(flags.refresh) || 30,
1846
1888
  submit: flags.submit === true || flags.submit === "true",
1847
1889
  });
1890
+ } else if (cmd === "proxy") {
1891
+ await runProxy({ port: flags.port ?? 8787 });
1848
1892
  } else if (cmd === "enroll") {
1849
1893
  await runEnroll({ label: flags.label });
1850
1894
  // `review` was removed from the public CLI (owner decision 2026-07-16):
@@ -1,11 +1,20 @@
1
1
  /**
2
2
  * narrate.mjs — deterministic prose "card" for a cascade result.
3
3
  *
4
- * Port of _template() from ~/Desktop/moses-sigrank/narrate.py. The model path
5
- * (MiniCPM4-0.5B) is intentionally SKIPPED: the template is the trustworthy,
6
- * instant, auditable fallback — same numbers in → same card out, and it can never
7
- * emit a metric the cascade didn't produce. A model hook can layer behind this same
8
- * narrate() interface later without a rewrite.
4
+ * Uses the 10 build archetype composition classifier, synced with the app's
5
+ * lib/analytics/build-archetypes.ts. Every operator lands in exactly one
6
+ * archetype that describes their operating shape — not their rank.
7
+ *
8
+ * Classification precedence (first match wins):
9
+ * 1. CONVERGENT — P80+ on all 3 axes (leverage + velocity + construction)
10
+ * 2. KINETIC — velocity >= 0.80
11
+ * 3. Construction — construction >= 0.02 (BUILDER / RECURSIVE / AMPLIFIER)
12
+ * 4. Reuse depth — else (INPUT-BOUND / PRIMING / CONTEXTUAL / DEEP READER / ARCHIVIST)
13
+ *
14
+ * The three derived dimensions:
15
+ * leverage = cache_read / input
16
+ * velocity = output / input
17
+ * construction = cache_write / cache_read
9
18
  *
10
19
  * Token-only. No network, no randomness.
11
20
  */
@@ -26,24 +35,158 @@ const plain = (n, dec) => {
26
35
  return v !== null ? v.toFixed(dec) : "—";
27
36
  };
28
37
 
38
+ // P80 thresholds calibrated from HCM cut (1,586 operators).
39
+ const CONVERGENT_T = { levP80: 74.6, velP80: 0.34, constrP80: 0.0431 };
40
+ const VEL_KINETIC = 0.8;
41
+ const LEV_INPUT_BOUND = 5;
42
+ const LEV_PRIMING = 10;
43
+ const LEV_CONTEXTUAL = 15;
44
+ const LEV_DEEP_READER = 23;
45
+ const CONSTR_ACTIVE = 0.02;
46
+ const LEV_BUILDER = 30;
47
+ const LEV_RECURSIVE = 50;
48
+
49
+ /** Classify an operator's cascade into one of 10 build archetypes.
50
+ * Returns { key, name, family, familyLabel, blurb }. */
51
+ export function classifyArchetype(cascade) {
52
+ const v = safeNum(cascade.velocity);
53
+ const l = safeNum(cascade.leverage);
54
+ const cw = cascade.pillars ? Number(cascade.pillars.cacheCreate) : NaN;
55
+ const cr = cascade.pillars ? Number(cascade.pillars.cacheRead) : NaN;
56
+ const input = cascade.pillars ? Number(cascade.pillars.input) : NaN;
57
+
58
+ // Derive construction = cache_write / cache_read
59
+ const constr = Number.isFinite(cr) && cr > 0 && Number.isFinite(cw) ? cw / cr : 0;
60
+ const lev = l ?? 0;
61
+ const vel = v ?? 0;
62
+
63
+ // 1. CONVERGENT — all three axes elevated (P80+)
64
+ if (
65
+ lev > CONVERGENT_T.levP80 &&
66
+ vel > CONVERGENT_T.velP80 &&
67
+ constr > CONVERGENT_T.constrP80
68
+ ) {
69
+ return {
70
+ key: "convergent",
71
+ name: "CONVERGENT",
72
+ family: "convergence",
73
+ familyLabel: "Convergence",
74
+ blurb:
75
+ "Deep reuse, active construction, and high generation rise together. A rare composition where all three operating axes are elevated without the usual tradeoffs.",
76
+ };
77
+ }
78
+
79
+ // 2. KINETIC — generation breakout
80
+ if (vel >= VEL_KINETIC) {
81
+ return {
82
+ key: "kinetic",
83
+ name: "KINETIC",
84
+ family: "generation",
85
+ familyLabel: "Generation",
86
+ blurb:
87
+ "Generation has broken out. Output approaches or exceeds fresh input, making transmission the defining feature of the composition.",
88
+ };
89
+ }
90
+
91
+ // 3. Construction branch — active context construction
92
+ if (constr >= CONSTR_ACTIVE) {
93
+ if (lev >= LEV_RECURSIVE) {
94
+ return {
95
+ key: "amplifier",
96
+ name: "AMPLIFIER",
97
+ family: "construction",
98
+ familyLabel: "Active Construction",
99
+ blurb:
100
+ "Deep reuse and active construction are operating together at scale. Existing context produces new work that expands the context available for future cycles.",
101
+ };
102
+ }
103
+ if (lev >= LEV_BUILDER) {
104
+ return {
105
+ key: "recursive",
106
+ name: "RECURSIVE",
107
+ family: "construction",
108
+ familyLabel: "Active Construction",
109
+ blurb:
110
+ "New context is being built on top of an already substantial reusable base. Construction and reuse are now feeding the same operating loop.",
111
+ };
112
+ }
113
+ return {
114
+ key: "builder",
115
+ name: "BUILDER",
116
+ family: "construction",
117
+ familyLabel: "Active Construction",
118
+ blurb:
119
+ "Active context construction has begun. The system is creating material for future reuse while leverage is still developing.",
120
+ };
121
+ }
122
+
123
+ // 4. Reuse depth branch — passive (construction < 0.02)
124
+ if (lev >= LEV_DEEP_READER) {
125
+ return {
126
+ key: "archivist",
127
+ name: "ARCHIVIST",
128
+ family: "reuse",
129
+ familyLabel: "Reuse Depth",
130
+ blurb:
131
+ "Extreme reuse of accumulated context. A deep context library carries the system while new construction remains limited.",
132
+ };
133
+ }
134
+ if (lev >= LEV_CONTEXTUAL) {
135
+ return {
136
+ key: "deep-reader",
137
+ name: "DEEP READER",
138
+ family: "reuse",
139
+ familyLabel: "Reuse Depth",
140
+ blurb:
141
+ "Strong accumulated context is carrying the workflow. The operator draws deeply from retained context while creating relatively little new context.",
142
+ };
143
+ }
144
+ if (lev >= LEV_PRIMING) {
145
+ return {
146
+ key: "contextual",
147
+ name: "CONTEXTUAL",
148
+ family: "reuse",
149
+ familyLabel: "Reuse Depth",
150
+ blurb:
151
+ "Retained context is now materially supporting the workflow. Reuse is established, while active construction remains limited.",
152
+ };
153
+ }
154
+ if (lev >= LEV_INPUT_BOUND) {
155
+ return {
156
+ key: "priming",
157
+ name: "PRIMING",
158
+ family: "reuse",
159
+ familyLabel: "Reuse Depth",
160
+ blurb:
161
+ "Reuse is beginning to form. Prior context is returning, but the system has not yet developed deep leverage.",
162
+ };
163
+ }
164
+ return {
165
+ key: "input-bound",
166
+ name: "INPUT-BOUND",
167
+ family: "reuse",
168
+ familyLabel: "Reuse Depth",
169
+ blurb:
170
+ "Fresh input still carries most of the workload. Little prior context is returning, so each cycle depends heavily on new input.",
171
+ };
172
+ }
173
+
29
174
  /**
30
175
  * Given a cascade result ({ velocity, leverage, dev10x, pillars, class }) and an
31
- * optional subject name, return "**CLASS.** <one or two sentences>". Deterministic.
176
+ * optional subject name, return "**CLASS.** <archetype blurb with context>".
177
+ * Deterministic — same numbers in → same card out.
32
178
  */
33
179
  export function narrate(cascade, name = "This operator") {
34
180
  const klass = cascade.class || cascade.klass || "UNCLASSED";
35
181
  const v = safeNum(cascade.velocity);
36
182
  const l = safeNum(cascade.leverage);
37
-
38
- // "non-compounding" = a stateless pipe: no cache commits, so the cascade can't
39
- // form. cascade.mjs leaves dev10x null when cacheCreate is 0 (the cw/o term
40
- // collapses), which is exactly metrics.py's non_compounding flag.
41
- // Also catches zero-input sessions where velocity/leverage are null.
42
183
  const cw = cascade.pillars ? Number(cascade.pillars.cacheCreate) : NaN;
184
+
185
+ // Handle non-compounding (stateless pipe) as a special case before the
186
+ // archetype classifier — cascade.mjs leaves dev10x null when cacheCreate is 0.
43
187
  const nonCompounding =
44
188
  cascade.dev10x == null || !(cw > 0) || v === null || l === null;
45
189
 
46
- let body;
47
190
  if (nonCompounding) {
48
191
  const leverageStr =
49
192
  l !== null
@@ -53,25 +196,24 @@ export function narrate(cascade, name = "This operator") {
53
196
  cascade.dev10x == null
54
197
  ? " 10xDEV is undefined — the compounding loop has not formed yet."
55
198
  : "";
56
- body =
199
+ const body =
57
200
  `${name} runs a stateless pipe — no cache commits, so the cascade can't form. ` +
58
201
  `High read volume, but nothing is being built forward. ${leverageStr}${dev10xNote}`;
59
- } else if (v >= 1 && l >= 100) {
60
- body =
61
- `${name} holds both axes at once: ${plain(v, 1)}x generation AND ${comma(l, 0)}x memory leverage. ` +
62
- `A closed kinetic loop — the rare operator the leverage/generation tradeoff says shouldn't exist.`;
63
- } else if (l >= 10 && v < 1) {
64
- body =
65
- `${name} is an archival sponge — ${comma(l, 0)}x reuse but only ${plain(v, 2)}x generation. ` +
66
- `Holds context beautifully, executes little with it. The reuse number is inflated by a weak commitment stage.`;
67
- } else if (v >= 0.8 && l < 2) {
68
- body =
69
- `${name} is a volatile ingestor — ${plain(v, 2)}x generation but ${plain(l, 1)}x leverage. ` +
70
- `Fast on single shots, resets between turns. Memory doesn't persist into a compounding loop.`;
71
- } else {
72
- body =
73
- `${name} sits low on both axes: ${plain(v, 2)}x generation, ${plain(l, 1)}x leverage. ` +
74
- `A transient profile — neither building state nor converting input to output efficiently.`;
202
+ return `**${klass}.** ${body}`;
75
203
  }
204
+
205
+ // Classify into one of 10 build archetypes
206
+ const arch = classifyArchetype(cascade);
207
+
208
+ // Build the card with archetype name, family, and context-specific numbers
209
+ const levStr = l !== null ? `${comma(l, 0)}x leverage` : "undefined leverage";
210
+ const velStr = v !== null ? `${plain(v, 2)}x generation` : "undefined generation";
211
+ const cr = cascade.pillars ? Number(cascade.pillars.cacheRead) : NaN;
212
+ const constrStr = `construction ${plain(Number.isFinite(cr) && cr > 0 ? cw / cr : 0, 4)}`;
213
+
214
+ const body =
215
+ `${name} is ${arch.name} — ${arch.blurb} ` +
216
+ `${levStr}, ${velStr}, ${constrStr}.`;
217
+
76
218
  return `**${klass}.** ${body}`;
77
219
  }
@@ -1657,6 +1657,7 @@ async function renderWatch(platform = "all", win = "all-windows") {
1657
1657
  "openclaw",
1658
1658
  "pi",
1659
1659
  "omp",
1660
+ "proxy",
1660
1661
  ];
1661
1662
  const platFilter = platform && platform !== "all" ? platform : null;
1662
1663
  const winFilter = win && win !== "all-windows" ? win : null;
package/proxy.mjs ADDED
@@ -0,0 +1,492 @@
1
+ /**
2
+ * Local, opt-in API proxy for provider-reported token usage.
3
+ *
4
+ * The proxy is inert until `sigrank proxy` starts it. It binds to loopback,
5
+ * forwards request/response bytes, and persists usage metadata only — never
6
+ * prompts, API keys, response text, or tool calls.
7
+ */
8
+
9
+ import http from "node:http";
10
+ import https from "node:https";
11
+ import { appendFile, chmod, mkdir } from "node:fs/promises";
12
+ import { homedir } from "node:os";
13
+ import { dirname, join } from "node:path";
14
+
15
+ const DEFAULT_HOST = "127.0.0.1";
16
+ const DEFAULT_PORT = 8787;
17
+ const DEFAULT_UPSTREAMS = Object.freeze({
18
+ anthropic: "https://api.anthropic.com",
19
+ openai: "https://api.openai.com",
20
+ });
21
+
22
+ const HOP_BY_HOP_HEADERS = new Set([
23
+ "connection",
24
+ "keep-alive",
25
+ "proxy-authenticate",
26
+ "proxy-authorization",
27
+ "te",
28
+ "trailer",
29
+ "transfer-encoding",
30
+ "upgrade",
31
+ ]);
32
+
33
+ export function defaultProxyLogPath() {
34
+ return join(homedir(), ".sigrank-mcp", "proxy-sessions.jsonl");
35
+ }
36
+
37
+ export function parseProxyPort(value, { allowZero = false } = {}) {
38
+ const port = Number(value);
39
+ const min = allowZero ? 0 : 1;
40
+ if (!Number.isInteger(port) || port < min || port > 65_535) {
41
+ throw new Error(`Invalid proxy port "${value}" (expected ${min}-${65_535})`);
42
+ }
43
+ return port;
44
+ }
45
+
46
+ function routeFor(pathname) {
47
+ if (pathname === "/v1/messages") {
48
+ return { backend: "anthropic", endpoint: "messages" };
49
+ }
50
+ if (pathname === "/v1/chat/completions") {
51
+ return { backend: "openai", endpoint: "chat-completions" };
52
+ }
53
+ if (pathname === "/v1/responses") {
54
+ return { backend: "openai", endpoint: "responses" };
55
+ }
56
+ return null;
57
+ }
58
+
59
+ function finiteTokenCount(value) {
60
+ const n = Number(value);
61
+ return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0;
62
+ }
63
+
64
+ function usageFields(backend, usage) {
65
+ if (!usage || typeof usage !== "object") return null;
66
+ if (backend === "anthropic") {
67
+ return {
68
+ input: finiteTokenCount(usage.input_tokens),
69
+ output: finiteTokenCount(usage.output_tokens),
70
+ cacheRead: finiteTokenCount(usage.cache_read_input_tokens),
71
+ cacheCreate: finiteTokenCount(usage.cache_creation_input_tokens),
72
+ };
73
+ }
74
+
75
+ const totalInput = finiteTokenCount(
76
+ usage.prompt_tokens ?? usage.input_tokens,
77
+ );
78
+ const cacheRead = finiteTokenCount(
79
+ usage.prompt_tokens_details?.cached_tokens ??
80
+ usage.input_tokens_details?.cached_tokens,
81
+ );
82
+ return {
83
+ // OpenAI's prompt/input total includes cached tokens. SigRank's input pillar
84
+ // is fresh input, so subtract the separately reported cache read amount.
85
+ input: Math.max(0, totalInput - cacheRead),
86
+ output: finiteTokenCount(
87
+ usage.completion_tokens ?? usage.output_tokens,
88
+ ),
89
+ cacheRead,
90
+ cacheCreate: 0,
91
+ };
92
+ }
93
+
94
+ function mergeDefinedUsage(target, source) {
95
+ if (!source || typeof source !== "object") return false;
96
+ let found = false;
97
+ for (const key of [
98
+ "input_tokens",
99
+ "output_tokens",
100
+ "cache_read_input_tokens",
101
+ "cache_creation_input_tokens",
102
+ ]) {
103
+ if (source[key] != null) {
104
+ target[key] = source[key];
105
+ found = true;
106
+ }
107
+ }
108
+ return found;
109
+ }
110
+
111
+ function createUsageTracker({ backend, endpoint, requestModel, warn }) {
112
+ let model = requestModel || null;
113
+ let usage = null;
114
+ const anthropicUsage = {};
115
+
116
+ function observe(payload) {
117
+ if (!payload || typeof payload !== "object") return;
118
+
119
+ if (backend === "anthropic") {
120
+ model = payload.message?.model || payload.model || model;
121
+ const next = payload.message?.usage || payload.usage;
122
+ if (mergeDefinedUsage(anthropicUsage, next)) usage = anthropicUsage;
123
+ return;
124
+ }
125
+
126
+ if (endpoint === "responses") {
127
+ const response = payload.response || payload;
128
+ model = response.model || model;
129
+ if (response.usage && typeof response.usage === "object") {
130
+ usage = response.usage;
131
+ }
132
+ return;
133
+ }
134
+
135
+ model = payload.model || model;
136
+ if (payload.usage && typeof payload.usage === "object") {
137
+ usage = payload.usage;
138
+ }
139
+ }
140
+
141
+ function observeJson(text) {
142
+ try {
143
+ observe(JSON.parse(text));
144
+ } catch (error) {
145
+ warn(`[proxy] usage parse warning: ${error.message}`);
146
+ }
147
+ }
148
+
149
+ function result() {
150
+ const fields = usageFields(backend, usage);
151
+ return fields ? { model: model || "unknown", ...fields } : null;
152
+ }
153
+
154
+ return { observe, observeJson, result };
155
+ }
156
+
157
+ /** Incremental SSE parser. Network chunks are not assumed to align with events. */
158
+ function createSseInspector(onData, warn) {
159
+ const decoder = new TextDecoder();
160
+ let buffer = "";
161
+
162
+ function parseFrame(frame) {
163
+ const data = frame
164
+ .split(/\r?\n/)
165
+ .filter((line) => line.startsWith("data:"))
166
+ .map((line) => line.slice(5).replace(/^ /, ""))
167
+ .join("\n");
168
+ if (!data || data === "[DONE]") return;
169
+ try {
170
+ onData(JSON.parse(data));
171
+ } catch (error) {
172
+ warn(`[proxy] SSE usage parse warning: ${error.message}`);
173
+ }
174
+ }
175
+
176
+ function drain(final = false) {
177
+ let match;
178
+ while ((match = /\r?\n\r?\n/.exec(buffer))) {
179
+ const frame = buffer.slice(0, match.index);
180
+ buffer = buffer.slice(match.index + match[0].length);
181
+ parseFrame(frame);
182
+ }
183
+ if (final && buffer.trim()) {
184
+ parseFrame(buffer);
185
+ buffer = "";
186
+ }
187
+ }
188
+
189
+ return {
190
+ push(chunk) {
191
+ buffer += decoder.decode(chunk, { stream: true });
192
+ drain(false);
193
+ },
194
+ end() {
195
+ buffer += decoder.decode();
196
+ drain(true);
197
+ },
198
+ };
199
+ }
200
+
201
+ function sanitizeRequestHeaders(headers, bodyLength) {
202
+ const out = { ...headers };
203
+ for (const name of HOP_BY_HOP_HEADERS) delete out[name];
204
+ delete out.host;
205
+ // Usage inspection operates on the raw response. Asking upstream for identity
206
+ // encoding avoids buffering/decompressing streamed provider responses.
207
+ out["accept-encoding"] = "identity";
208
+ out["content-length"] = String(bodyLength);
209
+ return out;
210
+ }
211
+
212
+ function sanitizeResponseHeaders(headers) {
213
+ const out = { ...headers };
214
+ for (const name of HOP_BY_HOP_HEADERS) delete out[name];
215
+ return out;
216
+ }
217
+
218
+ function prepareRequestBody(route, rawBody, injectOpenAIUsage, warn) {
219
+ let json = null;
220
+ try {
221
+ json = JSON.parse(rawBody.toString("utf8"));
222
+ } catch {
223
+ return { body: rawBody, json: null };
224
+ }
225
+
226
+ if (
227
+ injectOpenAIUsage &&
228
+ route.endpoint === "chat-completions" &&
229
+ json?.stream === true
230
+ ) {
231
+ json.stream_options = {
232
+ ...(json.stream_options || {}),
233
+ include_usage: true,
234
+ };
235
+ try {
236
+ return { body: Buffer.from(JSON.stringify(json)), json };
237
+ } catch (error) {
238
+ warn(`[proxy] request usage-option warning: ${error.message}`);
239
+ }
240
+ }
241
+ return { body: rawBody, json };
242
+ }
243
+
244
+ function createUsageWriter(logPath, logger) {
245
+ let queue = Promise.resolve();
246
+ const directory = dirname(logPath);
247
+
248
+ async function ensureDirectory() {
249
+ await mkdir(directory, { recursive: true, mode: 0o700 });
250
+ await chmod(directory, 0o700).catch(() => {});
251
+ }
252
+
253
+ function append(record) {
254
+ queue = queue
255
+ .then(async () => {
256
+ await ensureDirectory();
257
+ await appendFile(logPath, `${JSON.stringify(record)}\n`, {
258
+ encoding: "utf8",
259
+ mode: 0o600,
260
+ });
261
+ await chmod(logPath, 0o600).catch(() => {});
262
+ })
263
+ .catch((error) => {
264
+ logger.warn(`[proxy] usage log warning: ${error.message}`);
265
+ });
266
+ return queue;
267
+ }
268
+
269
+ return { ensureDirectory, append, flush: () => queue };
270
+ }
271
+
272
+ async function readRequestBody(req) {
273
+ const chunks = [];
274
+ for await (const chunk of req) {
275
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
276
+ }
277
+ return Buffer.concat(chunks);
278
+ }
279
+
280
+ function formatUsage(fields) {
281
+ return `input=${fields.input} output=${fields.output} cacheRead=${fields.cacheRead} cacheCreate=${fields.cacheCreate}`;
282
+ }
283
+
284
+ /**
285
+ * Start the opt-in loopback proxy.
286
+ *
287
+ * Test hooks (`upstreams`, `logPath`, `port: 0`, and `logger`) keep tests local;
288
+ * the CLI uses fixed provider origins and port 8787 by default.
289
+ */
290
+ export async function startProxy({
291
+ host = DEFAULT_HOST,
292
+ port = DEFAULT_PORT,
293
+ logPath = defaultProxyLogPath(),
294
+ upstreams = DEFAULT_UPSTREAMS,
295
+ injectOpenAIUsage = true,
296
+ logger = console,
297
+ } = {}) {
298
+ if (host !== "127.0.0.1" && host !== "::1" && host !== "localhost") {
299
+ throw new Error("SigRank proxy must bind to a loopback host");
300
+ }
301
+ port = parseProxyPort(port, { allowZero: true });
302
+ const writer = createUsageWriter(logPath, logger);
303
+ await writer.ensureDirectory();
304
+ let lastCaptureMs = 0;
305
+ const nextCaptureTimestamp = () => {
306
+ // The adapter intentionally deduplicates by timestamp. Keep timestamps
307
+ // monotonic so distinct responses completing within one millisecond cannot
308
+ // collapse into one call.
309
+ lastCaptureMs = Math.max(Date.now(), lastCaptureMs + 1);
310
+ return new Date(lastCaptureMs).toISOString();
311
+ };
312
+
313
+ const server = http.createServer(async (req, res) => {
314
+ let parsedUrl;
315
+ try {
316
+ parsedUrl = new URL(req.url || "/", `http://${req.headers.host || host}`);
317
+ } catch {
318
+ res.writeHead(400, { "content-type": "text/plain; charset=utf-8" });
319
+ res.end("Bad request URL\n");
320
+ return;
321
+ }
322
+
323
+ const route = routeFor(parsedUrl.pathname);
324
+ if (!route) {
325
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
326
+ res.end("SigRank proxy supports /v1/messages, /v1/chat/completions, and /v1/responses\n");
327
+ return;
328
+ }
329
+ if (req.method !== "POST") {
330
+ res.writeHead(405, {
331
+ allow: "POST",
332
+ "content-type": "text/plain; charset=utf-8",
333
+ });
334
+ res.end("Method not allowed\n");
335
+ return;
336
+ }
337
+
338
+ let rawBody;
339
+ try {
340
+ rawBody = await readRequestBody(req);
341
+ } catch (error) {
342
+ if (!res.headersSent) {
343
+ res.writeHead(400, { "content-type": "text/plain; charset=utf-8" });
344
+ }
345
+ res.end("Could not read request body\n");
346
+ logger.warn(`[proxy] request read warning: ${error.message}`);
347
+ return;
348
+ }
349
+
350
+ const prepared = prepareRequestBody(
351
+ route,
352
+ rawBody,
353
+ injectOpenAIUsage,
354
+ logger.warn.bind(logger),
355
+ );
356
+ const upstreamBase = upstreams[route.backend];
357
+ if (!upstreamBase) {
358
+ res.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
359
+ res.end(`No upstream configured for ${route.backend}\n`);
360
+ return;
361
+ }
362
+
363
+ const upstreamUrl = new URL(req.url, upstreamBase);
364
+ const transport = upstreamUrl.protocol === "http:" ? http : https;
365
+ const tracker = createUsageTracker({
366
+ backend: route.backend,
367
+ endpoint: route.endpoint,
368
+ requestModel: prepared.json?.model,
369
+ warn: logger.warn.bind(logger),
370
+ });
371
+
372
+ const upstreamReq = transport.request(
373
+ upstreamUrl,
374
+ {
375
+ method: "POST",
376
+ headers: sanitizeRequestHeaders(req.headers, prepared.body.length),
377
+ },
378
+ (upstreamRes) => {
379
+ const statusCode = upstreamRes.statusCode || 502;
380
+ const headers = sanitizeResponseHeaders(upstreamRes.headers);
381
+ res.writeHead(statusCode, headers);
382
+
383
+ const contentType = String(upstreamRes.headers["content-type"] || "");
384
+ const contentEncoding = String(
385
+ upstreamRes.headers["content-encoding"] || "identity",
386
+ );
387
+ const inspect = statusCode >= 200 && statusCode < 300;
388
+ const isSse = inspect && contentType.includes("text/event-stream");
389
+ const canInspect = contentEncoding === "identity";
390
+ const jsonChunks = [];
391
+ const inspector =
392
+ isSse && canInspect
393
+ ? createSseInspector(tracker.observe, logger.warn.bind(logger))
394
+ : null;
395
+
396
+ upstreamRes.on("data", (chunk) => {
397
+ // Forward first, inspect second: parsing never holds back streamed tokens.
398
+ if (!res.write(chunk)) {
399
+ upstreamRes.pause();
400
+ res.once("drain", () => upstreamRes.resume());
401
+ }
402
+ if (inspector) inspector.push(chunk);
403
+ else if (inspect && canInspect) jsonChunks.push(Buffer.from(chunk));
404
+ });
405
+
406
+ upstreamRes.on("end", () => {
407
+ if (inspector) inspector.end();
408
+ else if (inspect && canInspect && jsonChunks.length) {
409
+ tracker.observeJson(Buffer.concat(jsonChunks).toString("utf8"));
410
+ }
411
+
412
+ const fields = tracker.result();
413
+ if (fields) {
414
+ writer.append({
415
+ ts: nextCaptureTimestamp(),
416
+ backend: route.backend,
417
+ model: fields.model,
418
+ input: fields.input,
419
+ output: fields.output,
420
+ cacheRead: fields.cacheRead,
421
+ cacheCreate: fields.cacheCreate,
422
+ });
423
+ logger.log(
424
+ `[proxy] ${statusCode} POST ${parsedUrl.pathname} → ${route.backend} (${formatUsage(fields)})`,
425
+ );
426
+ } else {
427
+ logger.log(
428
+ `[proxy] ${statusCode} POST ${parsedUrl.pathname} → ${route.backend}`,
429
+ );
430
+ }
431
+ res.end();
432
+ });
433
+
434
+ upstreamRes.on("error", (error) => {
435
+ logger.warn(`[proxy] upstream response warning: ${error.message}`);
436
+ if (!res.writableEnded) res.destroy(error);
437
+ });
438
+ },
439
+ );
440
+
441
+ res.on("close", () => {
442
+ if (!res.writableEnded) upstreamReq.destroy();
443
+ });
444
+ upstreamReq.on("error", (error) => {
445
+ logger.warn(`[proxy] upstream request warning: ${error.message}`);
446
+ if (!res.headersSent) {
447
+ res.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
448
+ res.end("Upstream request failed\n");
449
+ } else if (!res.writableEnded) {
450
+ res.destroy(error);
451
+ }
452
+ });
453
+ upstreamReq.end(prepared.body);
454
+ });
455
+
456
+ server.on("clientError", (error, socket) => {
457
+ logger.warn(`[proxy] client warning: ${error.message}`);
458
+ if (socket.writable) socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
459
+ });
460
+
461
+ await new Promise((resolve, reject) => {
462
+ const onError = (error) => {
463
+ server.off("listening", onListening);
464
+ reject(error);
465
+ };
466
+ const onListening = () => {
467
+ server.off("error", onError);
468
+ resolve();
469
+ };
470
+ server.once("error", onError);
471
+ server.once("listening", onListening);
472
+ server.listen(port, host);
473
+ });
474
+
475
+ const address = server.address();
476
+ const listeningPort = typeof address === "object" ? address.port : port;
477
+ return {
478
+ server,
479
+ host,
480
+ port: listeningPort,
481
+ url: `http://${host}:${listeningPort}`,
482
+ logPath,
483
+ async close() {
484
+ if (server.listening) {
485
+ await new Promise((resolve, reject) =>
486
+ server.close((error) => (error ? reject(error) : resolve())),
487
+ );
488
+ }
489
+ await writer.flush();
490
+ },
491
+ };
492
+ }
@@ -9,9 +9,9 @@ of truth. The TUI, CLI, MCP output schemas, and this doc all read from that
9
9
  one list.
10
10
 
11
11
  The class is an **experience** axis — it measures how much volume the operator
12
- has accumulated. It is separate from the **cascade archetype** (the operator's
13
- token-flow shape: kinetic loop, archival sponge, volatile ingestor, etc.) and
14
- from the **TRANSMITTER peak badge** (a temporary state — see below).
12
+ has accumulated. It is separate from the **build archetype** (the operator's
13
+ composition shape: CONVERGENT, KINETIC, INPUT-BOUND, ARCHIVIST, AMPLIFIER, etc.)
14
+ and from the **TRANSMITTER peak badge** (a temporary state — see below).
15
15
 
16
16
  A 25th value, **UNCLASSED**, is returned when total tokens are null or
17
17
  non-finite (empty / all-zero session). It is distinct from IGNITER III so
@@ -10,7 +10,11 @@ SigRank is a public AI-operator leaderboard. This policy summarizes what data we
10
10
 
11
11
  ## What we never collect
12
12
 
13
- We do **not** read or transmit the content of your prompts or AI conversations. The local agent extracts only numeric token counts and metadata; transcripts never leave your device.
13
+ We do **not** collect or receive the content of your prompts or AI conversations
14
+ at the SigRank service. Local-log adapters extract only numeric usage metadata.
15
+ If you explicitly run the optional local API proxy, it forwards provider-bound
16
+ requests and responses in memory but does not persist their content; only usage
17
+ metadata is written locally or submitted to SigRank.
14
18
 
15
19
  ## Consent
16
20
 
@@ -10,17 +10,24 @@
10
10
 
11
11
  That's the entire payload — no prompts, no code, no file contents, no conversation text. The MCP parses your input locally and sends only the four token counts to the server, which re-parses and re-scores them authoritatively.
12
12
 
13
+ The optional `sigrank proxy` is a local transport path to Anthropic/OpenAI, not a
14
+ submission path to SigRank. When explicitly enabled, it necessarily receives and
15
+ forwards provider-bound API keys, prompts, tool calls, and responses in memory.
16
+ It does not persist that content or send it to the SigRank service; it appends
17
+ only provider-reported token counts, model/backend metadata, and timestamps to
18
+ `~/.sigrank-mcp/proxy-sessions.jsonl`.
19
+
13
20
  - **Signed path** (`submit_verified`, `watch_tokenpull` with `submit:true`): the four numbers travel with the device's public key, codename, window, and an ed25519 signature. The board verifies the signature without seeing your data.
14
21
  - **Paste path** (`submit_paste`, `tokenpull_submit`): the MCP parses your pasted token counts locally, then sends only the four canonical numbers to the server's web-paste endpoint. Even if you paste mixed text (prose + numbers), only the extracted token counts are transmitted — the raw text never leaves your machine.
15
22
 
16
23
  ## How it works
17
24
 
18
- 1. **Local-first:** All token pulling happens on your machine. SigRank reads session logs from ~/.claude, ~/.codex, ~/.local/share/amp, etc.
19
- 2. **Token counts only:** The MCP tools extract integer counts from log metadata. The actual content of your conversations is never read, parsed, or transmitted. On the paste path, the MCP parses the paste locally and sends only the four extracted numbers — the raw paste text stays on your machine.
25
+ 1. **Local-first:** All token pulling happens on your machine. SigRank reads session logs from ~/.claude, ~/.codex, ~/.local/share/amp, etc. The optional proxy also runs only on loopback and only when manually started.
26
+ 2. **Token-only persistence:** Local-log adapters extract integer counts from log metadata without reading conversation content. The optional proxy forwards request/response bytes transiently but persists only usage metadata. On the paste path, the MCP parses the paste locally and sends only the four extracted numbers — the raw paste text stays on your machine.
20
27
  3. **Signed submission:** Ranked submissions (`submit_verified`, `watch_tokenpull` with `submit:true`) are ed25519-signed with a device-bound key generated locally at enrollment. The board verifies the signature without seeing your data. Paste submissions (`submit_paste`, `tokenpull_submit`) are unsigned and go through the web-paste endpoint with a codename only — but still send only the four token counts, not the raw paste.
21
28
  4. **Read tools need no auth:** No API keys, no OAuth, no account needed to read the leaderboard or operator profiles. **Enrollment requires a connect code** from signalaf.com → Settings → New key (the code binds your device's public key to your operator server-side); a codename alone is not enough for the signed/ranked path.
22
29
 
23
- ## What SigRank can NOT see
30
+ ## What the SigRank service can NOT see
24
31
 
25
32
  - Your prompts or messages
26
33
  - Your code or file contents
@@ -28,6 +35,10 @@ That's the entire payload — no prompts, no code, no file contents, no conversa
28
35
  - Which AI platform you use (beyond token counts)
29
36
  - Your identity (only your chosen codename)
30
37
 
38
+ The local proxy process is different from the SigRank service: if you opt in, it
39
+ handles your provider traffic in memory solely to forward it to Anthropic or
40
+ OpenAI. That content is never stored in the proxy JSONL file.
41
+
31
42
  ## Verification
32
43
 
33
44
  The submit_verified tool uses ed25519 signing. The board's source_attestations table records the signature for audit. You can verify your own submissions via get_operator.