nous-token 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 nousworld
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/PROTOCOL.md ADDED
@@ -0,0 +1,176 @@
1
+ # nous-token Protocol v2
2
+
3
+ ## Overview
4
+
5
+ nous-token is an open-source AI usage tracking protocol. A transparent gateway proxies LLM API calls, extracts real token consumption from provider responses, and signs a **receipt** for every call. Users own their receipts and decide what to store on-chain.
6
+
7
+ The gateway guarantees data authenticity. It does not dictate storage format.
8
+
9
+ ## How It Works
10
+
11
+ ```
12
+ User → Plugin/CLI/SDK → Gateway → LLM Provider (any)
13
+ ↓ ↓
14
+ hash(API key) extract .usage
15
+ X-Nous-User auto-detect format
16
+
17
+ sign receipt (ECDSA) + store in D1 + update Merkle tree
18
+
19
+ User fetches receipts → decides what to store on-chain
20
+ ```
21
+
22
+ ### Provider Routing
23
+
24
+ Two ways to route through the gateway:
25
+
26
+ 1. **Shortcut prefix**: `/{provider}/v1/...` for known providers (openai, anthropic, deepseek, gemini, groq, together, mistral, openrouter, fireworks, perplexity, cohere)
27
+ 2. **Custom upstream**: Set `X-Nous-Upstream: https://api.example.com` header to proxy any OpenAI-compatible API
28
+
29
+ Usage extraction auto-detects response format (OpenAI, Anthropic, or Gemini style). No provider registration needed.
30
+
31
+ ## The Receipt
32
+
33
+ Every API call through the gateway produces a signed receipt:
34
+
35
+ ```json
36
+ {
37
+ "p": "nous",
38
+ "v": 1,
39
+ "type": "receipt",
40
+ "id": 42,
41
+ "ts": "2026-04-02T12:00:00.000Z",
42
+ "user": "a1b2c3d4...",
43
+ "provider": "anthropic",
44
+ "model": "claude-opus-4-6",
45
+ "input": 5000,
46
+ "output": 2000,
47
+ "cache_read": 0,
48
+ "cache_write": 0,
49
+ "total": 7000,
50
+ "leaf": "sha256...",
51
+ "sig": "ecdsa_signature..."
52
+ }
53
+ ```
54
+
55
+ **Verification**: `leaf` = SHA-256(`ts|user|provider|model|input|output|cache_read|cache_write|total`). `sig` = ECDSA-P256-SHA256 signature over `leaf`. Verify with the gateway's public key at `/api/pubkey`.
56
+
57
+ The gateway signs the leaf hash, not the JSON. This means:
58
+ - Anyone can independently recompute `leaf` from the fields and verify it matches
59
+ - The signature proves the gateway attested to this exact data
60
+ - No ambiguity from JSON serialization differences
61
+
62
+ ## What the Gateway Guarantees
63
+
64
+ 1. **Token counts are real** — extracted from the actual provider response, not estimated
65
+ 2. **Receipts are signed** — ECDSA P-256 signature on each record's leaf hash
66
+ 3. **History is append-only** — Merkle Mountain Range makes deletion/modification detectable
67
+ 4. **Data is public** — anyone can pull all records and verify independently
68
+
69
+ ## What the Gateway Does NOT Dictate
70
+
71
+ - What the user stores on-chain (individual receipts, summaries, or nothing)
72
+ - How the user calculates cost (different plans have different rates)
73
+ - What fields the user displays publicly
74
+ - When the user stores proof
75
+
76
+ ## Wallet Identity
77
+
78
+ Users identify themselves with an Ethereum wallet address. The wallet is the permanent identity — API keys rotate, wallets don't.
79
+
80
+ ### How it works
81
+
82
+ 1. User runs `npx nous-token setup`, enters wallet address + API key
83
+ 2. CLI computes `user_hash = SHA-256(api_key)[0:16]` locally, sends `{api_key, wallet}` to `/api/link`
84
+ 3. Gateway computes the same hash, verifies it exists in records, links all records to the wallet
85
+ 4. Future API calls include `?wallet=0x...` in the base URL (set by CLI) or `X-Nous-Wallet` header (set by plugin)
86
+ 5. Gateway stores wallet with each new record and backfills old records for the same hash
87
+
88
+ ### Binding rules
89
+
90
+ - **First bind wins**: Once a hash is linked to a wallet, it cannot be relinked to a different wallet. This prevents rebinding with expired or leaked API keys.
91
+ - **Multiple hashes per wallet**: A user can link multiple API keys (hashes) to the same wallet. The leaderboard aggregates all hashes under one wallet.
92
+ - **Wallet validation**: Must be a valid Ethereum address (`0x` + 40 hex chars). Invalid addresses are silently ignored.
93
+
94
+ ### Leaderboard aggregation
95
+
96
+ The leaderboard uses `CASE WHEN wallet != '' THEN wallet ELSE user_hash END` as the identity key. Users with wallets are aggregated by wallet; users without wallets fall back to hash-based identity.
97
+
98
+ ## Privacy by Structure
99
+
100
+ Not by promise — by code. Audit the source:
101
+
102
+ - **API Key**: Plugin computes SHA-256 hash locally, sends only the hash via `X-Nous-User` header. If no `X-Nous-User` is present (e.g., Claude Code via base URL), the gateway reads the API key from `Authorization` header **solely** to compute the same hash. The key is never stored, logged, or retained — it exists in Worker memory only for the duration of one SHA-256 call, then is discarded by GC. The `X-Nous-Upstream` header (if used) is stripped before forwarding.
103
+ - **Prompts**: `request.body` is piped directly to the provider. No `.text()`, `.json()`, or `.getReader()` is called on it.
104
+ - **Responses (streaming)**: Tee'd. One branch to user unchanged, other buffers last 4KB to extract `.usage` only.
105
+ - **Responses (non-streaming)**: Full body in Worker memory (V8 isolate, GC'd after request) to extract `.usage`. Never reads `.choices`, `.content`.
106
+ - **Storage**: D1 stores: timestamp, user_hash, provider, model, token counts, leaf_hash, receipt_sig. No prompts, no responses, no keys.
107
+
108
+ ## Tamper Detection: Merkle Mountain Range
109
+
110
+ Each receipt's `leaf` hash is appended to a Merkle Mountain Range (MMR). The MMR root changes if any single record is modified.
111
+
112
+ ### Verification Levels
113
+
114
+ | Level | How | Cost |
115
+ |---|---|---|
116
+ | **Single receipt** | Recompute `leaf` from fields, verify `sig` with pubkey | O(1) |
117
+ | **Full tree** | Pull all records, rebuild MMR, compare root | O(n) |
118
+ | **External anchor** | Compare root with published root (GitHub, on-chain) | O(1) |
119
+
120
+ ### Sentinel
121
+
122
+ Anyone can run an independent verifier:
123
+
124
+ ```
125
+ npx tsx sentinel.ts --watch
126
+ ```
127
+
128
+ The sentinel verifies leaf hashes, receipt signatures, and Merkle root integrity. No API key needed.
129
+
130
+ ## On-Chain Storage
131
+
132
+ Users choose how to store proof on BASE (or any EVM chain):
133
+
134
+ ### Option 1: Individual Receipts
135
+
136
+ Fetch signed receipts via `/api/user/:hash/receipts`, store as calldata. Each receipt is independently verifiable.
137
+
138
+ ### Option 2: Summary
139
+
140
+ Request a signed summary via `POST /api/sign`, store as calldata. The summary aggregates multiple receipts into a single signed attestation.
141
+
142
+ Both options use self-to-self transactions with data as calldata. No smart contract needed — the ECDSA signature is the proof.
143
+
144
+ ## Trust Model
145
+
146
+ 1. **Code is open-source** — anyone can audit
147
+ 2. **Per-call signatures** — every receipt is signed by the gateway
148
+ 3. **Merkle tree** — tampering with any record breaks the tree
149
+ 4. **Sentinels** — independent verifiers monitor continuously
150
+ 5. **On-chain anchoring** — published roots prevent history rewrites
151
+
152
+ ## API Endpoints
153
+
154
+ | Endpoint | Method | Description |
155
+ |---|---|---|
156
+ | `/{provider}/v1/...` | * | Proxy to LLM provider (append `?wallet=0x...` for identity) |
157
+ | `/api/leaderboard` | GET | Top users by tokens (`?days=30`, `?days=0` for all time) |
158
+ | `/api/leaderboard/model` | GET | Per-model user ranking (`?model=...&days=30`) |
159
+ | `/api/models` | GET | Usage breakdown by model (aggregated across providers) |
160
+ | `/api/stats` | GET | Global totals |
161
+ | `/api/wallet/{address}` | GET | Usage for a wallet (all linked hashes aggregated) |
162
+ | `/api/user/{hash}` | GET | Single user aggregated stats |
163
+ | `/api/user/{hash}/receipts` | GET | Signed receipts (paginated) |
164
+ | `/api/link` | POST | Link hash to wallet (`{api_key, wallet}`) — first bind wins |
165
+ | `/api/records` | GET | Raw records for sentinel verification |
166
+ | `/api/chain` | GET | Merkle root and MMR state |
167
+ | `/api/sign` | POST | Signed summary (optional aggregation) |
168
+ | `/api/pubkey` | GET | Gateway's ECDSA public key |
169
+
170
+ ## Auditing the Gateway
171
+
172
+ Search the source to verify privacy claims:
173
+ - `authorization`, `x-api-key` → read only to compute user hash when `X-Nous-User` is absent; value is not stored or logged
174
+ - `request.body` → only as argument to `fetch()` (piped, not consumed)
175
+ - `.content`, `.choices`, `.message` → absent from data-reading code
176
+ - `headers.get()` → only for `x-nous-user`, `x-nous-upstream`, `x-nous-wallet`, and `content-type`
package/README.md ADDED
@@ -0,0 +1,137 @@
1
+ # nous-token
2
+
3
+ Open-source AI usage tracker. Every token, verified.
4
+
5
+ One command to track all your AI spending across every provider. Your data is signed, verifiable, and yours.
6
+
7
+ **Live leaderboard**: [token.nousai.cc](https://token.nousai.cc)
8
+
9
+ ## Quick Start
10
+
11
+ ```bash
12
+ npx nous-token setup
13
+ ```
14
+
15
+ The CLI asks two things:
16
+ 1. **Wallet address** (0x...) — your permanent identity across API keys
17
+ 2. **API key** — used locally to compute your hash, never sent anywhere
18
+
19
+ Then it auto-detects your AI tools (Claude Code, Cursor, Python SDKs, etc.) and routes them through the gateway.
20
+
21
+ ## How It Works
22
+
23
+ ```
24
+ You → AI Tool → Gateway → LLM Provider
25
+
26
+ extract .usage from response
27
+ sign receipt (ECDSA)
28
+ record in Merkle tree
29
+
30
+ token.nousai.cc
31
+ ```
32
+
33
+ The gateway is a transparent proxy. It forwards your request untouched, reads only the `.usage` field from the response, signs a receipt, and records it. Your prompts and completions are never read or stored.
34
+
35
+ ## Supported Providers
36
+
37
+ Works with any OpenAI-compatible API out of the box:
38
+
39
+ | Provider | Route |
40
+ |----------|-------|
41
+ | OpenAI | `/openai/v1/...` |
42
+ | Anthropic | `/anthropic/...` |
43
+ | DeepSeek | `/deepseek/v1/...` |
44
+ | Google Gemini | `/gemini/...` |
45
+ | Groq | `/groq/v1/...` |
46
+ | Together | `/together/v1/...` |
47
+ | Mistral | `/mistral/v1/...` |
48
+ | OpenRouter | `/openrouter/api/v1/...` |
49
+ | Fireworks | `/fireworks/v1/...` |
50
+ | Perplexity | `/perplexity/v1/...` |
51
+ | Cohere | `/cohere/v1/...` |
52
+ | **Any custom** | Set `X-Nous-Upstream` header |
53
+
54
+ ## Wallet Identity
55
+
56
+ Your wallet address is your permanent identity. API keys expire and rotate — your wallet doesn't.
57
+
58
+ - Run `npx nous-token setup` with your wallet address
59
+ - All usage across any API key links to your wallet
60
+ - Switch keys, switch machines — your history follows you
61
+ - First bind is permanent (prevents rebind with leaked keys)
62
+
63
+ ## Privacy by Structure
64
+
65
+ Not by promise — by code. [Audit the source](gateway/src/index.ts).
66
+
67
+ | Data | What happens |
68
+ |------|-------------|
69
+ | API Key | Hashed (SHA-256) for identity. Never stored. |
70
+ | Prompts | `request.body` piped directly to provider. Never read. |
71
+ | Responses (streaming) | Tee'd. Only last 4KB buffered to extract `.usage`. |
72
+ | Responses (non-streaming) | In V8 isolate memory. Only `.usage` accessed. GC'd after request. |
73
+ | Storage | D1 stores: timestamp, user_hash, wallet, provider, model, token counts. Nothing else. |
74
+
75
+ ## Verification
76
+
77
+ Every API call produces a signed receipt. Anyone can verify independently.
78
+
79
+ ### Run a sentinel
80
+
81
+ ```bash
82
+ npx tsx sentinel.ts # one-shot verify
83
+ npx tsx sentinel.ts --watch # continuous monitoring
84
+ ```
85
+
86
+ The sentinel pulls all records, recomputes leaf hashes, rebuilds the Merkle tree, and verifies receipt signatures. No API key needed.
87
+
88
+ ### Trust model
89
+
90
+ 1. **Code is open-source** — audit everything
91
+ 2. **Per-call ECDSA signatures** — every receipt is signed
92
+ 3. **Merkle Mountain Range** — tampering breaks the tree
93
+ 4. **Sentinels** — independent verifiers anyone can run
94
+ 5. **On-chain anchoring** — store receipts on BASE or any EVM chain
95
+
96
+ ## API
97
+
98
+ Gateway: `https://gateway.nousai.cc`
99
+
100
+ | Endpoint | Method | Description |
101
+ |----------|--------|-------------|
102
+ | `/{provider}/v1/...` | * | Proxy to LLM provider |
103
+ | `/api/leaderboard` | GET | Top users by tokens (`?days=30`, `?days=0` for all time) |
104
+ | `/api/leaderboard/model` | GET | Per-model user ranking (`?model=...`) |
105
+ | `/api/models` | GET | Usage breakdown by model |
106
+ | `/api/stats` | GET | Global totals |
107
+ | `/api/wallet/{address}` | GET | Usage for a wallet (all linked hashes) |
108
+ | `/api/user/{hash}` | GET | Single user stats |
109
+ | `/api/user/{hash}/receipts` | GET | Signed receipts (paginated) |
110
+ | `/api/link` | POST | Link hash to wallet (`{api_key, wallet}`) |
111
+ | `/api/records` | GET | Raw records for sentinel verification |
112
+ | `/api/chain` | GET | Merkle root and MMR state |
113
+ | `/api/sign` | POST | Signed summary for on-chain storage |
114
+ | `/api/pubkey` | GET | Gateway's ECDSA public key |
115
+
116
+ ## Architecture
117
+
118
+ ```
119
+ nous-token/
120
+ ├── gateway/ Cloudflare Worker — proxy + usage extraction + signing
121
+ │ └── src/
122
+ │ ├── index.ts API routes + proxy logic
123
+ │ ├── db.ts D1 storage + Merkle Mountain Range
124
+ │ ├── providers.ts Usage format auto-detection (OpenAI/Anthropic/Gemini)
125
+ │ └── stream.ts Streaming response usage extraction
126
+ ├── web/ Cloudflare Pages — leaderboard frontend
127
+ │ └── index.html
128
+ ├── cli/ CLI setup tool
129
+ │ └── setup.ts
130
+ ├── index.ts OpenClaw plugin
131
+ ├── sentinel.ts Independent Merkle tree verifier
132
+ └── PROTOCOL.md Full protocol specification
133
+ ```
134
+
135
+ ## License
136
+
137
+ MIT
package/cli/setup.ts ADDED
@@ -0,0 +1,306 @@
1
+ #!/usr/bin/env npx tsx
2
+ //
3
+ // nous-token setup — one command to configure all AI tools
4
+ //
5
+ // Usage: npx nous-token setup
6
+ //
7
+ // Scans for installed AI tools, configures them to route through
8
+ // the nous-token gateway for usage tracking.
9
+
10
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync } from "fs";
11
+ import { homedir } from "os";
12
+ import { join } from "path";
13
+ import { execSync } from "child_process";
14
+ import { createHash } from "crypto";
15
+
16
+ const GATEWAY = "https://gateway.nousai.cc";
17
+ const HOME = homedir();
18
+ const NOUS_CONFIG = join(HOME, ".nous-token");
19
+
20
+ function loadWallet(): string {
21
+ try { return readFileSync(NOUS_CONFIG, "utf-8").trim(); } catch { return ""; }
22
+ }
23
+
24
+ function saveWallet(wallet: string): void {
25
+ writeFileSync(NOUS_CONFIG, wallet);
26
+ }
27
+
28
+ function gatewayUrl(path: string, wallet: string): string {
29
+ return wallet ? `${GATEWAY}${path}?wallet=${wallet}` : `${GATEWAY}${path}`;
30
+ }
31
+
32
+ interface Tool {
33
+ name: string;
34
+ detect: () => boolean;
35
+ configure: (wallet: string) => ConfigResult;
36
+ }
37
+
38
+ interface ConfigResult {
39
+ success: boolean;
40
+ message: string;
41
+ }
42
+
43
+ // ── Tool detectors and configurators ──
44
+
45
+ const tools: Tool[] = [
46
+ // OpenClaw
47
+ {
48
+ name: "OpenClaw",
49
+ detect: () => {
50
+ try { execSync("which openclaw", { stdio: "ignore" }); return true; } catch { return false; }
51
+ },
52
+ configure: (_wallet: string) => {
53
+ try {
54
+ execSync("openclaw plugins install nous-token", { stdio: "inherit" });
55
+ return { success: true, message: "installed nous-token plugin" };
56
+ } catch {
57
+ return { success: false, message: "plugin install failed" };
58
+ }
59
+ },
60
+ },
61
+
62
+ // Claude Code
63
+ {
64
+ name: "Claude Code",
65
+ detect: () => {
66
+ const claudeDir = join(HOME, ".claude");
67
+ if (existsSync(claudeDir)) return true;
68
+ try { execSync("which claude", { stdio: "ignore" }); return true; } catch { return false; }
69
+ },
70
+ configure: (wallet: string) => {
71
+ return setShellEnv("ANTHROPIC_BASE_URL", gatewayUrl("/anthropic", wallet), "Claude Code");
72
+ },
73
+ },
74
+
75
+ // Cursor
76
+ {
77
+ name: "Cursor",
78
+ detect: () => {
79
+ const paths = [
80
+ join(HOME, ".cursor"),
81
+ join(HOME, "Library", "Application Support", "Cursor"),
82
+ join(HOME, ".config", "Cursor"),
83
+ ];
84
+ return paths.some(p => existsSync(p));
85
+ },
86
+ configure: (wallet: string) => {
87
+ const settingsPaths = [
88
+ join(HOME, "Library", "Application Support", "Cursor", "User", "settings.json"),
89
+ join(HOME, ".config", "Cursor", "User", "settings.json"),
90
+ ];
91
+ for (const sp of settingsPaths) {
92
+ if (existsSync(sp)) {
93
+ try {
94
+ const content = JSON.parse(readFileSync(sp, "utf-8"));
95
+ if (content["openai.baseUrl"]?.includes("nousai")) {
96
+ return { success: true, message: "already configured" };
97
+ }
98
+ content["openai.baseUrl"] = gatewayUrl("/openai/v1", wallet);
99
+ writeFileSync(sp, JSON.stringify(content, null, 2));
100
+ return { success: true, message: `set baseUrl in Cursor settings` };
101
+ } catch {
102
+ return { success: false, message: "failed to update Cursor settings" };
103
+ }
104
+ }
105
+ }
106
+ return { success: false, message: "Cursor settings file not found" };
107
+ },
108
+ },
109
+
110
+ // Codex CLI
111
+ {
112
+ name: "Codex",
113
+ detect: () => {
114
+ return existsSync(join(HOME, ".codex")) ||
115
+ (() => { try { execSync("which codex", { stdio: "ignore" }); return true; } catch { return false; } })();
116
+ },
117
+ configure: (wallet: string) => {
118
+ return setShellEnv("OPENAI_BASE_URL", gatewayUrl("/openai/v1", wallet), "Codex");
119
+ },
120
+ },
121
+
122
+ // Gemini CLI
123
+ {
124
+ name: "Gemini CLI",
125
+ detect: () => {
126
+ return existsSync(join(HOME, ".gemini")) ||
127
+ (() => { try { execSync("which gemini", { stdio: "ignore" }); return true; } catch { return false; } })();
128
+ },
129
+ configure: (wallet: string) => {
130
+ return setShellEnv("GOOGLE_GEMINI_BASE_URL", gatewayUrl("/gemini", wallet), "Gemini CLI");
131
+ },
132
+ },
133
+
134
+ // Python openai SDK
135
+ {
136
+ name: "Python (openai)",
137
+ detect: () => {
138
+ try { execSync("python3 -c 'import openai'", { stdio: "ignore" }); return true; } catch { return false; }
139
+ },
140
+ configure: (wallet: string) => {
141
+ return setShellEnv("OPENAI_BASE_URL", gatewayUrl("/openai/v1", wallet), "Python openai SDK");
142
+ },
143
+ },
144
+
145
+ // Python anthropic SDK
146
+ {
147
+ name: "Python (anthropic)",
148
+ detect: () => {
149
+ try { execSync("python3 -c 'import anthropic'", { stdio: "ignore" }); return true; } catch { return false; }
150
+ },
151
+ configure: (wallet: string) => {
152
+ return setShellEnv("ANTHROPIC_BASE_URL", gatewayUrl("/anthropic", wallet), "Python anthropic SDK");
153
+ },
154
+ },
155
+ ];
156
+
157
+ // ── Shell env helper ──
158
+
159
+ function setShellEnv(key: string, value: string, toolName: string): ConfigResult {
160
+ const shell = process.env.SHELL || "/bin/bash";
161
+ const rcFile = shell.includes("zsh") ? join(HOME, ".zshrc") : join(HOME, ".bashrc");
162
+
163
+ if (existsSync(rcFile)) {
164
+ const content = readFileSync(rcFile, "utf-8");
165
+ // Check if already configured with the exact same value
166
+ if (content.includes(`${key}="${value}"`)) {
167
+ return { success: true, message: "already configured" };
168
+ }
169
+ // Remove old nous-token setting if exists, then write new one
170
+ const lines = content.split("\n").filter(l => !(l.includes(`${key}=`) && l.includes("nousai")));
171
+ lines.push(`export ${key}="${value}" # nous-token gateway`);
172
+ writeFileSync(rcFile, lines.join("\n"));
173
+ } else {
174
+ writeFileSync(rcFile, `export ${key}="${value}" # nous-token gateway\n`);
175
+ }
176
+
177
+ // Also set in current process
178
+ process.env[key] = value;
179
+
180
+ const rcName = rcFile.includes("zsh") ? "~/.zshrc" : "~/.bashrc";
181
+ return { success: true, message: `set ${key} in ${rcName}` };
182
+ }
183
+
184
+ // ── Find first available API key ──
185
+
186
+ function findFirstApiKey(): string | null {
187
+ const keys = [
188
+ process.env.OPENAI_API_KEY,
189
+ process.env.ANTHROPIC_API_KEY,
190
+ process.env.DEEPSEEK_API_KEY,
191
+ process.env.GEMINI_API_KEY,
192
+ process.env.GROQ_API_KEY,
193
+ ];
194
+ for (const key of keys) {
195
+ if (key) return key.replace(/^Bearer\s+/i, "");
196
+ }
197
+ return null;
198
+ }
199
+
200
+ function computeUserHash(): string | null {
201
+ const key = findFirstApiKey();
202
+ if (!key) return null;
203
+ return createHash("sha256").update(key).digest("hex").slice(0, 32);
204
+ }
205
+
206
+ // ── Prompt helper ──
207
+
208
+ async function ask(prompt: string): Promise<string> {
209
+ process.stdout.write(prompt);
210
+ return new Promise<string>((resolve) => {
211
+ process.stdin.setEncoding("utf-8");
212
+ process.stdin.once("data", (chunk) => resolve(chunk.toString().trim()));
213
+ process.stdin.resume();
214
+ });
215
+ }
216
+
217
+ // ── Main ──
218
+
219
+ console.log("");
220
+ console.log(" \x1b[1mnous-token setup\x1b[0m\n");
221
+
222
+ // Step 1: Wallet address
223
+ let wallet = loadWallet();
224
+ const walletArg = process.argv.find(a => /^0x[a-fA-F0-9]{40}$/.test(a));
225
+ if (walletArg) {
226
+ wallet = walletArg.toLowerCase();
227
+ } else if (!wallet) {
228
+ const input = await ask(" Wallet address (0x...): ");
229
+ if (/^0x[a-fA-F0-9]{40}$/.test(input)) {
230
+ wallet = input.toLowerCase();
231
+ } else if (input) {
232
+ console.log(" \x1b[33mInvalid address format.\x1b[0m\n");
233
+ process.exit(1);
234
+ }
235
+ }
236
+
237
+ if (wallet) {
238
+ saveWallet(wallet);
239
+ console.log(` \x1b[36mWallet: ${wallet}\x1b[0m\n`);
240
+ } else {
241
+ console.log(" \x1b[90mNo wallet. Run again with: npx nous-token setup 0x...\x1b[0m\n");
242
+ process.exit(0);
243
+ }
244
+
245
+ // Step 2: API key — read from env or ask
246
+ let apiKey = findFirstApiKey();
247
+ if (!apiKey) {
248
+ const input = await ask(" API key (sk-...): ");
249
+ if (input) {
250
+ apiKey = input.replace(/^Bearer\s+/i, "");
251
+ }
252
+ }
253
+
254
+ // Step 3: Link hash → wallet via gateway
255
+ if (apiKey) {
256
+ const hash = createHash("sha256").update(apiKey).digest("hex").slice(0, 32);
257
+ console.log(` \x1b[90mUser hash: ${hash}\x1b[0m`);
258
+ try {
259
+ const res = await fetch(`${GATEWAY}/api/link`, {
260
+ method: "POST",
261
+ headers: { "Content-Type": "application/json" },
262
+ body: JSON.stringify({ api_key: apiKey, wallet }),
263
+ });
264
+ const data = await res.json() as { ok?: boolean; error?: string };
265
+ if (data.ok) {
266
+ console.log(` \x1b[32m✓ Linked to wallet\x1b[0m\n`);
267
+ } else {
268
+ console.log(` \x1b[90m${data.error || "No existing records to link — they'll link automatically on first use."}\x1b[0m\n`);
269
+ }
270
+ } catch {
271
+ console.log(" \x1b[90mCouldn't reach gateway — linking will happen on first use.\x1b[0m\n");
272
+ }
273
+ } else {
274
+ console.log(" \x1b[90mNo API key found. Wallet will link on first use through the gateway.\x1b[0m\n");
275
+ }
276
+
277
+ // Step 4: Configure tools
278
+ console.log(" Scanning for AI tools...\n");
279
+
280
+ let configured = 0;
281
+
282
+ for (const tool of tools) {
283
+ if (tool.detect()) {
284
+ const result = tool.configure(wallet);
285
+ if (result.success) {
286
+ console.log(` \x1b[32m✓\x1b[0m ${tool.name.padEnd(20)} → ${result.message}`);
287
+ configured++;
288
+ } else {
289
+ console.log(` \x1b[31m✗\x1b[0m ${tool.name.padEnd(20)} → ${result.message}`);
290
+ }
291
+ } else {
292
+ console.log(` \x1b[90m-\x1b[0m \x1b[90m${tool.name.padEnd(20)} → not found\x1b[0m`);
293
+ }
294
+ }
295
+
296
+ console.log("");
297
+
298
+ if (configured > 0) {
299
+ console.log(` \x1b[32m${configured} tool(s) configured.\x1b[0m`);
300
+ console.log(` See your usage at \x1b[4mhttps://token.nousai.cc\x1b[0m`);
301
+ console.log(` Run \x1b[1msource ~/.zshrc\x1b[0m to apply changes in this terminal.`);
302
+ } else {
303
+ console.log(" No AI tools found. Install Claude Code, Cursor, or any OpenAI-compatible tool.");
304
+ }
305
+
306
+ console.log("");
package/index.ts ADDED
@@ -0,0 +1,130 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
+
3
+ // nous-token plugin: routes LLM calls through the nous-token gateway
4
+ // for real usage tracking on the global leaderboard.
5
+ //
6
+ // Supports 11 built-in providers + unlimited custom providers via config.
7
+ // Any OpenAI-compatible API can be routed through the gateway.
8
+ //
9
+ // PRIVACY: API Key never leaves the machine in readable form.
10
+ // Plugin computes SHA-256(apiKey) locally and sends only the hash
11
+ // to the gateway via X-Nous-User header.
12
+
13
+ const GATEWAY = "https://gateway.nousai.cc";
14
+
15
+ // Built-in providers: prefix maps to gateway shortcut route
16
+ const BUILTIN_PROVIDERS: Record<string, { prefix: string; api: string; envVars: string[] }> = {
17
+ openai: { prefix: "openai", api: "openai-completions", envVars: ["OPENAI_API_KEY"] },
18
+ anthropic: { prefix: "anthropic", api: "anthropic-messages", envVars: ["ANTHROPIC_API_KEY"] },
19
+ deepseek: { prefix: "deepseek", api: "openai-completions", envVars: ["DEEPSEEK_API_KEY"] },
20
+ google: { prefix: "gemini", api: "openai-completions", envVars: ["GEMINI_API_KEY", "GOOGLE_AI_API_KEY"] },
21
+ groq: { prefix: "groq", api: "openai-completions", envVars: ["GROQ_API_KEY"] },
22
+ together: { prefix: "together", api: "openai-completions", envVars: ["TOGETHER_API_KEY"] },
23
+ mistral: { prefix: "mistral", api: "openai-completions", envVars: ["MISTRAL_API_KEY"] },
24
+ openrouter: { prefix: "openrouter", api: "openai-completions", envVars: ["OPENROUTER_API_KEY"] },
25
+ fireworks: { prefix: "fireworks", api: "openai-completions", envVars: ["FIREWORKS_API_KEY"] },
26
+ perplexity: { prefix: "perplexity", api: "openai-completions", envVars: ["PERPLEXITY_API_KEY"] },
27
+ cohere: { prefix: "cohere", api: "openai-completions", envVars: ["COHERE_API_KEY"] },
28
+ };
29
+
30
+ interface PluginConfig {
31
+ // Custom providers: { "myapi": { "upstream": "https://api.myapi.com", "envVar": "MYAPI_KEY" } }
32
+ customProviders?: Record<string, { upstream: string; envVar: string; api?: string }>;
33
+ }
34
+
35
+ export default function plugin(api: OpenClawPluginApi): void {
36
+ const cfg = (api.pluginConfig ?? {}) as PluginConfig;
37
+
38
+ // ── Register built-in providers ──
39
+ for (const [providerKey, { prefix, api: apiType, envVars }] of Object.entries(BUILTIN_PROVIDERS)) {
40
+ registerProvider(api, providerKey, {
41
+ baseUrl: `${GATEWAY}/${prefix}/v1`,
42
+ apiType,
43
+ envVars,
44
+ });
45
+ }
46
+
47
+ // ── Register custom providers from config ──
48
+ if (cfg.customProviders) {
49
+ for (const [name, { upstream, envVar, api: apiType }] of Object.entries(cfg.customProviders)) {
50
+ registerProvider(api, name, {
51
+ // Custom providers use X-Nous-Upstream header instead of shortcut prefix
52
+ baseUrl: `${GATEWAY}/v1`,
53
+ apiType: apiType || "openai-completions",
54
+ envVars: [envVar],
55
+ upstreamHeader: upstream,
56
+ });
57
+ }
58
+ }
59
+
60
+ const total = Object.keys(BUILTIN_PROVIDERS).length + Object.keys(cfg.customProviders || {}).length;
61
+ api.logger.info(`[nous-token] ready — ${total} providers, usage tracked via gateway`);
62
+ }
63
+
64
+ // ── Provider registration ──
65
+
66
+ function registerProvider(
67
+ api: OpenClawPluginApi,
68
+ providerKey: string,
69
+ opts: { baseUrl: string; apiType: string; envVars: string[]; upstreamHeader?: string }
70
+ ): void {
71
+ const nousId = `nous-${providerKey}`;
72
+
73
+ api.registerProvider({
74
+ id: nousId,
75
+ label: `${providerKey} (nous-token)`,
76
+
77
+ auth: {
78
+ envVars: opts.envVars,
79
+ choices: [{
80
+ method: "api-key" as const,
81
+ choiceId: `${nousId}-key`,
82
+ choiceLabel: `${providerKey} API key`,
83
+ groupId: nousId,
84
+ groupLabel: `${providerKey} via nous-token`,
85
+ }],
86
+ },
87
+
88
+ catalog: { run: async () => null },
89
+
90
+ resolveDynamicModel: (ctx) => ({
91
+ id: ctx.modelId,
92
+ name: ctx.modelId,
93
+ provider: nousId,
94
+ api: opts.apiType,
95
+ baseUrl: opts.baseUrl,
96
+ reasoning: false,
97
+ input: ["text"] as const,
98
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
99
+ contextWindow: 200000,
100
+ maxTokens: 32768,
101
+ }),
102
+
103
+ wrapStreamFn: (ctx) => {
104
+ if (!ctx.streamFn) return undefined;
105
+ const inner = ctx.streamFn;
106
+ return async (params) => {
107
+ let rawKey = params.headers?.["authorization"] || params.headers?.["x-api-key"] || "";
108
+ rawKey = rawKey.replace(/^Bearer\s+/i, "");
109
+ if (!rawKey) {
110
+ return inner(params);
111
+ }
112
+ const keyHash = await sha256(rawKey);
113
+ params.headers = {
114
+ ...params.headers,
115
+ "x-nous-user": keyHash,
116
+ ...(opts.upstreamHeader ? { "x-nous-upstream": opts.upstreamHeader } : {}),
117
+ };
118
+ return inner(params);
119
+ };
120
+ },
121
+ });
122
+ }
123
+
124
+ async function sha256(input: string): Promise<string> {
125
+ const data = new TextEncoder().encode(input);
126
+ const hash = await crypto.subtle.digest("SHA-256", data);
127
+ return Array.from(new Uint8Array(hash).slice(0, 16))
128
+ .map((b) => b.toString(16).padStart(2, "0"))
129
+ .join("");
130
+ }
@@ -0,0 +1,44 @@
1
+ {
2
+ "id": "nous-token",
3
+ "name": "nous-token",
4
+ "description": "Track your AI usage on the global leaderboard. Routes LLM calls through the nous-token gateway to record real token consumption. Supports 11 providers out of the box + unlimited custom providers. No prompt or response content is stored.",
5
+ "version": "0.2.0",
6
+ "kind": "provider",
7
+ "providers": [
8
+ "nous-openai", "nous-anthropic", "nous-deepseek", "nous-google",
9
+ "nous-groq", "nous-together", "nous-mistral", "nous-openrouter",
10
+ "nous-fireworks", "nous-perplexity", "nous-cohere"
11
+ ],
12
+ "providerAuthEnvVars": {
13
+ "nous-openai": ["OPENAI_API_KEY"],
14
+ "nous-anthropic": ["ANTHROPIC_API_KEY"],
15
+ "nous-deepseek": ["DEEPSEEK_API_KEY"],
16
+ "nous-google": ["GEMINI_API_KEY", "GOOGLE_AI_API_KEY"],
17
+ "nous-groq": ["GROQ_API_KEY"],
18
+ "nous-together": ["TOGETHER_API_KEY"],
19
+ "nous-mistral": ["MISTRAL_API_KEY"],
20
+ "nous-openrouter": ["OPENROUTER_API_KEY"],
21
+ "nous-fireworks": ["FIREWORKS_API_KEY"],
22
+ "nous-perplexity": ["PERPLEXITY_API_KEY"],
23
+ "nous-cohere": ["COHERE_API_KEY"]
24
+ },
25
+ "configSchema": {
26
+ "type": "object",
27
+ "additionalProperties": false,
28
+ "properties": {
29
+ "customProviders": {
30
+ "type": "object",
31
+ "description": "Add custom providers. Key is provider name, value has upstream URL and env var for API key.",
32
+ "additionalProperties": {
33
+ "type": "object",
34
+ "properties": {
35
+ "upstream": { "type": "string", "description": "Provider API base URL (e.g. https://api.myapi.com)" },
36
+ "envVar": { "type": "string", "description": "Environment variable name for the API key" },
37
+ "api": { "type": "string", "description": "API type (default: openai-completions)", "enum": ["openai-completions", "anthropic-messages"] }
38
+ },
39
+ "required": ["upstream", "envVar"]
40
+ }
41
+ }
42
+ }
43
+ }
44
+ }
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "nous-token",
3
+ "version": "0.1.0",
4
+ "description": "Open-source AI usage tracker. One command to track all your AI spending.",
5
+ "type": "module",
6
+ "main": "index.ts",
7
+ "bin": {
8
+ "nous-token": "./cli/setup.ts"
9
+ },
10
+ "files": [
11
+ "cli/",
12
+ "index.ts",
13
+ "sentinel.ts",
14
+ "openclaw.plugin.json",
15
+ "README.md",
16
+ "PROTOCOL.md"
17
+ ],
18
+ "openclaw": {
19
+ "extensions": ["./index.ts"]
20
+ },
21
+ "dependencies": {
22
+ "tsx": "^4"
23
+ },
24
+ "keywords": ["ai", "llm", "usage", "tracker", "openclaw", "openai", "anthropic", "leaderboard", "wallet", "crypto"],
25
+ "license": "MIT",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/nousworld/nous-token"
29
+ }
30
+ }
package/sentinel.ts ADDED
@@ -0,0 +1,256 @@
1
+ #!/usr/bin/env npx tsx
2
+ //
3
+ // nous-token sentinel — independent Merkle tree verifier
4
+ //
5
+ // Pulls records from the gateway, recomputes leaf hashes, rebuilds the
6
+ // Merkle Mountain Range locally, and compares the root with the gateway's.
7
+ //
8
+ // Usage:
9
+ // npx tsx sentinel.ts # one-shot verify
10
+ // npx tsx sentinel.ts --watch # verify every 5 minutes
11
+ // npx tsx sentinel.ts --gateway https://... # verify a different gateway
12
+ //
13
+ // Anyone can run this. No API key needed. All data is public.
14
+
15
+ const DEFAULT_GATEWAY = "https://gateway.nousai.cc";
16
+ const POLL_INTERVAL_MS = 5 * 60 * 1000;
17
+
18
+ interface Record {
19
+ id: number;
20
+ timestamp: string;
21
+ user_hash: string;
22
+ provider: string;
23
+ model: string;
24
+ input_tokens: number;
25
+ output_tokens: number;
26
+ cache_read_tokens: number;
27
+ cache_write_tokens: number;
28
+ total_tokens: number;
29
+ leaf_hash: string;
30
+ receipt_sig?: string;
31
+ }
32
+
33
+ interface ChainState {
34
+ merkle_root: string;
35
+ leaf_count: number;
36
+ peak_count: number;
37
+ }
38
+
39
+ interface Peak {
40
+ height: number;
41
+ hash: string;
42
+ }
43
+
44
+ // ── Crypto ──
45
+
46
+ async function sha256(input: string): Promise<string> {
47
+ const data = new TextEncoder().encode(input);
48
+ const hash = await crypto.subtle.digest("SHA-256", data);
49
+ return Array.from(new Uint8Array(hash))
50
+ .map((b) => b.toString(16).padStart(2, "0"))
51
+ .join("");
52
+ }
53
+
54
+ // ── MMR ──
55
+
56
+ function computeLeafHash(r: Record): Promise<string> {
57
+ // Model name is normalized at write time (provider/ prefix stripped)
58
+ // Use the stored model name as-is — it matches what was hashed at write time
59
+ return sha256([
60
+ r.timestamp,
61
+ r.user_hash,
62
+ r.provider,
63
+ r.model,
64
+ r.input_tokens,
65
+ r.output_tokens,
66
+ r.cache_read_tokens,
67
+ r.cache_write_tokens,
68
+ r.total_tokens,
69
+ ].join("|"));
70
+ }
71
+
72
+ async function appendToMMR(peaks: Peak[], leafHash: string): Promise<Peak[]> {
73
+ let current: Peak = { height: 0, hash: leafHash };
74
+
75
+ while (peaks.length > 0 && peaks[peaks.length - 1].height === current.height) {
76
+ const left = peaks.pop()!;
77
+ const mergedHash = await sha256(left.hash + "|" + current.hash);
78
+ current = { height: current.height + 1, hash: mergedHash };
79
+ }
80
+
81
+ peaks.push(current);
82
+ return peaks;
83
+ }
84
+
85
+ async function computeRoot(peaks: Peak[]): Promise<string> {
86
+ if (peaks.length === 0) return "empty";
87
+ if (peaks.length === 1) return peaks[0].hash;
88
+ return sha256(peaks.map((p) => p.hash).join("|"));
89
+ }
90
+
91
+ // ── HTTP ──
92
+
93
+ async function fetchJSON<T>(url: string): Promise<T> {
94
+ const res = await fetch(url);
95
+ if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
96
+ return res.json() as Promise<T>;
97
+ }
98
+
99
+ // ── Receipt Signature Verification ──
100
+
101
+ async function getGatewayPubkey(gateway: string): Promise<CryptoKey | null> {
102
+ try {
103
+ const res = await fetchJSON<{ ok: boolean; algorithm: string; pubkey: JsonWebKey }>(
104
+ `${gateway}/api/pubkey`
105
+ );
106
+ if (!res.ok || !res.pubkey) return null;
107
+ return crypto.subtle.importKey(
108
+ "jwk",
109
+ res.pubkey,
110
+ { name: "ECDSA", namedCurve: "P-256" },
111
+ false,
112
+ ["verify"]
113
+ );
114
+ } catch {
115
+ return null;
116
+ }
117
+ }
118
+
119
+ async function verifyReceiptSig(pubkey: CryptoKey, leafHash: string, sig: string): Promise<boolean> {
120
+ const sigBytes = new Uint8Array(sig.match(/.{2}/g)!.map((b) => parseInt(b, 16)));
121
+ return crypto.subtle.verify(
122
+ { name: "ECDSA", hash: "SHA-256" },
123
+ pubkey,
124
+ sigBytes,
125
+ new TextEncoder().encode(leafHash)
126
+ );
127
+ }
128
+
129
+ // ── Verification ──
130
+
131
+ async function verify(gateway: string): Promise<boolean> {
132
+ // 1. Get Merkle state from gateway
133
+ const chainRes = await fetchJSON<{ ok: boolean; data: ChainState }>(
134
+ `${gateway}/api/chain`
135
+ );
136
+ const state = chainRes.data;
137
+
138
+ if (state.leaf_count === 0) {
139
+ console.log("[sentinel] Tree is empty (no records yet). OK.");
140
+ return true;
141
+ }
142
+
143
+ console.log(`[sentinel] Gateway reports: ${state.leaf_count} records, ${state.peak_count} peaks`);
144
+ console.log(`[sentinel] Merkle root: ${state.merkle_root}`);
145
+
146
+ // 2. Fetch gateway public key for receipt signature verification
147
+ const pubkey = await getGatewayPubkey(gateway);
148
+ if (pubkey) {
149
+ console.log(`[sentinel] Gateway public key loaded — will verify receipt signatures`);
150
+ } else {
151
+ console.log(`[sentinel] No public key available — skipping receipt signature verification`);
152
+ }
153
+
154
+ // 3. Pull all records and rebuild MMR
155
+ let afterId = 0;
156
+ let verified = 0;
157
+ let sigVerified = 0;
158
+ let sigMissing = 0;
159
+ let peaks: Peak[] = [];
160
+
161
+ while (true) {
162
+ const recordsRes = await fetchJSON<{ ok: boolean; data: Record[]; has_more: boolean }>(
163
+ `${gateway}/api/records?after=${afterId}&limit=10000`
164
+ );
165
+
166
+ if (recordsRes.data.length === 0) break;
167
+
168
+ for (const r of recordsRes.data) {
169
+ // Recompute leaf hash from record fields
170
+ const computed = await computeLeafHash(r);
171
+ if (computed !== r.leaf_hash) {
172
+ console.error(`[sentinel] LEAF HASH MISMATCH at record #${r.id}`);
173
+ console.error(` Computed: ${computed}`);
174
+ console.error(` Stored: ${r.leaf_hash}`);
175
+ return false;
176
+ }
177
+
178
+ // Verify receipt signature if available
179
+ if (pubkey && r.receipt_sig) {
180
+ const sigValid = await verifyReceiptSig(pubkey, r.leaf_hash, r.receipt_sig);
181
+ if (!sigValid) {
182
+ console.error(`[sentinel] RECEIPT SIGNATURE INVALID at record #${r.id}`);
183
+ return false;
184
+ }
185
+ sigVerified++;
186
+ } else if (!r.receipt_sig) {
187
+ sigMissing++;
188
+ }
189
+
190
+ // Append to local MMR
191
+ peaks = await appendToMMR(peaks, computed);
192
+
193
+ afterId = r.id;
194
+ verified++;
195
+ }
196
+
197
+ console.log(`[sentinel] Verified ${verified} leaf hashes...`);
198
+
199
+ if (!recordsRes.has_more) break;
200
+ }
201
+
202
+ // 3. Compare roots
203
+ if (verified !== state.leaf_count) {
204
+ console.error(`[sentinel] RECORD COUNT MISMATCH`);
205
+ console.error(` Gateway claims: ${state.leaf_count}`);
206
+ console.error(` Records found: ${verified}`);
207
+ return false;
208
+ }
209
+
210
+ const computedRoot = await computeRoot(peaks);
211
+ if (computedRoot !== state.merkle_root) {
212
+ console.error(`[sentinel] MERKLE ROOT MISMATCH`);
213
+ console.error(` Computed: ${computedRoot}`);
214
+ console.error(` Gateway: ${state.merkle_root}`);
215
+ return false;
216
+ }
217
+
218
+ console.log(`[sentinel] ALL ${verified} RECORDS VERIFIED. Merkle tree intact.`);
219
+ console.log(`[sentinel] Root: ${computedRoot}`);
220
+ console.log(`[sentinel] Peaks: ${peaks.length} (heights: ${peaks.map((p) => p.height).join(", ")})`);
221
+ if (sigVerified > 0) {
222
+ console.log(`[sentinel] Receipt signatures verified: ${sigVerified}/${verified}` +
223
+ (sigMissing > 0 ? ` (${sigMissing} unsigned — pre-receipt records)` : ""));
224
+ }
225
+ return true;
226
+ }
227
+
228
+ // ── Main ──
229
+
230
+ const args = process.argv.slice(2);
231
+ const watch = args.includes("--watch");
232
+ const gatewayIdx = args.indexOf("--gateway");
233
+ const gateway = (gatewayIdx >= 0 ? args[gatewayIdx + 1] : DEFAULT_GATEWAY).replace(/\/$/, "");
234
+
235
+ console.log(`[sentinel] Gateway: ${gateway}`);
236
+ console.log(`[sentinel] Mode: ${watch ? "watch (every 5m)" : "one-shot"}`);
237
+ console.log("");
238
+
239
+ async function run() {
240
+ try {
241
+ const ok = await verify(gateway);
242
+ if (!ok) {
243
+ console.error("\n[sentinel] TAMPERING DETECTED. Data integrity compromised.");
244
+ if (!watch) process.exit(1);
245
+ }
246
+ } catch (err) {
247
+ console.error(`[sentinel] Error: ${err}`);
248
+ if (!watch) process.exit(1);
249
+ }
250
+ }
251
+
252
+ await run();
253
+
254
+ if (watch) {
255
+ setInterval(run, POLL_INTERVAL_MS);
256
+ }