sigrank 0.16.0 → 0.17.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/connect.mjs CHANGED
@@ -4,10 +4,10 @@
4
4
 
5
5
  /** True iff this device identity is signed in (bound to an operator). */
6
6
  export function isSignedIn(id) {
7
- return !!(id && id.codename && id.operator_id)
7
+ return !!(id && id.codename && id.operator_id);
8
8
  }
9
9
 
10
10
  /** True iff `s` is a single character a connect code can contain ([A-Za-z0-9-]). */
11
11
  export function isCodeChar(s) {
12
- return typeof s === 'string' && /^[A-Za-z0-9-]$/.test(s)
12
+ return typeof s === "string" && /^[A-Za-z0-9-]$/.test(s);
13
13
  }
package/index.mjs CHANGED
@@ -21,139 +21,356 @@
21
21
  * Token-only — no transcript content.
22
22
  */
23
23
 
24
- import { Server } from '@modelcontextprotocol/sdk/server/index.js'
25
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
24
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
25
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
26
26
  import {
27
27
  CallToolRequestSchema,
28
28
  ListToolsRequestSchema,
29
29
  ListPromptsRequestSchema,
30
30
  GetPromptRequestSchema,
31
+ ListResourcesRequestSchema,
32
+ ReadResourceRequestSchema,
31
33
  McpError,
32
34
  ErrorCode,
33
- } from '@modelcontextprotocol/sdk/types.js'
34
- import { TOOLS, callTool } from './tools.mjs'
35
- import { runCli } from './cli.mjs'
36
- import { readFileSync } from 'node:fs'
35
+ } from "@modelcontextprotocol/sdk/types.js";
36
+ import { TOOLS, callTool } from "./tools.mjs";
37
+ import { runCli } from "./cli.mjs";
38
+ import { readFileSync } from "node:fs";
37
39
 
38
40
  // FIX K: read the version from package.json (was hardcoded '0.7.0' — drifted from
39
41
  // 0.11.0). The MCP server identity now tracks the published package version.
40
42
  function serverVersion() {
41
43
  try {
42
- const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf-8'))
43
- return pkg.version || '0.0.0'
44
+ const pkg = JSON.parse(
45
+ readFileSync(new URL("./package.json", import.meta.url), "utf-8"),
46
+ );
47
+ return pkg.version || "0.0.0";
44
48
  } catch {
45
- return '0.0.0'
49
+ return "0.0.0";
46
50
  }
47
51
  }
48
52
 
49
53
  // Prevent silent crashes — log to stderr (MCP clients read stdout; stderr is safe for
50
54
  // diagnostics). The process exits so the client can respawn with a clean slate rather
51
55
  // than hanging on a broken connection.
52
- process.on('uncaughtException', (err) => {
53
- process.stderr.write(`[sigrank-mcp] uncaughtException: ${err?.stack || err}\n`)
54
- process.exit(1)
55
- })
56
- process.on('unhandledRejection', (reason) => {
57
- process.stderr.write(`[sigrank-mcp] unhandledRejection: ${reason?.stack || reason}\n`)
58
- process.exit(1)
59
- })
56
+ process.on("uncaughtException", (err) => {
57
+ process.stderr.write(
58
+ `[sigrank-mcp] uncaughtException: ${err?.stack || err}\n`,
59
+ );
60
+ process.exit(1);
61
+ });
62
+ process.on("unhandledRejection", (reason) => {
63
+ process.stderr.write(
64
+ `[sigrank-mcp] unhandledRejection: ${reason?.stack || reason}\n`,
65
+ );
66
+ process.exit(1);
67
+ });
60
68
 
61
69
  async function startMcpServer() {
62
70
  const server = new Server(
63
- { name: 'sigrank', version: serverVersion() },
64
- { capabilities: { tools: {}, prompts: {} } },
65
- )
66
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }))
71
+ { name: "sigrank", version: serverVersion() },
72
+ { capabilities: { tools: {}, prompts: {}, resources: {} } },
73
+ );
74
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
75
+ tools: TOOLS,
76
+ }));
67
77
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
68
78
  // Unknown tool name = a client/host bug → JSON-RPC -32602 protocol error, per the MCP
69
79
  // spec. isError results (below) are reserved for tools that ran and failed.
70
80
  if (!TOOLS.some((t) => t.name === req.params.name)) {
71
- throw new McpError(ErrorCode.InvalidParams, `Unknown tool: ${req.params.name}`)
81
+ throw new McpError(
82
+ ErrorCode.InvalidParams,
83
+ `Unknown tool: ${req.params.name}`,
84
+ );
72
85
  }
73
86
  try {
74
- const out = await callTool(req.params.name, req.params.arguments)
75
- return { content: [{ type: 'text', text: JSON.stringify(out, null, 2) }] }
87
+ const out = await callTool(req.params.name, req.params.arguments);
88
+ return {
89
+ content: [{ type: "text", text: JSON.stringify(out, null, 2) }],
90
+ };
76
91
  } catch (e) {
77
- return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true }
92
+ return {
93
+ content: [{ type: "text", text: `Error: ${e.message}` }],
94
+ isError: true,
95
+ };
78
96
  }
79
- })
97
+ });
80
98
 
81
99
  // --- Prompts: interactive templates that agents can offer to users ---
82
100
  const PROMPTS = [
83
101
  {
84
- name: 'check-my-efficiency',
102
+ name: "check-my-efficiency",
85
103
  description:
86
- 'Pull your local token usage, compute your SigRank yield, and compare your efficiency against the leaderboard. Shows your class tier (Burner/Builder/10xer) and where you rank.',
104
+ "Pull your local token usage, compute your SigRank yield, and compare your efficiency against the leaderboard. Shows your class tier (Burner/Builder/10xer) and where you rank.",
87
105
  arguments: [],
88
106
  },
89
107
  {
90
- name: 'simulate-improvement',
108
+ name: "simulate-improvement",
91
109
  description:
92
110
  'Run a what-if analysis on your token mix: "if I increased my cache reads by 50k, how would my yield change?" Uses simulate_change to show the payoff before you change your workflow.',
93
111
  arguments: [
94
112
  {
95
- name: 'change',
96
- description: 'The change to simulate (e.g. "increase cache reads by 50000" or "reduce input by 100000")',
113
+ name: "change",
114
+ description:
115
+ 'The change to simulate (e.g. "increase cache reads by 50000" or "reduce input by 100000")',
97
116
  required: true,
98
117
  },
99
118
  ],
100
119
  },
101
120
  {
102
- name: 'compare-with-leader',
121
+ name: "compare-with-leader",
103
122
  description:
104
- 'Fetch the top operator from the SigRank leaderboard and compare their token efficiency metrics against yours. Shows the gap and what you\'d need to change to close it.',
123
+ "Fetch the top operator from the SigRank leaderboard and compare their token efficiency metrics against yours. Shows the gap and what you'd need to change to close it.",
105
124
  arguments: [],
106
125
  },
107
- ]
126
+ ];
108
127
 
109
- server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: PROMPTS }))
128
+ server.setRequestHandler(ListPromptsRequestSchema, async () => ({
129
+ prompts: PROMPTS,
130
+ }));
110
131
  server.setRequestHandler(GetPromptRequestSchema, async (req) => {
111
- const name = req.params.name
112
- const args = req.params.arguments || {}
113
- if (name === 'check-my-efficiency') {
132
+ const name = req.params.name;
133
+ const args = req.params.arguments || {};
134
+ if (name === "check-my-efficiency") {
114
135
  return {
115
136
  messages: [
116
137
  {
117
- role: 'user',
138
+ role: "user",
118
139
  content: {
119
- type: 'text',
120
- text: 'Pull my local token usage with tokenpull, compute my yield, and then fetch the leaderboard so I can see where I rank. Show me my class tier and my key metrics (Yield, Leverage, Velocity).',
140
+ type: "text",
141
+ text: "Pull my local token usage with tokenpull, compute my yield, and then fetch the leaderboard so I can see where I rank. Show me my class tier and my key metrics (Yield, Leverage, Velocity).",
121
142
  },
122
143
  },
123
144
  ],
124
- }
145
+ };
125
146
  }
126
- if (name === 'simulate-improvement') {
127
- const change = args.change || 'increase cache reads by 50000'
147
+ if (name === "simulate-improvement") {
148
+ const change = args.change || "increase cache reads by 50000";
128
149
  return {
129
150
  messages: [
130
151
  {
131
- role: 'user',
152
+ role: "user",
132
153
  content: {
133
- type: 'text',
154
+ type: "text",
134
155
  text: `First pull my local token usage with tokenpull to get my current pillars. Then use simulate_change to answer: "what if I ${change}?" Show me the yield delta and whether my class tier would change.`,
135
156
  },
136
157
  },
137
158
  ],
138
- }
159
+ };
139
160
  }
140
- if (name === 'compare-with-leader') {
161
+ if (name === "compare-with-leader") {
141
162
  return {
142
163
  messages: [
143
164
  {
144
- role: 'user',
165
+ role: "user",
145
166
  content: {
146
- type: 'text',
147
- text: 'Fetch the SigRank leaderboard with get_leaderboard, then pull my local token usage with tokenpull. Compare my yield, leverage, and velocity against the top operator. What\'s the gap and what would I need to change to close it?',
167
+ type: "text",
168
+ text: "Fetch the SigRank leaderboard with get_leaderboard, then pull my local token usage with tokenpull. Compare my yield, leverage, and velocity against the top operator. What's the gap and what would I need to change to close it?",
148
169
  },
149
170
  },
150
171
  ],
151
- }
172
+ };
152
173
  }
153
- throw new McpError(ErrorCode.InvalidParams, `Unknown prompt: ${name}`)
154
- })
174
+ throw new McpError(ErrorCode.InvalidParams, `Unknown prompt: ${name}`);
175
+ });
176
+
177
+ // --- Resources: static context agents can read without calling a tool ---
178
+ const RESOURCES = [
179
+ {
180
+ uri: "sigrank://scoring-formula",
181
+ name: "SigRank Scoring Formula",
182
+ description:
183
+ "The yield cascade formula and all derived metrics: Υ (Yield = Cache Reads × Output / Input²), SNR (signal-to-noise), Leverage (Cr/I), Velocity (O/I), 10xDEV score, and class tier thresholds (Burner / Builder / 10xer).",
184
+ mimeType: "text/markdown",
185
+ },
186
+ {
187
+ uri: "sigrank://class-tiers",
188
+ name: "Class Tier Definitions",
189
+ description:
190
+ "Definitions and thresholds for the three SigRank operator classes: Burner (raw volume), Builder (balanced efficiency), and 10xer (cascade-optimized). Includes the yield ranges that define each tier.",
191
+ mimeType: "text/markdown",
192
+ },
193
+ {
194
+ uri: "sigrank://install-guide",
195
+ name: "Quick Start Guide",
196
+ description:
197
+ "Step-by-step install and first submission: npx sigrank → enroll → submit. Covers ccusage/tokscale/tokendash bundled deps, privacy model (token counts only, never prompts), and dry-run workflow.",
198
+ mimeType: "text/markdown",
199
+ },
200
+ {
201
+ uri: "sigrank://privacy-model",
202
+ name: "Privacy Model",
203
+ description:
204
+ "How SigRank protects user privacy: runs locally, only four token counts (input, output, cacheCreate, cacheRead) leave the machine, ed25519-signed submissions, no prompt content ever transmitted.",
205
+ mimeType: "text/markdown",
206
+ },
207
+ ];
208
+
209
+ const RESOURCE_CONTENT = {
210
+ "sigrank://scoring-formula": `# SigRank Scoring Formula
211
+
212
+ ## Yield (Υ) — the headline metric
213
+
214
+ \`\`\`
215
+ Υ = Cache Reads × Output / Input²
216
+ \`\`\`
217
+
218
+ Yield rewards operators who maximize output while minimizing input — and who build cache to amortize that input across sessions. A high yield means you're getting more done with less, efficiently.
219
+
220
+ ## Derived Metrics
221
+
222
+ | Metric | Formula | Meaning |
223
+ |--------|---------|---------|
224
+ | **SNR** | Output / (Input + CacheCreate) | Signal-to-noise: how much of your token spend is productive output vs. overhead |
225
+ | **Leverage** | Cache Reads / Input | How well you reuse cached context — higher = better cache utilization |
226
+ | **Velocity** | Output / Input | Raw output efficiency — how much output you generate per token of input |
227
+ | **10xDEV** | Composite score | Weighted blend of yield, leverage, and velocity for cross-platform comparison |
228
+
229
+ ## Class Tier Thresholds
230
+
231
+ | Tier | Yield Range | Profile |
232
+ |------|-------------|---------|
233
+ | **Burner** | Υ < 1.0 | Raw volume — high input, low cache reuse, brute-force output |
234
+ | **Builder** | 1.0 ≤ Υ < 10.0 | Balanced — moderate cache, decent output efficiency |
235
+ | **10xer** | Υ ≥ 10.0 | Cascade-optimized — high cache reads, minimal input, efficient output |
236
+
237
+ The formula is deterministic and computed locally. No network calls needed for scoring.
238
+ `,
239
+
240
+ "sigrank://class-tiers": `# SigRank Class Tiers
241
+
242
+ Every operator is classified into one of three tiers based on their Yield (Υ = Cache Reads × Output / Input²).
243
+
244
+ ## Burner (Υ < 1.0)
245
+ - **Profile:** Raw volume operators. High input tokens, low cache reuse.
246
+ - **Behavior:** Brute-force — lots of context fed in, relatively little output back.
247
+ - **Typical:** New AI users, verbose prompters, no session continuity.
248
+ - **Fix:** Build cache across sessions. Stop re-explaining context. Use --continue.
249
+
250
+ ## Builder (1.0 ≤ Υ < 10.0)
251
+ - **Profile:** Balanced operators. Moderate cache, decent output efficiency.
252
+ - **Behavior:** Productive — reasonable input-to-output ratio, some cache leverage.
253
+ - **Typical:** Experienced AI coders who use CLAUDE.md, project context, and session continuity.
254
+ - **Fix:** Increase cache reads by reusing sessions. Reduce input by trimming unnecessary context.
255
+
256
+ ## 10xer (Υ ≥ 10.0)
257
+ - **Profile:** Cascade-optimized operators. High cache reads, minimal input, efficient output.
258
+ - **Behavior:** Surgical — minimal new input, maximum cache reuse, high-yield output.
259
+ - **Typical:** Power users with long-running sessions, tight context windows, and aggressive cache strategies.
260
+ - **Maintain:** Keep cache hit rate high. Avoid context bloat. Monitor with watch_tokenpull.
261
+
262
+ Tiers are recalculated on every submission. Your tier can change between windows (7d, 30d, 90d, all-time).
263
+ `,
264
+
265
+ "sigrank://install-guide": `# SigRank Quick Start
266
+
267
+ ## Install
268
+
269
+ \`\`\`bash
270
+ npx sigrank
271
+ \`\`\`
272
+
273
+ No global install needed. The MCP server starts automatically when an AI client (Claude, Cursor, Cline) connects.
274
+
275
+ ## First Submission
276
+
277
+ 1. **Enroll** — register your operator codename:
278
+ \`\`\`bash
279
+ npx sigrank enroll
280
+ \`\`\`
281
+
282
+ 2. **Pull + Submit** — scan your local AI session logs and publish:
283
+ \`\`\`bash
284
+ npx sigrank submit
285
+ \`\`\`
286
+
287
+ 3. **Dry Run** — see exactly what would be sent before publishing:
288
+ \`\`\`bash
289
+ npx sigrank submit --dry-run
290
+ \`\`\`
291
+
292
+ ## Token Pull Sources (bundled)
293
+
294
+ SigRank bundles three token readers as dependencies:
295
+ - **ccusage** — Claude Code session logs
296
+ - **tokscale** — multi-platform token telemetry
297
+ - **tokendash** — dashboard + TUI for token usage
298
+
299
+ No separate install needed. All three are called automatically by the tokenpull tools.
300
+
301
+ ## MCP Client Setup
302
+
303
+ Add to your MCP client config:
304
+ \`\`\`json
305
+ {
306
+ "mcpServers": {
307
+ "sigrank": {
308
+ "command": "npx",
309
+ "args": ["sigrank"]
310
+ }
311
+ }
312
+ }
313
+ \`\`\`
314
+
315
+ ## Live Board
316
+
317
+ Visit https://signalaf.com to see the global leaderboard.
318
+ `,
319
+
320
+ "sigrank://privacy-model": `# SigRank Privacy Model
321
+
322
+ ## What leaves your machine
323
+
324
+ **Only four numbers per submission:**
325
+ - Input tokens
326
+ - Output tokens
327
+ - Cache create tokens
328
+ - Cache read tokens
329
+
330
+ **That's it.** No prompts, no code, no file contents, no conversation text.
331
+
332
+ ## How it works
333
+
334
+ 1. **Local-first:** All token pulling happens on your machine. SigRank reads session logs from ~/.claude, ~/.codex, ~/.local/share/amp, etc.
335
+ 2. **Token counts only:** The MCP tools extract integer counts from log metadata. The actual content of your conversations is never read, parsed, or transmitted.
336
+ 3. **Signed submission:** Submissions are ed25519-signed. The board verifies authenticity without seeing your data.
337
+ 4. **No auth required:** No API keys, no OAuth, no account needed to read the leaderboard. Enrollment only requires a codename.
338
+
339
+ ## What SigRank can NOT see
340
+
341
+ - Your prompts or messages
342
+ - Your code or file contents
343
+ - Your tool calls or their results
344
+ - Which AI platform you use (beyond token counts)
345
+ - Your identity (only your chosen codename)
346
+
347
+ ## Verification
348
+
349
+ 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.
350
+ `,
351
+ };
352
+
353
+ server.setRequestHandler(ListResourcesRequestSchema, async () => ({
354
+ resources: RESOURCES,
355
+ }));
356
+ server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
357
+ const uri = req.params.uri;
358
+ const content = RESOURCE_CONTENT[uri];
359
+ if (!content) {
360
+ throw new McpError(ErrorCode.InvalidParams, `Unknown resource: ${uri}`);
361
+ }
362
+ return {
363
+ contents: [
364
+ {
365
+ uri,
366
+ mimeType: "text/markdown",
367
+ text: content,
368
+ },
369
+ ],
370
+ };
371
+ });
155
372
 
156
- await server.connect(new StdioServerTransport())
373
+ await server.connect(new StdioServerTransport());
157
374
  }
158
375
 
159
376
  // Route:
@@ -161,13 +378,13 @@ async function startMcpServer() {
161
378
  // so a typo never silently starts a hung MCP stdio server on a TTY)
162
379
  // no args + TTY stdout → full tabbed TUI (interactive use)
163
380
  // no args + piped → MCP stdio server (AI client use)
164
- const cliArgs = process.argv.slice(2)
381
+ const cliArgs = process.argv.slice(2);
165
382
  if (cliArgs.length > 0) {
166
- runCli(process.argv)
383
+ runCli(process.argv);
167
384
  } else if (process.stdout.isTTY) {
168
385
  // Interactive terminal — launch the full tabbed TUI
169
- const { runTui } = await import('./tui.mjs')
170
- runTui()
386
+ const { runTui } = await import("./tui.mjs");
387
+ runTui();
171
388
  } else {
172
- startMcpServer()
389
+ startMcpServer();
173
390
  }
package/keystore.mjs CHANGED
@@ -10,73 +10,84 @@
10
10
  // and prefix "ed25519:". The server re-adds the SPKI prefix to verify. Round-trip is
11
11
  // locked by sign.test.mjs + the canon-parity fixture.
12
12
 
13
- import { homedir } from 'node:os'
14
- import { join } from 'node:path'
15
- import { mkdirSync, readFileSync, writeFileSync, existsSync, chmodSync, unlinkSync } from 'node:fs'
16
- import { generateKeyPairSync, randomUUID } from 'node:crypto'
13
+ import { homedir } from "node:os";
14
+ import { join } from "node:path";
15
+ import {
16
+ mkdirSync,
17
+ readFileSync,
18
+ writeFileSync,
19
+ existsSync,
20
+ chmodSync,
21
+ unlinkSync,
22
+ } from "node:fs";
23
+ import { generateKeyPairSync, randomUUID } from "node:crypto";
17
24
 
18
- const DIR = join(homedir(), '.sigrank-mcp')
19
- const PATH = join(DIR, 'identity.json')
25
+ const DIR = join(homedir(), ".sigrank-mcp");
26
+ const PATH = join(DIR, "identity.json");
20
27
 
21
28
  /** SPKI DER prefix length for an ed25519 public key (12 bytes before the raw 32). */
22
- const SPKI_PREFIX_LEN = 12
29
+ const SPKI_PREFIX_LEN = 12;
23
30
 
24
31
  /** Resolve this package's version for the agent_version stamp (best-effort). */
25
32
  function agentVersion() {
26
33
  try {
27
- const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf-8'))
28
- return `sigrank-mcp/${pkg.version}`
34
+ const pkg = JSON.parse(
35
+ readFileSync(new URL("./package.json", import.meta.url), "utf-8"),
36
+ );
37
+ return `sigrank-mcp/${pkg.version}`;
29
38
  } catch {
30
- return 'sigrank-mcp'
39
+ return "sigrank-mcp";
31
40
  }
32
41
  }
33
42
 
34
43
  export function keystorePath() {
35
- return PATH
44
+ return PATH;
36
45
  }
37
46
 
38
47
  /** Read the stored identity, or null if absent/corrupt. */
39
48
  export function loadIdentity() {
40
- if (!existsSync(PATH)) return null
49
+ if (!existsSync(PATH)) return null;
41
50
  try {
42
- return JSON.parse(readFileSync(PATH, 'utf-8'))
51
+ return JSON.parse(readFileSync(PATH, "utf-8"));
43
52
  } catch {
44
- return null
53
+ return null;
45
54
  }
46
55
  }
47
56
 
48
57
  /** Write the identity with strict perms (dir 0700, file 0600). */
49
58
  export function persistIdentity(identity) {
50
- mkdirSync(DIR, { recursive: true })
59
+ mkdirSync(DIR, { recursive: true });
51
60
  try {
52
- chmodSync(DIR, 0o700)
61
+ chmodSync(DIR, 0o700);
53
62
  } catch {
54
63
  /* best-effort on platforms without chmod */
55
64
  }
56
- writeFileSync(PATH, `${JSON.stringify(identity, null, 2)}\n`, { mode: 0o600 })
65
+ writeFileSync(PATH, `${JSON.stringify(identity, null, 2)}\n`, {
66
+ mode: 0o600,
67
+ });
57
68
  try {
58
- chmodSync(PATH, 0o600)
69
+ chmodSync(PATH, 0o600);
59
70
  } catch {
60
71
  /* best-effort */
61
72
  }
62
- return identity
73
+ return identity;
63
74
  }
64
75
 
65
76
  /** Build a fresh keypair record (pure — no fs). Exposed for tests. */
66
77
  export function generateIdentity({ device_id } = {}) {
67
- const { publicKey, privateKey } = generateKeyPairSync('ed25519')
68
- const spki = publicKey.export({ type: 'spki', format: 'der' })
69
- const rawPub = Buffer.from(spki).subarray(SPKI_PREFIX_LEN) // the raw 32 bytes
70
- const pkcs8 = privateKey.export({ type: 'pkcs8', format: 'der' })
78
+ const { publicKey, privateKey } = generateKeyPairSync("ed25519");
79
+ const spki = publicKey.export({ type: "spki", format: "der" });
80
+ const rawPub = Buffer.from(spki).subarray(SPKI_PREFIX_LEN); // the raw 32 bytes
81
+ const pkcs8 = privateKey.export({ type: "pkcs8", format: "der" });
71
82
  return {
72
83
  device_id: device_id || randomUUID(),
73
84
  codename: null,
74
85
  operator_id: null,
75
- public_key: `ed25519:${rawPub.toString('base64')}`,
76
- private_key_pkcs8_b64: Buffer.from(pkcs8).toString('base64'),
86
+ public_key: `ed25519:${rawPub.toString("base64")}`,
87
+ private_key_pkcs8_b64: Buffer.from(pkcs8).toString("base64"),
77
88
  agent_version: agentVersion(),
78
89
  enrolled_at: null,
79
- }
90
+ };
80
91
  }
81
92
 
82
93
  /**
@@ -91,16 +102,17 @@ export function generateIdentity({ device_id } = {}) {
91
102
  * tests (no fs, so the owner's live identity is never at risk during a test run).
92
103
  */
93
104
  export function bindingForFreshIdentity(existing, fresh) {
94
- if (!existing) return { codename: null, operator_id: null, enrolled_at: null }
105
+ if (!existing)
106
+ return { codename: null, operator_id: null, enrolled_at: null };
95
107
  if (existing.device_id && fresh.device_id === existing.device_id) {
96
108
  return {
97
109
  codename: existing.codename ?? null,
98
110
  operator_id: existing.operator_id ?? null,
99
111
  enrolled_at: existing.enrolled_at ?? null,
100
- }
112
+ };
101
113
  }
102
114
  // device_id changed → the old binding belongs to a different device; drop it.
103
- return { codename: null, operator_id: null, enrolled_at: null }
115
+ return { codename: null, operator_id: null, enrolled_at: null };
104
116
  }
105
117
 
106
118
  /**
@@ -108,35 +120,43 @@ export function bindingForFreshIdentity(existing, fresh) {
108
120
  * complete existing identity is returned untouched (never rotates a live key).
109
121
  */
110
122
  export function ensureIdentity() {
111
- const existing = loadIdentity()
112
- if (existing?.private_key_pkcs8_b64 && existing?.public_key && existing?.device_id) {
113
- return existing
123
+ const existing = loadIdentity();
124
+ if (
125
+ existing?.private_key_pkcs8_b64 &&
126
+ existing?.public_key &&
127
+ existing?.device_id
128
+ ) {
129
+ return existing;
114
130
  }
115
131
  // Regeneration path: a partial/corrupt record. The binding is tied to device_id —
116
132
  // if a new device_id is generated, the old codename/operator MUST NOT carry over
117
133
  // (bindingForFreshIdentity drops them). Only a reused device_id keeps its binding.
118
- const fresh = generateIdentity({ device_id: existing?.device_id })
119
- const binding = bindingForFreshIdentity(existing, fresh)
120
- fresh.codename = binding.codename
121
- fresh.operator_id = binding.operator_id
122
- fresh.enrolled_at = binding.enrolled_at
123
- return persistIdentity(fresh)
134
+ const fresh = generateIdentity({ device_id: existing?.device_id });
135
+ const binding = bindingForFreshIdentity(existing, fresh);
136
+ fresh.codename = binding.codename;
137
+ fresh.operator_id = binding.operator_id;
138
+ fresh.enrolled_at = binding.enrolled_at;
139
+ return persistIdentity(fresh);
124
140
  }
125
141
 
126
142
  /** Record a successful enrollment (codename/operator_id + enrolled_at) into the keystore. */
127
143
  export function recordEnrollment({ codename, operator_id }) {
128
- const id = ensureIdentity()
144
+ const id = ensureIdentity();
129
145
  // OVERWRITE from the server's enroll response — a new enroll is a new binding, full
130
146
  // stop. Never `??`-preserve a stale codename/operator onto this device (the other
131
147
  // half of the Frankenstein bug: a re-enroll used to keep the old codename when the
132
148
  // new one was absent). A 201 enrolled always carries both.
133
- id.codename = codename ?? null
134
- id.operator_id = operator_id ?? null
135
- id.enrolled_at = new Date().toISOString()
136
- return persistIdentity(id)
149
+ id.codename = codename ?? null;
150
+ id.operator_id = operator_id ?? null;
151
+ id.enrolled_at = new Date().toISOString();
152
+ return persistIdentity(id);
137
153
  }
138
154
 
139
155
  /** Clear the local identity (sign out). Next enroll provisions a fresh device_id. */
140
156
  export function clearIdentity() {
141
- try { if (existsSync(PATH)) unlinkSync(PATH) } catch { /* best-effort */ }
157
+ try {
158
+ if (existsSync(PATH)) unlinkSync(PATH);
159
+ } catch {
160
+ /* best-effort */
161
+ }
142
162
  }