sigrank 0.0.196 → 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 +37 -3
- package/adapters/index.mjs +112 -57
- package/adapters/tokenpull.mjs +3 -6
- package/index.mjs +1 -0
- package/package.json +2 -1
- package/presentation/cli.mjs +44 -0
- package/presentation/narrate.mjs +171 -29
- package/presentation/tui.mjs +1 -0
- package/proxy.mjs +492 -0
- package/resources/class-tiers.md +3 -3
- package/resources/data-policy.md +5 -1
- package/resources/privacy-model.md +14 -3
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
|
|
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 #
|
|
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 (
|
|
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) |
|
package/adapters/index.mjs
CHANGED
|
@@ -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,23 +745,16 @@ export const hermesAdapter = {
|
|
|
744
745
|
|
|
745
746
|
// ── 14. Devin CLI ────────────────────────────────────────────────────────────
|
|
746
747
|
// SQLite: ~/.local/share/devin/cli/sessions.db
|
|
747
|
-
//
|
|
748
|
-
//
|
|
749
|
-
//
|
|
750
|
-
//
|
|
751
|
-
//
|
|
752
|
-
//
|
|
753
|
-
// for Claude models (claude-sonnet-4-6, claude-opus-4-6) but writes NULL for
|
|
754
|
-
// GLM-5-2 and other non-Claude models. When the field is NULL, estimate
|
|
755
|
-
// cacheCreate from cache_read using the construction ratio (cw/cr = 0.0635)
|
|
756
|
-
// calibrated from the 8K Claude-era rows (50.3M cw / 792M cr). This preserves
|
|
757
|
-
// the compounding signal — without it, cacheCreate=0 makes every window look
|
|
758
|
-
// like a "stateless pipe" (no cache commits), which is a data gap, not reality.
|
|
759
|
-
const DEVIN_CONSTRUCTION_RATIO = 0.0635;
|
|
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).
|
|
760
754
|
export const devinAdapter = {
|
|
761
755
|
platform: "devin",
|
|
762
756
|
defaultRoot: () => join(homedir(), ".local", "share", "devin", "cli"),
|
|
763
|
-
async *
|
|
757
|
+
async *records(root) {
|
|
764
758
|
for (const r of roots("DEVIN_HOME", root)) {
|
|
765
759
|
const dbPath = join(r, "sessions.db");
|
|
766
760
|
const rows = await sqliteJson(
|
|
@@ -769,7 +763,6 @@ export const devinAdapter = {
|
|
|
769
763
|
json_extract(chat_message, '$.metadata.metrics.input_tokens') as input_tokens,
|
|
770
764
|
json_extract(chat_message, '$.metadata.metrics.output_tokens') as output_tokens,
|
|
771
765
|
json_extract(chat_message, '$.metadata.metrics.cache_read_tokens') as cache_read_tokens,
|
|
772
|
-
json_extract(chat_message, '$.metadata.metrics.cache_creation_tokens') as cache_creation_tokens,
|
|
773
766
|
json_extract(chat_message, '$.metadata.created_at') as created_at
|
|
774
767
|
FROM message_nodes
|
|
775
768
|
WHERE json_extract(chat_message, '$.role') = 'assistant'
|
|
@@ -778,24 +771,15 @@ export const devinAdapter = {
|
|
|
778
771
|
60_000,
|
|
779
772
|
);
|
|
780
773
|
for (const row of rows) {
|
|
781
|
-
const
|
|
774
|
+
const inputIncl = Number(row.input_tokens || 0);
|
|
775
|
+
const cached = Number(row.cache_read_tokens || 0);
|
|
782
776
|
const output = Number(row.output_tokens || 0);
|
|
783
|
-
|
|
784
|
-
// Use native cache_creation_tokens when present; estimate from cache_read
|
|
785
|
-
// when NULL (GLM-5-2 era doesn't record the field).
|
|
786
|
-
const cacheCreate =
|
|
787
|
-
row.cache_creation_tokens != null
|
|
788
|
-
? Number(row.cache_creation_tokens || 0)
|
|
789
|
-
: Math.round(cacheRead * DEVIN_CONSTRUCTION_RATIO);
|
|
790
|
-
if (input + output + cacheRead + cacheCreate === 0) continue;
|
|
777
|
+
if (inputIncl + output + cached === 0) continue;
|
|
791
778
|
yield {
|
|
792
|
-
id: `devin:${row.row_id}`,
|
|
793
|
-
sid: row.session_id || null,
|
|
794
779
|
ts: row.created_at || null,
|
|
795
|
-
input,
|
|
796
780
|
output,
|
|
797
|
-
|
|
798
|
-
|
|
781
|
+
cacheRead: cached,
|
|
782
|
+
uncached: Math.max(0, inputIncl - cached),
|
|
799
783
|
file: dbPath,
|
|
800
784
|
};
|
|
801
785
|
}
|
|
@@ -941,8 +925,9 @@ function ompLineCouldMatter(line) {
|
|
|
941
925
|
}
|
|
942
926
|
|
|
943
927
|
/** Parse a single omp transcript file and yield usage records.
|
|
944
|
-
* Isolated so it can be called concurrently from the bounded pool.
|
|
945
|
-
|
|
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) {
|
|
946
931
|
if (!text) return;
|
|
947
932
|
let sid = null;
|
|
948
933
|
for (const line of text.split("\n")) {
|
|
@@ -988,35 +973,104 @@ export const ompAdapter = {
|
|
|
988
973
|
defaultRoot: () => join(homedir(), ".omp", "agent", "sessions"),
|
|
989
974
|
async *messages(root) {
|
|
990
975
|
for (const r of roots("OMP_DATA_DIR", root)) {
|
|
991
|
-
//
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
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;
|
|
995
993
|
}
|
|
996
994
|
|
|
997
|
-
//
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
995
|
+
// Default: uncached Stage 1 scan
|
|
996
|
+
yield* ompUncachedScan(r);
|
|
997
|
+
}
|
|
998
|
+
},
|
|
999
|
+
};
|
|
1000
|
+
|
|
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
|
+
}
|
|
1003
1009
|
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
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;
|
|
1019
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
|
+
};
|
|
1020
1074
|
}
|
|
1021
1075
|
},
|
|
1022
1076
|
};
|
|
@@ -1040,6 +1094,7 @@ export const ADAPTERS = {
|
|
|
1040
1094
|
devin: devinAdapter,
|
|
1041
1095
|
other: otherAdapter,
|
|
1042
1096
|
omp: ompAdapter,
|
|
1097
|
+
proxy: proxyAdapter,
|
|
1043
1098
|
};
|
|
1044
1099
|
|
|
1045
1100
|
export const ALL_PLATFORMS = Object.keys(ADAPTERS).concat(["claude", "codex"]);
|
package/adapters/tokenpull.mjs
CHANGED
|
@@ -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:
|
|
363
|
-
// which splits via ioRatio (Beta from Claude, Alpha 2.0).
|
|
364
|
-
|
|
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.
|
|
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/",
|
package/presentation/cli.mjs
CHANGED
|
@@ -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):
|
package/presentation/narrate.mjs
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* narrate.mjs — deterministic prose "card" for a cascade result.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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.** <
|
|
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
|
-
|
|
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
|
}
|
package/presentation/tui.mjs
CHANGED
|
@@ -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
|
+
}
|
package/resources/class-tiers.md
CHANGED
|
@@ -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 **
|
|
13
|
-
|
|
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
|
package/resources/data-policy.md
CHANGED
|
@@ -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**
|
|
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
|
|
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.
|