sigrank 0.11.4
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 +21 -0
- package/README.md +301 -0
- package/adapters.mjs +480 -0
- package/cascade.mjs +143 -0
- package/cli.mjs +1394 -0
- package/index.mjs +82 -0
- package/keystore.mjs +142 -0
- package/narrate.mjs +65 -0
- package/package.json +45 -0
- package/sign.mjs +87 -0
- package/submit.mjs +164 -0
- package/tokenpull.mjs +282 -0
- package/tools.mjs +610 -0
- package/tui.mjs +1582 -0
package/index.mjs
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* SigRank MCP server + CLI entry point.
|
|
4
|
+
*
|
|
5
|
+
* The TUI is the whole app. Launch it and sign in inside it:
|
|
6
|
+
* npx sigrank full tabbed TUI (Dashboard / Trends / Compare /
|
|
7
|
+
* Board / Watch / Connect). Sign in on the Connect
|
|
8
|
+
* tab (paste a connect code), then [S] submits.
|
|
9
|
+
*
|
|
10
|
+
* CLI shortcuts (optional — the TUI never needs them):
|
|
11
|
+
* npx sigrank enroll sign in: redeem a connect code from signalaf.com
|
|
12
|
+
* npx sigrank submit publish your verified runs to the board
|
|
13
|
+
* npx sigrank board | me | compare | watch read / publish helpers
|
|
14
|
+
* npx sigrank --help full reference
|
|
15
|
+
*
|
|
16
|
+
* For AI clients (NOT human commands): in a piped/non-TTY context this starts an MCP
|
|
17
|
+
* stdio server; AI clients call its tools automatically. See ./tools.mjs.
|
|
18
|
+
*
|
|
19
|
+
* Pure cascade math: ./cascade.mjs | narration card: ./narrate.mjs
|
|
20
|
+
* Tool table + dispatcher: ./tools.mjs | Terminal UI: ./cli.mjs + ./tui.mjs
|
|
21
|
+
* Token-only — no transcript content.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
|
25
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
26
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
|
|
27
|
+
import { TOOLS, callTool } from './tools.mjs'
|
|
28
|
+
import { runCli } from './cli.mjs'
|
|
29
|
+
import { readFileSync } from 'node:fs'
|
|
30
|
+
|
|
31
|
+
// FIX K: read the version from package.json (was hardcoded '0.7.0' — drifted from
|
|
32
|
+
// 0.11.0). The MCP server identity now tracks the published package version.
|
|
33
|
+
function serverVersion() {
|
|
34
|
+
try {
|
|
35
|
+
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf-8'))
|
|
36
|
+
return pkg.version || '0.0.0'
|
|
37
|
+
} catch {
|
|
38
|
+
return '0.0.0'
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Prevent silent crashes — log to stderr (MCP clients read stdout; stderr is safe for
|
|
43
|
+
// diagnostics). The process exits so the client can respawn with a clean slate rather
|
|
44
|
+
// than hanging on a broken connection.
|
|
45
|
+
process.on('uncaughtException', (err) => {
|
|
46
|
+
process.stderr.write(`[sigrank-mcp] uncaughtException: ${err?.stack || err}\n`)
|
|
47
|
+
process.exit(1)
|
|
48
|
+
})
|
|
49
|
+
process.on('unhandledRejection', (reason) => {
|
|
50
|
+
process.stderr.write(`[sigrank-mcp] unhandledRejection: ${reason?.stack || reason}\n`)
|
|
51
|
+
process.exit(1)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
async function startMcpServer() {
|
|
55
|
+
const server = new Server({ name: 'sigrank', version: serverVersion() }, { capabilities: { tools: {} } })
|
|
56
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }))
|
|
57
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
58
|
+
try {
|
|
59
|
+
const out = await callTool(req.params.name, req.params.arguments)
|
|
60
|
+
return { content: [{ type: 'text', text: JSON.stringify(out, null, 2) }] }
|
|
61
|
+
} catch (e) {
|
|
62
|
+
return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true }
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
await server.connect(new StdioServerTransport())
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Route:
|
|
69
|
+
// any CLI command arg → terminal UI / CLI command
|
|
70
|
+
// no args + TTY stdout → full tabbed TUI (interactive use)
|
|
71
|
+
// no args + piped → MCP stdio server (AI client use)
|
|
72
|
+
const cliArgs = process.argv.slice(2)
|
|
73
|
+
const CLI_COMMANDS = new Set(['board', 'me', 'compare', 'tui', 'watch', 'enroll', 'submit', 'help', '--help', '-h', '--version', '-v'])
|
|
74
|
+
if (cliArgs.length > 0 && (CLI_COMMANDS.has(cliArgs[0]) || cliArgs[0].startsWith('--'))) {
|
|
75
|
+
runCli(process.argv)
|
|
76
|
+
} else if (cliArgs.length === 0 && process.stdout.isTTY) {
|
|
77
|
+
// Interactive terminal — launch the full tabbed TUI
|
|
78
|
+
const { runTui } = await import('./tui.mjs')
|
|
79
|
+
runTui()
|
|
80
|
+
} else {
|
|
81
|
+
startMcpServer()
|
|
82
|
+
}
|
package/keystore.mjs
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// keystore.mjs — local ed25519 device identity for SigRank signed submit (D7 §4.5).
|
|
2
|
+
//
|
|
3
|
+
// Persists a per-device keypair at ~/.sigrank-mcp/identity.json (dir 0700, file 0600).
|
|
4
|
+
// The PRIVATE key never leaves this machine; only the raw 32-byte public key (as
|
|
5
|
+
// "ed25519:<base64>") is sent to the server at enroll time. The device_id (uuid) is
|
|
6
|
+
// generated once and is the immutable binding target server-side.
|
|
7
|
+
//
|
|
8
|
+
// Public-key export MUST match the server's publicKeyFromAgent: generateKeyPairSync
|
|
9
|
+
// exports SPKI DER with a 12-byte prefix; we strip it to the raw 32 bytes, base64 it,
|
|
10
|
+
// and prefix "ed25519:". The server re-adds the SPKI prefix to verify. Round-trip is
|
|
11
|
+
// locked by sign.test.mjs + the canon-parity fixture.
|
|
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'
|
|
17
|
+
|
|
18
|
+
const DIR = join(homedir(), '.sigrank-mcp')
|
|
19
|
+
const PATH = join(DIR, 'identity.json')
|
|
20
|
+
|
|
21
|
+
/** SPKI DER prefix length for an ed25519 public key (12 bytes before the raw 32). */
|
|
22
|
+
const SPKI_PREFIX_LEN = 12
|
|
23
|
+
|
|
24
|
+
/** Resolve this package's version for the agent_version stamp (best-effort). */
|
|
25
|
+
function agentVersion() {
|
|
26
|
+
try {
|
|
27
|
+
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf-8'))
|
|
28
|
+
return `sigrank-mcp/${pkg.version}`
|
|
29
|
+
} catch {
|
|
30
|
+
return 'sigrank-mcp'
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function keystorePath() {
|
|
35
|
+
return PATH
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Read the stored identity, or null if absent/corrupt. */
|
|
39
|
+
export function loadIdentity() {
|
|
40
|
+
if (!existsSync(PATH)) return null
|
|
41
|
+
try {
|
|
42
|
+
return JSON.parse(readFileSync(PATH, 'utf-8'))
|
|
43
|
+
} catch {
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Write the identity with strict perms (dir 0700, file 0600). */
|
|
49
|
+
export function persistIdentity(identity) {
|
|
50
|
+
mkdirSync(DIR, { recursive: true })
|
|
51
|
+
try {
|
|
52
|
+
chmodSync(DIR, 0o700)
|
|
53
|
+
} catch {
|
|
54
|
+
/* best-effort on platforms without chmod */
|
|
55
|
+
}
|
|
56
|
+
writeFileSync(PATH, `${JSON.stringify(identity, null, 2)}\n`, { mode: 0o600 })
|
|
57
|
+
try {
|
|
58
|
+
chmodSync(PATH, 0o600)
|
|
59
|
+
} catch {
|
|
60
|
+
/* best-effort */
|
|
61
|
+
}
|
|
62
|
+
return identity
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Build a fresh keypair record (pure — no fs). Exposed for tests. */
|
|
66
|
+
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' })
|
|
71
|
+
return {
|
|
72
|
+
device_id: device_id || randomUUID(),
|
|
73
|
+
codename: null,
|
|
74
|
+
operator_id: null,
|
|
75
|
+
public_key: `ed25519:${rawPub.toString('base64')}`,
|
|
76
|
+
private_key_pkcs8_b64: Buffer.from(pkcs8).toString('base64'),
|
|
77
|
+
agent_version: agentVersion(),
|
|
78
|
+
enrolled_at: null,
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Decide the binding (codename/operator_id/enrolled_at) to carry onto a freshly
|
|
84
|
+
* generated identity. The binding is tied to device_id: if device_id is CHANGING
|
|
85
|
+
* (a new device, e.g. after a reset/re-enroll where the old file was deleted), the
|
|
86
|
+
* old binding is INVALIDATED — never preserve a stale codename/operator onto a
|
|
87
|
+
* different device. That mismatch is the "Frankenstein identity" root cause: a new
|
|
88
|
+
* device_id + an old codename → the server sees a mismatch → tags submissions
|
|
89
|
+
* `unverified` → never ranks, yet `isSignedIn` reads the local codename as present.
|
|
90
|
+
* Only reuse the binding when the SAME device_id is being kept. Pure — exposed for
|
|
91
|
+
* tests (no fs, so the owner's live identity is never at risk during a test run).
|
|
92
|
+
*/
|
|
93
|
+
export function bindingForFreshIdentity(existing, fresh) {
|
|
94
|
+
if (!existing) return { codename: null, operator_id: null, enrolled_at: null }
|
|
95
|
+
if (existing.device_id && fresh.device_id === existing.device_id) {
|
|
96
|
+
return {
|
|
97
|
+
codename: existing.codename ?? null,
|
|
98
|
+
operator_id: existing.operator_id ?? null,
|
|
99
|
+
enrolled_at: existing.enrolled_at ?? null,
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// device_id changed → the old binding belongs to a different device; drop it.
|
|
103
|
+
return { codename: null, operator_id: null, enrolled_at: null }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Load the existing identity, or generate + persist a fresh one. Idempotent: a
|
|
108
|
+
* complete existing identity is returned untouched (never rotates a live key).
|
|
109
|
+
*/
|
|
110
|
+
export function ensureIdentity() {
|
|
111
|
+
const existing = loadIdentity()
|
|
112
|
+
if (existing?.private_key_pkcs8_b64 && existing?.public_key && existing?.device_id) {
|
|
113
|
+
return existing
|
|
114
|
+
}
|
|
115
|
+
// Regeneration path: a partial/corrupt record. The binding is tied to device_id —
|
|
116
|
+
// if a new device_id is generated, the old codename/operator MUST NOT carry over
|
|
117
|
+
// (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)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Record a successful enrollment (codename/operator_id + enrolled_at) into the keystore. */
|
|
127
|
+
export function recordEnrollment({ codename, operator_id }) {
|
|
128
|
+
const id = ensureIdentity()
|
|
129
|
+
// OVERWRITE from the server's enroll response — a new enroll is a new binding, full
|
|
130
|
+
// stop. Never `??`-preserve a stale codename/operator onto this device (the other
|
|
131
|
+
// half of the Frankenstein bug: a re-enroll used to keep the old codename when the
|
|
132
|
+
// 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)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Clear the local identity (sign out). Next enroll provisions a fresh device_id. */
|
|
140
|
+
export function clearIdentity() {
|
|
141
|
+
try { if (existsSync(PATH)) unlinkSync(PATH) } catch { /* best-effort */ }
|
|
142
|
+
}
|
package/narrate.mjs
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* narrate.mjs — deterministic prose "card" for a cascade result.
|
|
3
|
+
*
|
|
4
|
+
* Port of _template() from ~/Desktop/moses-sigrank/narrate.py. The model path
|
|
5
|
+
* (MiniCPM4-0.5B) is intentionally SKIPPED: the template is the trustworthy,
|
|
6
|
+
* instant, auditable fallback — same numbers in → same card out, and it can never
|
|
7
|
+
* emit a metric the cascade didn't produce. A model hook can layer behind this same
|
|
8
|
+
* narrate() interface later without a rewrite.
|
|
9
|
+
*
|
|
10
|
+
* Token-only. No network, no randomness.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// Safe formatters: never emit NaN/Infinity/undefined into the card.
|
|
14
|
+
const safeNum = (n) => (Number.isFinite(Number(n)) ? Number(n) : null)
|
|
15
|
+
const comma = (n, dec) => {
|
|
16
|
+
const v = safeNum(n)
|
|
17
|
+
return v !== null ? v.toLocaleString('en-US', { minimumFractionDigits: dec, maximumFractionDigits: dec }) : '—'
|
|
18
|
+
}
|
|
19
|
+
const plain = (n, dec) => {
|
|
20
|
+
const v = safeNum(n)
|
|
21
|
+
return v !== null ? v.toFixed(dec) : '—'
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Given a cascade result ({ velocity, leverage, dev10x, pillars, class }) and an
|
|
26
|
+
* optional subject name, return "**CLASS.** <one or two sentences>". Deterministic.
|
|
27
|
+
*/
|
|
28
|
+
export function narrate(cascade, name = 'This operator') {
|
|
29
|
+
const klass = cascade.class || cascade.klass || 'UNCLASSED'
|
|
30
|
+
const v = safeNum(cascade.velocity)
|
|
31
|
+
const l = safeNum(cascade.leverage)
|
|
32
|
+
|
|
33
|
+
// "non-compounding" = a stateless pipe: no cache commits, so the cascade can't
|
|
34
|
+
// form. cascade.mjs leaves dev10x null when cacheCreate is 0 (the cw/o term
|
|
35
|
+
// collapses), which is exactly metrics.py's non_compounding flag.
|
|
36
|
+
// Also catches zero-input sessions where velocity/leverage are null.
|
|
37
|
+
const cw = cascade.pillars ? Number(cascade.pillars.cacheCreate) : NaN
|
|
38
|
+
const nonCompounding = cascade.dev10x == null || !(cw > 0) || v === null || l === null
|
|
39
|
+
|
|
40
|
+
let body
|
|
41
|
+
if (nonCompounding) {
|
|
42
|
+
const leverageStr = l !== null ? `Leverage ${comma(l, 1)}x comes from reuse alone.` : 'Leverage is undefined (no fresh input recorded).'
|
|
43
|
+
const dev10xNote = cascade.dev10x == null ? ' 10xDEV is undefined — the compounding loop has not formed yet.' : ''
|
|
44
|
+
body =
|
|
45
|
+
`${name} runs a stateless pipe — no cache commits, so the cascade can't form. ` +
|
|
46
|
+
`High read volume, but nothing is being built forward. ${leverageStr}${dev10xNote}`
|
|
47
|
+
} else if (v >= 1 && l >= 100) {
|
|
48
|
+
body =
|
|
49
|
+
`${name} holds both axes at once: ${plain(v, 1)}x generation AND ${comma(l, 0)}x memory leverage. ` +
|
|
50
|
+
`A closed kinetic loop — the rare operator the leverage/generation tradeoff says shouldn't exist.`
|
|
51
|
+
} else if (l >= 10 && v < 1) {
|
|
52
|
+
body =
|
|
53
|
+
`${name} is an archival sponge — ${comma(l, 0)}x reuse but only ${plain(v, 2)}x generation. ` +
|
|
54
|
+
`Holds context beautifully, executes little with it. The reuse number is inflated by a weak commitment stage.`
|
|
55
|
+
} else if (v >= 0.8 && l < 2) {
|
|
56
|
+
body =
|
|
57
|
+
`${name} is a volatile ingestor — ${plain(v, 2)}x generation but ${plain(l, 1)}x leverage. ` +
|
|
58
|
+
`Fast on single shots, resets between turns. Memory doesn't persist into a compounding loop.`
|
|
59
|
+
} else {
|
|
60
|
+
body =
|
|
61
|
+
`${name} sits low on both axes: ${plain(v, 2)}x generation, ${plain(l, 1)}x leverage. ` +
|
|
62
|
+
`A transient profile — neither building state nor converting input to output efficiently.`
|
|
63
|
+
}
|
|
64
|
+
return `**${klass}.** ${body}`
|
|
65
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "sigrank",
|
|
3
|
+
"version": "0.11.4",
|
|
4
|
+
"description": "SigRank MCP server — the yield cascade + live leaderboard as MCP tools any agent can call",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/SunrisesIllNeverSee/sigrank-mcp.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://signalaf.com",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/SunrisesIllNeverSee/sigrank-mcp/issues"
|
|
14
|
+
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"sigrank": "index.mjs",
|
|
17
|
+
"sigrank-mcp": "index.mjs"
|
|
18
|
+
},
|
|
19
|
+
"main": "index.mjs",
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=18"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"start": "node index.mjs",
|
|
25
|
+
"test": "node test.mjs && node sign.test.mjs"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"index.mjs",
|
|
29
|
+
"cli.mjs",
|
|
30
|
+
"tui.mjs",
|
|
31
|
+
"tools.mjs",
|
|
32
|
+
"cascade.mjs",
|
|
33
|
+
"narrate.mjs",
|
|
34
|
+
"tokenpull.mjs",
|
|
35
|
+
"adapters.mjs",
|
|
36
|
+
"sign.mjs",
|
|
37
|
+
"keystore.mjs",
|
|
38
|
+
"submit.mjs",
|
|
39
|
+
"README.md",
|
|
40
|
+
"LICENSE"
|
|
41
|
+
],
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@modelcontextprotocol/sdk": "^1.0.0"
|
|
44
|
+
}
|
|
45
|
+
}
|
package/sign.mjs
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// sign.mjs — canonical JSON + ed25519 signing for SigRank signed snapshot submit.
|
|
2
|
+
//
|
|
3
|
+
// BYTE-COMPATIBLE PORT of sigrank-app lib/ingest/signature.ts. This is the highest-
|
|
4
|
+
// risk file in the client: the server VERIFIES the signature over ITS recomputation
|
|
5
|
+
// of these exact canonical bytes, so a single divergent byte hard-rejects (422) every
|
|
6
|
+
// verified submission. Keep this a line-for-line mirror of the server canonicalizer:
|
|
7
|
+
// canonical JSON = recursively sorted keys, compact separators, UTF-8, with the
|
|
8
|
+
// derived agent.signature + agent.snapshot_hash stripped before serialization.
|
|
9
|
+
// snapshot_hash = "sha256:" + hex(sha256(canonical_bytes)).
|
|
10
|
+
// public key = "ed25519:<base64 of the 32 raw verify-key bytes>".
|
|
11
|
+
// signature = base64 of the 64-byte ed25519 signature over canonical_bytes.
|
|
12
|
+
// The canon-parity fixture (tests/fixtures/canon_parity.json) + sign.test.mjs + the
|
|
13
|
+
// sigrank-app canon_parity gate together lock this byte-for-byte.
|
|
14
|
+
|
|
15
|
+
import { createHash, createPrivateKey, createPublicKey, sign as edSign, verify as edVerify } from 'node:crypto'
|
|
16
|
+
|
|
17
|
+
/** Agent fields excluded from the canonical (signed/hashed) body — they derive from it. */
|
|
18
|
+
const DERIVED_AGENT_FIELDS = ['signature', 'snapshot_hash']
|
|
19
|
+
|
|
20
|
+
/** Recursively sort object keys (matches the server's Object.keys(...).sort()); arrays keep order. */
|
|
21
|
+
function sortDeep(v) {
|
|
22
|
+
if (Array.isArray(v)) return v.map(sortDeep)
|
|
23
|
+
if (v && typeof v === 'object') {
|
|
24
|
+
const out = {}
|
|
25
|
+
for (const k of Object.keys(v).sort()) out[k] = sortDeep(v[k])
|
|
26
|
+
return out
|
|
27
|
+
}
|
|
28
|
+
return v
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Canonical JSON string the agent signs/hashes (derived agent fields stripped). */
|
|
32
|
+
export function canonicalJson(payload) {
|
|
33
|
+
const clone = JSON.parse(JSON.stringify(payload))
|
|
34
|
+
const agent = clone.agent
|
|
35
|
+
if (agent && typeof agent === 'object') {
|
|
36
|
+
for (const f of DERIVED_AGENT_FIELDS) delete agent[f]
|
|
37
|
+
}
|
|
38
|
+
return JSON.stringify(sortDeep(clone))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** UTF-8 bytes of the canonical JSON — the exact bytes that get signed. */
|
|
42
|
+
export function canonicalBytes(payload) {
|
|
43
|
+
return Buffer.from(canonicalJson(payload), 'utf-8')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** "sha256:<hex>" over the canonical bytes (matches the server snapshotHash). */
|
|
47
|
+
export function snapshotHash(payload) {
|
|
48
|
+
return `sha256:${createHash('sha256').update(canonicalBytes(payload)).digest('hex')}`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** SPKI DER prefix for a raw 32-byte ed25519 public key. */
|
|
52
|
+
const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex')
|
|
53
|
+
|
|
54
|
+
/** Build a node KeyObject from an "ed25519:<base64>" (or bare base64) public key, or null. */
|
|
55
|
+
function publicKeyFrom(pk) {
|
|
56
|
+
try {
|
|
57
|
+
const body = pk.startsWith('ed25519:') ? pk.slice('ed25519:'.length) : pk
|
|
58
|
+
const raw = Buffer.from(body, 'base64')
|
|
59
|
+
if (raw.length !== 32) return null
|
|
60
|
+
return createPublicKey({ key: Buffer.concat([ED25519_SPKI_PREFIX, raw]), format: 'der', type: 'spki' })
|
|
61
|
+
} catch {
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Sign the payload's canonical bytes with the PKCS8-DER-base64 private key → base64 sig.
|
|
68
|
+
* The returned string is the X-Agent-Signature header value.
|
|
69
|
+
*/
|
|
70
|
+
export function signPayload(payload, privateKeyPkcs8B64) {
|
|
71
|
+
const key = createPrivateKey({ key: Buffer.from(privateKeyPkcs8B64, 'base64'), format: 'der', type: 'pkcs8' })
|
|
72
|
+
return edSign(null, canonicalBytes(payload), key).toString('base64')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Local verify (parity with the server's verifySignature) — used by the self-test
|
|
77
|
+
* and as a pre-send sanity check. Returns false on any malformation, never throws.
|
|
78
|
+
*/
|
|
79
|
+
export function verifyPayload(payload, signatureB64, publicKey) {
|
|
80
|
+
const key = publicKeyFrom(publicKey)
|
|
81
|
+
if (!key) return false
|
|
82
|
+
try {
|
|
83
|
+
return edVerify(null, canonicalBytes(payload), key, Buffer.from(signatureB64, 'base64'))
|
|
84
|
+
} catch {
|
|
85
|
+
return false
|
|
86
|
+
}
|
|
87
|
+
}
|
package/submit.mjs
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// submit.mjs — build + ed25519-sign a Schema 1.0 snapshot payload and POST it to the
|
|
2
|
+
// VERIFIED ingest endpoint (D7 §5). Requires an enrolled identity (keystore.codename +
|
|
3
|
+
// operator_id + private key). The server re-derives the ranked Υ from the 4 raw pillars,
|
|
4
|
+
// so the core/background metrics here are schema-valid, plausibility-clean DISPLAY inputs.
|
|
5
|
+
//
|
|
6
|
+
// raw_telemetry sessions/turns/active are conservative PROXIES: tokenpull yields the 4
|
|
7
|
+
// pillars + a message count, not session structure. They are chosen to pass the
|
|
8
|
+
// plausibility gate WITHOUT flagging (a flag downgrades verified→flagged → not ranked).
|
|
9
|
+
// TODO(tokenpull): emit real sessions_count / turns_total / active_minutes per window so
|
|
10
|
+
// the display signa_rate reflects true session shape (the ranked Υ is already exact).
|
|
11
|
+
|
|
12
|
+
import { snapshotHash, signPayload } from './sign.mjs'
|
|
13
|
+
|
|
14
|
+
const WINDOW_TYPE = { '7d': '7d', '30d': '30d', '90d': '90d', all: 'all_time' }
|
|
15
|
+
const WINDOW_SPAN_DAYS = { '7d': 7, '30d': 30, '90d': 90, all_time: 3650 }
|
|
16
|
+
const PLATFORM_ENUM = new Set(['claude', 'chatgpt', 'gemini', 'pi', 'codex', 'multi', 'other'])
|
|
17
|
+
// Provenance tag only — the board ranks by Υ (no RS.xx weights, §0.1).
|
|
18
|
+
const RULESET_VERSION = 'sigrank-token-1'
|
|
19
|
+
const DAY_MS = 86_400_000
|
|
20
|
+
|
|
21
|
+
const clamp = (x, lo, hi) => Math.max(lo, Math.min(hi, x))
|
|
22
|
+
const round = (n, dp) => {
|
|
23
|
+
const f = 10 ** dp
|
|
24
|
+
return Math.round(n * f) / f
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Map any tokenpull platform to the Schema 1.0 platform.primary enum (others → 'other'). */
|
|
28
|
+
export function toPlatformPrimary(platform) {
|
|
29
|
+
return PLATFORM_ENUM.has(platform) ? platform : 'other'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* buildPayload — Schema 1.0 payload from a tokenpull window + enrolled identity.
|
|
34
|
+
* Pure (given opts.now); agent.snapshot_hash is computed last (canonical strips it).
|
|
35
|
+
*/
|
|
36
|
+
export function buildPayload(windowKey, pillars, messages, identity, platform, opts = {}) {
|
|
37
|
+
const windowType = WINDOW_TYPE[windowKey] || windowKey
|
|
38
|
+
const spanDays = WINDOW_SPAN_DAYS[windowType] || 30
|
|
39
|
+
const nowMs = opts.now ?? Date.now()
|
|
40
|
+
|
|
41
|
+
const input = Math.max(0, Math.round(pillars?.input || 0))
|
|
42
|
+
const output = Math.max(0, Math.round(pillars?.output || 0))
|
|
43
|
+
const cacheCreate = Math.max(0, Math.round(pillars?.cacheCreate || 0))
|
|
44
|
+
const cacheRead = Math.max(0, Math.round(pillars?.cacheRead || 0))
|
|
45
|
+
const tokensTotal = input + output + cacheCreate + cacheRead
|
|
46
|
+
|
|
47
|
+
// Proxies — plausibility-clean (see file header).
|
|
48
|
+
const msgs = Math.max(0, Math.round(messages || 0))
|
|
49
|
+
const turnsTotal = Math.max(output > 0 ? 1 : 0, msgs)
|
|
50
|
+
const sessionsCount = tokensTotal > 0 ? clamp(Math.round(msgs / 8) || 1, 1, Math.max(1, turnsTotal)) : 0
|
|
51
|
+
const spanMinutes = spanDays * 1440
|
|
52
|
+
const minActive = Math.ceil(output / 19000) + 1 // keep output/active under the 20k/min flag
|
|
53
|
+
const activeMinutes = clamp(Math.max(msgs, minActive), 1, Math.max(1, spanMinutes - 1))
|
|
54
|
+
|
|
55
|
+
const compressionRatio = input + output > 0 ? clamp(output / (input + output), 0, 1) : 0
|
|
56
|
+
const sessionDepth = sessionsCount > 0 ? turnsTotal / sessionsCount : 0
|
|
57
|
+
|
|
58
|
+
const payload = {
|
|
59
|
+
schema_version: '1.0',
|
|
60
|
+
codename: identity.codename,
|
|
61
|
+
device_id: identity.device_id,
|
|
62
|
+
submitted_at: new Date(nowMs).toISOString(),
|
|
63
|
+
window: {
|
|
64
|
+
type: windowType,
|
|
65
|
+
start: new Date(nowMs - spanDays * DAY_MS).toISOString(),
|
|
66
|
+
end: new Date(nowMs).toISOString(),
|
|
67
|
+
},
|
|
68
|
+
platform: { primary: toPlatformPrimary(platform), models: [] },
|
|
69
|
+
core_metrics: {
|
|
70
|
+
compression_ratio: round(compressionRatio, 4),
|
|
71
|
+
prompt_complexity: round(clamp(sessionDepth, 0, 100), 2),
|
|
72
|
+
cross_thread_score: clamp(Math.round(cacheRead / Math.max(cacheCreate, 1)), 0, 100000),
|
|
73
|
+
session_depth_avg: round(Math.max(0, sessionDepth), 2),
|
|
74
|
+
// M.05 token_throughput muted 2026-06-26 — word-era metric the web already
|
|
75
|
+
// mutes (RS01 tt→0) + strips. Send null (RPC p_token_throughput is nullable
|
|
76
|
+
// DEFAULT NULL). raw_telemetry.tokens_total still carries the raw count.
|
|
77
|
+
token_throughput: null,
|
|
78
|
+
},
|
|
79
|
+
background_metrics: {
|
|
80
|
+
message_volume: msgs,
|
|
81
|
+
account_age_days: Math.max(1, Math.round(opts.accountAgeDays ?? 365)),
|
|
82
|
+
total_messages_lifetime: Math.max(msgs, Math.round(opts.totalMessages ?? msgs)),
|
|
83
|
+
},
|
|
84
|
+
raw_telemetry: {
|
|
85
|
+
sessions_count: sessionsCount,
|
|
86
|
+
turns_total: turnsTotal,
|
|
87
|
+
tokens_total: tokensTotal,
|
|
88
|
+
tokens_input_fresh: input,
|
|
89
|
+
tokens_output: output,
|
|
90
|
+
tokens_cache_read: cacheRead,
|
|
91
|
+
tokens_cache_creation: cacheCreate,
|
|
92
|
+
active_minutes_est: activeMinutes,
|
|
93
|
+
},
|
|
94
|
+
tier: 'free',
|
|
95
|
+
agent: {
|
|
96
|
+
version: identity.agent_version,
|
|
97
|
+
ruleset_version: RULESET_VERSION,
|
|
98
|
+
snapshot_hash: '',
|
|
99
|
+
public_key: identity.public_key,
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
payload.agent.snapshot_hash = snapshotHash(payload)
|
|
103
|
+
return payload
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* submitSignedWindow — build + sign + POST one window to /api/v1/snapshots.
|
|
108
|
+
* Returns a structured result; never throws on a network error. opts: { apiBase,
|
|
109
|
+
* fetchImpl, platform, now } (fetchImpl lets the caller inject a timeout-wrapped fetch).
|
|
110
|
+
*/
|
|
111
|
+
export async function submitSignedWindow(windowKey, pillars, messages, identity, opts = {}) {
|
|
112
|
+
if (!identity?.codename || !identity?.operator_id) {
|
|
113
|
+
return { status: 'not_enrolled', reason: 'not_enrolled', detail: 'Run `npx sigrank-mcp enroll` to bind this device first.' }
|
|
114
|
+
}
|
|
115
|
+
if (!identity?.private_key_pkcs8_b64) {
|
|
116
|
+
return { status: 'error', reason: 'no_key', detail: 'No local signing key — re-run `npx sigrank-mcp enroll`.' }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const apiBase = opts.apiBase || process.env.SIGRANK_API_BASE || 'https://signalaf.com'
|
|
120
|
+
// FIX M: default a 15s AbortController timeout when the caller doesn't inject a
|
|
121
|
+
// fetchImpl. The TUI path already wraps fetch (callTool → doFetch, 10s), but the
|
|
122
|
+
// legacy CLI `watch --submit` + the CLI default-view [S] call submitSignedWindow
|
|
123
|
+
// with no fetchImpl → bare fetch → a hung POST blocks forever. This default fixes
|
|
124
|
+
// it everywhere at once. (15s is generous for a signed POST to /api/v1/snapshots.)
|
|
125
|
+
const fetchImpl = opts.fetchImpl || ((url, init = {}) => {
|
|
126
|
+
const ctrl = new AbortController()
|
|
127
|
+
const timer = setTimeout(() => ctrl.abort(), 15_000)
|
|
128
|
+
return fetch(url, { ...init, signal: init.signal || ctrl.signal }).finally(() => clearTimeout(timer))
|
|
129
|
+
})
|
|
130
|
+
const payload = buildPayload(windowKey, pillars, messages, identity, opts.platform || 'claude', opts)
|
|
131
|
+
const signature = signPayload(payload, identity.private_key_pkcs8_b64)
|
|
132
|
+
|
|
133
|
+
let res
|
|
134
|
+
let ack
|
|
135
|
+
try {
|
|
136
|
+
res = await fetchImpl(`${apiBase}/api/v1/snapshots`, {
|
|
137
|
+
method: 'POST',
|
|
138
|
+
headers: {
|
|
139
|
+
'content-type': 'application/json',
|
|
140
|
+
accept: 'application/json',
|
|
141
|
+
'x-agent-signature': signature,
|
|
142
|
+
},
|
|
143
|
+
body: JSON.stringify(payload),
|
|
144
|
+
})
|
|
145
|
+
try {
|
|
146
|
+
ack = await res.json()
|
|
147
|
+
} catch {
|
|
148
|
+
ack = {}
|
|
149
|
+
}
|
|
150
|
+
} catch (e) {
|
|
151
|
+
return { status: 'error', reason: 'network', detail: e.message, window: WINDOW_TYPE[windowKey] || windowKey }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
status: res.ok ? ack.status || 'received' : 'error',
|
|
156
|
+
httpStatus: res.status,
|
|
157
|
+
window: WINDOW_TYPE[windowKey] || windowKey,
|
|
158
|
+
verification_tier: ack.verification_tier ?? null,
|
|
159
|
+
persisted: ack.persisted ?? null,
|
|
160
|
+
snapshot_hash: payload.agent.snapshot_hash,
|
|
161
|
+
reason: res.ok ? null : ack.reason || ack.status || `http_${res.status}`,
|
|
162
|
+
detail: ack.detail ?? null,
|
|
163
|
+
}
|
|
164
|
+
}
|