sigrank 0.15.5 → 0.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +132 -77
- package/adapters.mjs +508 -240
- package/badges.mjs +264 -0
- package/cascade.mjs +185 -54
- package/cli.mjs +1311 -893
- package/connect.mjs +2 -2
- package/index.mjs +78 -59
- package/keystore.mjs +64 -44
- package/narrate.mjs +34 -22
- package/package.json +4 -2
- package/preflight.mjs +191 -0
- package/sign.mjs +46 -27
- package/submit.mjs +166 -62
- package/tokenpull.mjs +337 -188
- package/tools.mjs +2154 -534
- package/tui.mjs +1860 -1081
package/preflight.mjs
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* preflight.mjs — local plausibility pre-checks for the MCP submit path.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors ONLY the server's public plausibility gate (gates.ts:86-137) —
|
|
5
|
+
* totals consistency, turns/sessions ratios, output rate, cache ratio,
|
|
6
|
+
* cadence. These are integrity guards labeled "NOT the proprietary RS.xx"
|
|
7
|
+
* and are safe to replicate in an open agent.
|
|
8
|
+
*
|
|
9
|
+
* The proprietary battery (Benford / cadence / contamination) is SERVER-ONLY
|
|
10
|
+
* and must NOT be shipped in the agent. The server always runs its own
|
|
11
|
+
* battery after the POST — this preflight is a client-side preview of the
|
|
12
|
+
* public plausibility checks only, not a replacement for the full gate chain.
|
|
13
|
+
*
|
|
14
|
+
* If preflight flags, the operator sees WHY their submission would be
|
|
15
|
+
* rejected or flagged, and can fix the issue or submit anyway.
|
|
16
|
+
*
|
|
17
|
+
* Source of truth: lib/ingest/gates.ts (plausibilityGate). This port must
|
|
18
|
+
* stay in sync with the plausibility gate ONLY.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
// ── plausibility checks (from gates.ts:86-137) ──────────────────────────────
|
|
22
|
+
|
|
23
|
+
const GATE_LIMITS = {
|
|
24
|
+
TOTALS_TOLERANCE_FRAC: 0.01,
|
|
25
|
+
MAX_OUTPUT_TOKENS_PER_MIN: 20_000,
|
|
26
|
+
// Tightened range-plausibility bounds (deviewreview3)
|
|
27
|
+
MAX_CACHE_REUSE_RATIO: 35,
|
|
28
|
+
MIN_CACHE_WRITE_RATIO: 0.5,
|
|
29
|
+
MIN_INPUT_SHARE_FRAC: 0.0003,
|
|
30
|
+
MAX_CADENCE_PER_MIN: 15,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Run plausibility checks against a payload's raw_telemetry.
|
|
35
|
+
* Returns array of issues: { severity, code, detail }
|
|
36
|
+
*/
|
|
37
|
+
export function plausibilityCheck(rt, window) {
|
|
38
|
+
const out = [];
|
|
39
|
+
const pillars =
|
|
40
|
+
rt.tokens_input_fresh +
|
|
41
|
+
rt.tokens_output +
|
|
42
|
+
rt.tokens_cache_read +
|
|
43
|
+
rt.tokens_cache_creation;
|
|
44
|
+
|
|
45
|
+
if (rt.tokens_total > 0) {
|
|
46
|
+
const tol = Math.max(
|
|
47
|
+
1,
|
|
48
|
+
rt.tokens_total * GATE_LIMITS.TOTALS_TOLERANCE_FRAC,
|
|
49
|
+
);
|
|
50
|
+
if (Math.abs(pillars - rt.tokens_total) > tol) {
|
|
51
|
+
out.push({
|
|
52
|
+
severity: "reject",
|
|
53
|
+
code: "totals_inconsistent",
|
|
54
|
+
detail: `Σ4 pillars (${pillars}) ≠ tokens_total (${rt.tokens_total})`,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
if (rt.sessions_count === 0) {
|
|
58
|
+
out.push({
|
|
59
|
+
severity: "reject",
|
|
60
|
+
code: "tokens_without_sessions",
|
|
61
|
+
detail: `tokens_total ${rt.tokens_total} with sessions_count 0`,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (rt.turns_total < rt.sessions_count) {
|
|
66
|
+
out.push({
|
|
67
|
+
severity: "reject",
|
|
68
|
+
code: "turns_lt_sessions",
|
|
69
|
+
detail: `turns_total ${rt.turns_total} < sessions_count ${rt.sessions_count}`,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
if (rt.tokens_output > 0 && rt.turns_total === 0) {
|
|
73
|
+
out.push({
|
|
74
|
+
severity: "reject",
|
|
75
|
+
code: "output_without_turns",
|
|
76
|
+
detail: `tokens_output ${rt.tokens_output} with turns_total 0`,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (window) {
|
|
81
|
+
const spanMin =
|
|
82
|
+
(Date.parse(window.end) - Date.parse(window.start)) / 60_000;
|
|
83
|
+
if (Number.isFinite(spanMin) && rt.active_minutes_est > spanMin + 1) {
|
|
84
|
+
out.push({
|
|
85
|
+
severity: "flag",
|
|
86
|
+
code: "active_exceeds_window",
|
|
87
|
+
detail: `active_minutes_est ${rt.active_minutes_est} > window span ${Math.round(spanMin)}m`,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const outPerMin = rt.tokens_output / Math.max(rt.active_minutes_est, 1);
|
|
93
|
+
if (outPerMin > GATE_LIMITS.MAX_OUTPUT_TOKENS_PER_MIN) {
|
|
94
|
+
out.push({
|
|
95
|
+
severity: "flag",
|
|
96
|
+
code: "implausible_output_rate",
|
|
97
|
+
detail: `${Math.round(outPerMin)} output tok/min > ${GATE_LIMITS.MAX_OUTPUT_TOKENS_PER_MIN}`,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Cross-field ratio checks (defense-in-depth — also in the plausibility gate)
|
|
102
|
+
// Bounds tightened (deviewreview3): original 100:1 + 50/min were too loose.
|
|
103
|
+
if (rt.tokens_cache_read > 1_000 && rt.tokens_cache_creation === 0) {
|
|
104
|
+
out.push({
|
|
105
|
+
severity: "flag",
|
|
106
|
+
code: "cache_without_creation",
|
|
107
|
+
detail: `${rt.tokens_cache_read} cache_read with 0 cache_creation (impossible cascade)`,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
if (
|
|
111
|
+
rt.tokens_cache_creation > 0 &&
|
|
112
|
+
rt.tokens_cache_read / rt.tokens_cache_creation >
|
|
113
|
+
GATE_LIMITS.MAX_CACHE_REUSE_RATIO
|
|
114
|
+
) {
|
|
115
|
+
out.push({
|
|
116
|
+
severity: "flag",
|
|
117
|
+
code: "extreme_cache_ratio",
|
|
118
|
+
detail: `cache_read/cache_creation = ${(rt.tokens_cache_read / rt.tokens_cache_creation).toFixed(1)}:1 (real max ~30:1)`,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
if (
|
|
122
|
+
rt.tokens_output > 1_000 &&
|
|
123
|
+
rt.tokens_cache_creation / rt.tokens_output <
|
|
124
|
+
GATE_LIMITS.MIN_CACHE_WRITE_RATIO
|
|
125
|
+
) {
|
|
126
|
+
out.push({
|
|
127
|
+
severity: "flag",
|
|
128
|
+
code: "low_cache_write_ratio",
|
|
129
|
+
detail: `cache_creation/output = ${(rt.tokens_cache_creation / rt.tokens_output).toFixed(2)}:1 (real min ~1.5:1)`,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
if (
|
|
133
|
+
pillars > 10_000 &&
|
|
134
|
+
rt.tokens_input_fresh / pillars < GATE_LIMITS.MIN_INPUT_SHARE_FRAC
|
|
135
|
+
) {
|
|
136
|
+
out.push({
|
|
137
|
+
severity: "flag",
|
|
138
|
+
code: "implausible_input_share",
|
|
139
|
+
detail: `input is ${((rt.tokens_input_fresh / pillars) * 100).toFixed(3)}% of total (real min ~0.3%)`,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
if (
|
|
143
|
+
rt.active_minutes_est > 0 &&
|
|
144
|
+
rt.turns_total / rt.active_minutes_est > GATE_LIMITS.MAX_CADENCE_PER_MIN
|
|
145
|
+
) {
|
|
146
|
+
out.push({
|
|
147
|
+
severity: "flag",
|
|
148
|
+
code: "implausible_cadence",
|
|
149
|
+
detail: `${(rt.turns_total / rt.active_minutes_est).toFixed(1)} turns/min (real: 0.5-10)`,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── full preflight (plausibility only — NO battery) ─────────────────────────
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Run preflight plausibility checks against a payload. Returns:
|
|
160
|
+
* { pass: true, issues: [] } — clean submission
|
|
161
|
+
* { pass: false, issues: [...], wouldDowngrade } — would be flagged/rejected
|
|
162
|
+
*
|
|
163
|
+
* NOTE: this only mirrors the PUBLIC plausibility gate. The server runs
|
|
164
|
+
* additional proprietary checks (the battery) that this preflight does NOT
|
|
165
|
+
* replicate. A "pass" here means "passes the plausibility gate," not
|
|
166
|
+
* "guaranteed to pass all server gates."
|
|
167
|
+
*/
|
|
168
|
+
export function preflight(payload) {
|
|
169
|
+
const issues = plausibilityCheck(payload.raw_telemetry, payload.window);
|
|
170
|
+
|
|
171
|
+
const hasReject = issues.some((i) => i.severity === "reject");
|
|
172
|
+
const hasFlag = issues.some((i) => i.severity === "flag");
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
pass: issues.length === 0,
|
|
176
|
+
wouldReject: hasReject,
|
|
177
|
+
wouldDowngrade: hasFlag,
|
|
178
|
+
issues,
|
|
179
|
+
summary: hasReject
|
|
180
|
+
? `REJECT: ${issues
|
|
181
|
+
.filter((i) => i.severity === "reject")
|
|
182
|
+
.map((i) => i.code)
|
|
183
|
+
.join(", ")}`
|
|
184
|
+
: hasFlag
|
|
185
|
+
? `FLAG (verified → flagged, not ranked): ${issues
|
|
186
|
+
.filter((i) => i.severity === "flag")
|
|
187
|
+
.map((i) => i.code)
|
|
188
|
+
.join(", ")}`
|
|
189
|
+
: "clean — passes plausibility gate (server runs additional proprietary checks)",
|
|
190
|
+
};
|
|
191
|
+
}
|
package/sign.mjs
CHANGED
|
@@ -12,54 +12,64 @@
|
|
|
12
12
|
// The canon-parity fixture (tests/fixtures/canon_parity.json) + sign.test.mjs + the
|
|
13
13
|
// sigrank-app canon_parity gate together lock this byte-for-byte.
|
|
14
14
|
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
createHash,
|
|
17
|
+
createPrivateKey,
|
|
18
|
+
createPublicKey,
|
|
19
|
+
sign as edSign,
|
|
20
|
+
verify as edVerify,
|
|
21
|
+
} from "node:crypto";
|
|
16
22
|
|
|
17
23
|
/** Agent fields excluded from the canonical (signed/hashed) body — they derive from it. */
|
|
18
|
-
const DERIVED_AGENT_FIELDS = [
|
|
24
|
+
const DERIVED_AGENT_FIELDS = ["signature", "snapshot_hash"];
|
|
19
25
|
|
|
20
26
|
/** Recursively sort object keys (matches the server's Object.keys(...).sort()); arrays keep order. */
|
|
21
27
|
function sortDeep(v) {
|
|
22
|
-
if (Array.isArray(v)) return v.map(sortDeep)
|
|
23
|
-
if (v && typeof v ===
|
|
24
|
-
const out = {}
|
|
25
|
-
for (const k of Object.keys(v).sort()) out[k] = sortDeep(v[k])
|
|
26
|
-
return out
|
|
28
|
+
if (Array.isArray(v)) return v.map(sortDeep);
|
|
29
|
+
if (v && typeof v === "object") {
|
|
30
|
+
const out = {};
|
|
31
|
+
for (const k of Object.keys(v).sort()) out[k] = sortDeep(v[k]);
|
|
32
|
+
return out;
|
|
27
33
|
}
|
|
28
|
-
return v
|
|
34
|
+
return v;
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
/** Canonical JSON string the agent signs/hashes (derived agent fields stripped). */
|
|
32
38
|
export function canonicalJson(payload) {
|
|
33
|
-
const clone = JSON.parse(JSON.stringify(payload))
|
|
34
|
-
const agent = clone.agent
|
|
35
|
-
if (agent && typeof agent ===
|
|
36
|
-
for (const f of DERIVED_AGENT_FIELDS) delete agent[f]
|
|
39
|
+
const clone = JSON.parse(JSON.stringify(payload));
|
|
40
|
+
const agent = clone.agent;
|
|
41
|
+
if (agent && typeof agent === "object") {
|
|
42
|
+
for (const f of DERIVED_AGENT_FIELDS) delete agent[f];
|
|
37
43
|
}
|
|
38
|
-
return JSON.stringify(sortDeep(clone))
|
|
44
|
+
return JSON.stringify(sortDeep(clone));
|
|
39
45
|
}
|
|
40
46
|
|
|
41
47
|
/** UTF-8 bytes of the canonical JSON — the exact bytes that get signed. */
|
|
42
48
|
export function canonicalBytes(payload) {
|
|
43
|
-
return Buffer.from(canonicalJson(payload),
|
|
49
|
+
return Buffer.from(canonicalJson(payload), "utf-8");
|
|
44
50
|
}
|
|
45
51
|
|
|
46
52
|
/** "sha256:<hex>" over the canonical bytes (matches the server snapshotHash). */
|
|
47
53
|
export function snapshotHash(payload) {
|
|
48
|
-
return `sha256:${createHash(
|
|
54
|
+
return `sha256:${createHash("sha256").update(canonicalBytes(payload)).digest("hex")}`;
|
|
49
55
|
}
|
|
50
56
|
|
|
51
57
|
/** SPKI DER prefix for a raw 32-byte ed25519 public key. */
|
|
52
|
-
const ED25519_SPKI_PREFIX = Buffer.from(
|
|
58
|
+
const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
|
|
53
59
|
|
|
54
60
|
/** Build a node KeyObject from an "ed25519:<base64>" (or bare base64) public key, or null. */
|
|
55
61
|
function publicKeyFrom(pk) {
|
|
56
62
|
try {
|
|
57
|
-
const body = pk.startsWith(
|
|
58
|
-
const raw = Buffer.from(body,
|
|
59
|
-
if (raw.length !== 32) return null
|
|
60
|
-
return createPublicKey({
|
|
63
|
+
const body = pk.startsWith("ed25519:") ? pk.slice("ed25519:".length) : pk;
|
|
64
|
+
const raw = Buffer.from(body, "base64");
|
|
65
|
+
if (raw.length !== 32) return null;
|
|
66
|
+
return createPublicKey({
|
|
67
|
+
key: Buffer.concat([ED25519_SPKI_PREFIX, raw]),
|
|
68
|
+
format: "der",
|
|
69
|
+
type: "spki",
|
|
70
|
+
});
|
|
61
71
|
} catch {
|
|
62
|
-
return null
|
|
72
|
+
return null;
|
|
63
73
|
}
|
|
64
74
|
}
|
|
65
75
|
|
|
@@ -68,8 +78,12 @@ function publicKeyFrom(pk) {
|
|
|
68
78
|
* The returned string is the X-Agent-Signature header value.
|
|
69
79
|
*/
|
|
70
80
|
export function signPayload(payload, privateKeyPkcs8B64) {
|
|
71
|
-
const key = createPrivateKey({
|
|
72
|
-
|
|
81
|
+
const key = createPrivateKey({
|
|
82
|
+
key: Buffer.from(privateKeyPkcs8B64, "base64"),
|
|
83
|
+
format: "der",
|
|
84
|
+
type: "pkcs8",
|
|
85
|
+
});
|
|
86
|
+
return edSign(null, canonicalBytes(payload), key).toString("base64");
|
|
73
87
|
}
|
|
74
88
|
|
|
75
89
|
/**
|
|
@@ -77,11 +91,16 @@ export function signPayload(payload, privateKeyPkcs8B64) {
|
|
|
77
91
|
* and as a pre-send sanity check. Returns false on any malformation, never throws.
|
|
78
92
|
*/
|
|
79
93
|
export function verifyPayload(payload, signatureB64, publicKey) {
|
|
80
|
-
const key = publicKeyFrom(publicKey)
|
|
81
|
-
if (!key) return false
|
|
94
|
+
const key = publicKeyFrom(publicKey);
|
|
95
|
+
if (!key) return false;
|
|
82
96
|
try {
|
|
83
|
-
return edVerify(
|
|
97
|
+
return edVerify(
|
|
98
|
+
null,
|
|
99
|
+
canonicalBytes(payload),
|
|
100
|
+
key,
|
|
101
|
+
Buffer.from(signatureB64, "base64"),
|
|
102
|
+
);
|
|
84
103
|
} catch {
|
|
85
|
-
return false
|
|
104
|
+
return false;
|
|
86
105
|
}
|
|
87
106
|
}
|
package/submit.mjs
CHANGED
|
@@ -9,54 +9,78 @@
|
|
|
9
9
|
// TODO(tokenpull): emit real sessions_count / turns_total / active_minutes per window so
|
|
10
10
|
// the display signa_rate reflects true session shape (the ranked Υ is already exact).
|
|
11
11
|
|
|
12
|
-
import { snapshotHash, signPayload } from
|
|
12
|
+
import { snapshotHash, signPayload } from "./sign.mjs";
|
|
13
|
+
import { preflight } from "./preflight.mjs";
|
|
13
14
|
|
|
14
|
-
const WINDOW_TYPE = {
|
|
15
|
-
const WINDOW_SPAN_DAYS = {
|
|
16
|
-
const PLATFORM_ENUM = new Set([
|
|
15
|
+
const WINDOW_TYPE = { "7d": "7d", "30d": "30d", "90d": "90d", all: "all_time" };
|
|
16
|
+
const WINDOW_SPAN_DAYS = { "7d": 7, "30d": 30, "90d": 90, all_time: 3650 };
|
|
17
|
+
const PLATFORM_ENUM = new Set([
|
|
18
|
+
"claude",
|
|
19
|
+
"chatgpt",
|
|
20
|
+
"gemini",
|
|
21
|
+
"pi",
|
|
22
|
+
"codex",
|
|
23
|
+
"multi",
|
|
24
|
+
"other",
|
|
25
|
+
]);
|
|
17
26
|
// Provenance tag only — the board ranks by Υ (no RS.xx weights, §0.1).
|
|
18
|
-
const RULESET_VERSION =
|
|
19
|
-
const DAY_MS = 86_400_000
|
|
27
|
+
const RULESET_VERSION = "sigrank-token-1";
|
|
28
|
+
const DAY_MS = 86_400_000;
|
|
20
29
|
|
|
21
|
-
const clamp = (x, lo, hi) => Math.max(lo, Math.min(hi, x))
|
|
30
|
+
const clamp = (x, lo, hi) => Math.max(lo, Math.min(hi, x));
|
|
22
31
|
const round = (n, dp) => {
|
|
23
|
-
const f = 10 ** dp
|
|
24
|
-
return Math.round(n * f) / f
|
|
25
|
-
}
|
|
32
|
+
const f = 10 ** dp;
|
|
33
|
+
return Math.round(n * f) / f;
|
|
34
|
+
};
|
|
26
35
|
|
|
27
36
|
/** Map any tokenpull platform to the Schema 1.0 platform.primary enum (others → 'other'). */
|
|
28
37
|
export function toPlatformPrimary(platform) {
|
|
29
|
-
return PLATFORM_ENUM.has(platform) ? platform :
|
|
38
|
+
return PLATFORM_ENUM.has(platform) ? platform : "other";
|
|
30
39
|
}
|
|
31
40
|
|
|
32
41
|
/**
|
|
33
42
|
* buildPayload — Schema 1.0 payload from a tokenpull window + enrolled identity.
|
|
34
43
|
* Pure (given opts.now); agent.snapshot_hash is computed last (canonical strips it).
|
|
35
44
|
*/
|
|
36
|
-
export function buildPayload(
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
45
|
+
export function buildPayload(
|
|
46
|
+
windowKey,
|
|
47
|
+
pillars,
|
|
48
|
+
messages,
|
|
49
|
+
identity,
|
|
50
|
+
platform,
|
|
51
|
+
opts = {},
|
|
52
|
+
) {
|
|
53
|
+
const windowType = WINDOW_TYPE[windowKey] || windowKey;
|
|
54
|
+
const spanDays = WINDOW_SPAN_DAYS[windowType] || 30;
|
|
55
|
+
const nowMs = opts.now ?? Date.now();
|
|
40
56
|
|
|
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
|
|
57
|
+
const input = Math.max(0, Math.round(pillars?.input || 0));
|
|
58
|
+
const output = Math.max(0, Math.round(pillars?.output || 0));
|
|
59
|
+
const cacheCreate = Math.max(0, Math.round(pillars?.cacheCreate || 0));
|
|
60
|
+
const cacheRead = Math.max(0, Math.round(pillars?.cacheRead || 0));
|
|
61
|
+
const tokensTotal = input + output + cacheCreate + cacheRead;
|
|
46
62
|
|
|
47
63
|
// 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 =
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
64
|
+
const msgs = Math.max(0, Math.round(messages || 0));
|
|
65
|
+
const turnsTotal = Math.max(output > 0 ? 1 : 0, msgs);
|
|
66
|
+
const sessionsCount =
|
|
67
|
+
tokensTotal > 0
|
|
68
|
+
? clamp(Math.round(msgs / 8) || 1, 1, Math.max(1, turnsTotal))
|
|
69
|
+
: 0;
|
|
70
|
+
const spanMinutes = spanDays * 1440;
|
|
71
|
+
const minActive = Math.ceil(output / 19000) + 1; // keep output/active under the 20k/min flag
|
|
72
|
+
const activeMinutes = clamp(
|
|
73
|
+
Math.max(msgs, minActive),
|
|
74
|
+
1,
|
|
75
|
+
Math.max(1, spanMinutes - 1),
|
|
76
|
+
);
|
|
54
77
|
|
|
55
|
-
const compressionRatio =
|
|
56
|
-
|
|
78
|
+
const compressionRatio =
|
|
79
|
+
input + output > 0 ? clamp(output / (input + output), 0, 1) : 0;
|
|
80
|
+
const sessionDepth = sessionsCount > 0 ? turnsTotal / sessionsCount : 0;
|
|
57
81
|
|
|
58
82
|
const payload = {
|
|
59
|
-
schema_version:
|
|
83
|
+
schema_version: "1.0",
|
|
60
84
|
codename: identity.codename,
|
|
61
85
|
device_id: identity.device_id,
|
|
62
86
|
submitted_at: new Date(nowMs).toISOString(),
|
|
@@ -69,7 +93,11 @@ export function buildPayload(windowKey, pillars, messages, identity, platform, o
|
|
|
69
93
|
core_metrics: {
|
|
70
94
|
compression_ratio: round(compressionRatio, 4),
|
|
71
95
|
prompt_complexity: round(clamp(sessionDepth, 0, 100), 2),
|
|
72
|
-
cross_thread_score: clamp(
|
|
96
|
+
cross_thread_score: clamp(
|
|
97
|
+
Math.round(cacheRead / Math.max(cacheCreate, 1)),
|
|
98
|
+
0,
|
|
99
|
+
100000,
|
|
100
|
+
),
|
|
73
101
|
session_depth_avg: round(Math.max(0, sessionDepth), 2),
|
|
74
102
|
// M.05 token_throughput muted 2026-06-26 — word-era metric the web already
|
|
75
103
|
// mutes (RS01 tt→0) + strips. Send null (RPC p_token_throughput is nullable
|
|
@@ -79,7 +107,10 @@ export function buildPayload(windowKey, pillars, messages, identity, platform, o
|
|
|
79
107
|
background_metrics: {
|
|
80
108
|
message_volume: msgs,
|
|
81
109
|
account_age_days: Math.max(1, Math.round(opts.accountAgeDays ?? 365)),
|
|
82
|
-
total_messages_lifetime: Math.max(
|
|
110
|
+
total_messages_lifetime: Math.max(
|
|
111
|
+
msgs,
|
|
112
|
+
Math.round(opts.totalMessages ?? msgs),
|
|
113
|
+
),
|
|
83
114
|
},
|
|
84
115
|
raw_telemetry: {
|
|
85
116
|
sessions_count: sessionsCount,
|
|
@@ -91,16 +122,24 @@ export function buildPayload(windowKey, pillars, messages, identity, platform, o
|
|
|
91
122
|
tokens_cache_creation: cacheCreate,
|
|
92
123
|
active_minutes_est: activeMinutes,
|
|
93
124
|
},
|
|
94
|
-
tier:
|
|
125
|
+
tier: "free",
|
|
95
126
|
agent: {
|
|
96
127
|
version: identity.agent_version,
|
|
97
128
|
ruleset_version: RULESET_VERSION,
|
|
98
|
-
snapshot_hash:
|
|
129
|
+
snapshot_hash: "",
|
|
99
130
|
public_key: identity.public_key,
|
|
100
131
|
},
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// Cascade Report block (Phase 1 — mode detection + badges + health).
|
|
135
|
+
// Computed locally (pure math), submitted alongside the 4 token pillars.
|
|
136
|
+
// The server stores it as-is — does NOT recompute modes.
|
|
137
|
+
// Weekly granularity is the privacy boundary — daily modes never leave the machine.
|
|
138
|
+
if (opts.report && typeof opts.report === "object") {
|
|
139
|
+
payload.report = opts.report;
|
|
101
140
|
}
|
|
102
|
-
payload.agent.snapshot_hash = snapshotHash(payload)
|
|
103
|
-
return payload
|
|
141
|
+
payload.agent.snapshot_hash = snapshotHash(payload);
|
|
142
|
+
return payload;
|
|
104
143
|
}
|
|
105
144
|
|
|
106
145
|
/**
|
|
@@ -108,74 +147,139 @@ export function buildPayload(windowKey, pillars, messages, identity, platform, o
|
|
|
108
147
|
* Returns a structured result; never throws on a network error. opts: { apiBase,
|
|
109
148
|
* fetchImpl, platform, now } (fetchImpl lets the caller inject a timeout-wrapped fetch).
|
|
110
149
|
*/
|
|
111
|
-
export async function submitSignedWindow(
|
|
150
|
+
export async function submitSignedWindow(
|
|
151
|
+
windowKey,
|
|
152
|
+
pillars,
|
|
153
|
+
messages,
|
|
154
|
+
identity,
|
|
155
|
+
opts = {},
|
|
156
|
+
) {
|
|
112
157
|
if (!identity?.codename || !identity?.operator_id) {
|
|
113
|
-
return {
|
|
158
|
+
return {
|
|
159
|
+
status: "not_enrolled",
|
|
160
|
+
reason: "not_enrolled",
|
|
161
|
+
detail: "Run `npx sigrank-mcp enroll` to bind this device first.",
|
|
162
|
+
};
|
|
114
163
|
}
|
|
115
164
|
if (!identity?.private_key_pkcs8_b64) {
|
|
116
|
-
return {
|
|
165
|
+
return {
|
|
166
|
+
status: "error",
|
|
167
|
+
reason: "no_key",
|
|
168
|
+
detail: "No local signing key — re-run `npx sigrank-mcp enroll`.",
|
|
169
|
+
};
|
|
117
170
|
}
|
|
118
171
|
|
|
119
|
-
const apiBase =
|
|
172
|
+
const apiBase =
|
|
173
|
+
opts.apiBase || process.env.SIGRANK_API_BASE || "https://signalaf.com";
|
|
120
174
|
// FIX M: default a 15s AbortController timeout when the caller doesn't inject a
|
|
121
175
|
// fetchImpl. The TUI path already wraps fetch (callTool → doFetch, 10s), but the
|
|
122
176
|
// legacy CLI `watch --submit` + the CLI default-view [S] call submitSignedWindow
|
|
123
177
|
// with no fetchImpl → bare fetch → a hung POST blocks forever. This default fixes
|
|
124
178
|
// it everywhere at once. (15s is generous for a signed POST to /api/v1/snapshots.)
|
|
125
|
-
const fetchImpl =
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
179
|
+
const fetchImpl =
|
|
180
|
+
opts.fetchImpl ||
|
|
181
|
+
((url, init = {}) => {
|
|
182
|
+
const ctrl = new AbortController();
|
|
183
|
+
const timer = setTimeout(() => ctrl.abort(), 15_000);
|
|
184
|
+
return fetch(url, {
|
|
185
|
+
...init,
|
|
186
|
+
signal: init.signal || ctrl.signal,
|
|
187
|
+
}).finally(() => clearTimeout(timer));
|
|
188
|
+
});
|
|
189
|
+
const payload = buildPayload(
|
|
190
|
+
windowKey,
|
|
191
|
+
pillars,
|
|
192
|
+
messages,
|
|
193
|
+
identity,
|
|
194
|
+
opts.platform || "claude",
|
|
195
|
+
opts,
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
// Preflight: run the same anti-gaming checks the server will run, locally.
|
|
199
|
+
// If the payload would be rejected or downgraded, warn the operator BEFORE
|
|
200
|
+
// submitting. The server always runs its own checks — this is a preview.
|
|
201
|
+
const pre = preflight(payload);
|
|
202
|
+
if (!pre.pass && !opts.skipPreflight) {
|
|
203
|
+
if (pre.wouldReject) {
|
|
204
|
+
return {
|
|
205
|
+
status: "preflight_rejected",
|
|
206
|
+
window: WINDOW_TYPE[windowKey] || windowKey,
|
|
207
|
+
preflight: pre,
|
|
208
|
+
detail: `Submission would be REJECTED by the server: ${pre.summary}. Fix the issue or re-run with skipPreflight.`,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
// Flags downgrade verified → flagged (not ranked). Warn but don't block
|
|
212
|
+
// unless the caller explicitly asked for strict mode.
|
|
213
|
+
if (opts.strictPreflight) {
|
|
214
|
+
return {
|
|
215
|
+
status: "preflight_flagged",
|
|
216
|
+
window: WINDOW_TYPE[windowKey] || windowKey,
|
|
217
|
+
preflight: pre,
|
|
218
|
+
detail: `Submission would be DOWNGRADED (not ranked): ${pre.summary}. Fix the issue or re-run with skipPreflight.`,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
// Non-strict: attach the preflight warning to the result but proceed.
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const signature = signPayload(payload, identity.private_key_pkcs8_b64);
|
|
132
225
|
|
|
133
226
|
// Dry run: build + sign exactly as a real publish, then STOP before the POST.
|
|
134
227
|
// Returns the exact payload that would be sent — the privacy proof ("token
|
|
135
228
|
// counts only") is inspectable, and nothing touches the network.
|
|
136
229
|
if (opts.dryRun) {
|
|
137
230
|
return {
|
|
138
|
-
status:
|
|
231
|
+
status: "dry_run",
|
|
139
232
|
window: WINDOW_TYPE[windowKey] || windowKey,
|
|
140
233
|
would_post: `${apiBase}/api/v1/snapshots`,
|
|
141
234
|
payload,
|
|
142
235
|
signature,
|
|
143
|
-
|
|
144
|
-
|
|
236
|
+
preflight: pre,
|
|
237
|
+
detail: "Nothing sent. Re-run without dry_run to publish.",
|
|
238
|
+
};
|
|
145
239
|
}
|
|
146
240
|
|
|
147
|
-
let res
|
|
148
|
-
let ack
|
|
241
|
+
let res;
|
|
242
|
+
let ack;
|
|
149
243
|
try {
|
|
150
244
|
res = await fetchImpl(`${apiBase}/api/v1/snapshots`, {
|
|
151
|
-
method:
|
|
245
|
+
method: "POST",
|
|
152
246
|
headers: {
|
|
153
|
-
|
|
154
|
-
accept:
|
|
155
|
-
|
|
247
|
+
"content-type": "application/json",
|
|
248
|
+
accept: "application/json",
|
|
249
|
+
"x-agent-signature": signature,
|
|
156
250
|
},
|
|
157
251
|
body: JSON.stringify(payload),
|
|
158
|
-
})
|
|
252
|
+
});
|
|
159
253
|
try {
|
|
160
|
-
ack = await res.json()
|
|
254
|
+
ack = await res.json();
|
|
161
255
|
} catch {
|
|
162
|
-
ack = {}
|
|
256
|
+
ack = {};
|
|
163
257
|
}
|
|
164
258
|
} catch (e) {
|
|
165
|
-
return {
|
|
259
|
+
return {
|
|
260
|
+
status: "error",
|
|
261
|
+
reason: "network",
|
|
262
|
+
detail: e.message,
|
|
263
|
+
window: WINDOW_TYPE[windowKey] || windowKey,
|
|
264
|
+
};
|
|
166
265
|
}
|
|
167
266
|
|
|
168
267
|
return {
|
|
169
|
-
status: res.ok ? ack.status ||
|
|
268
|
+
status: res.ok ? ack.status || "received" : "error",
|
|
170
269
|
httpStatus: res.status,
|
|
171
270
|
window: WINDOW_TYPE[windowKey] || windowKey,
|
|
172
271
|
verification_tier: ack.verification_tier ?? null,
|
|
173
272
|
persisted: ack.persisted ?? null,
|
|
174
273
|
// ranked = actually on the board (verified + written), not just "received".
|
|
175
274
|
// An unenrolled/revoked device gets HTTP 202 received but is NEVER ranked.
|
|
176
|
-
ranked: !!(
|
|
275
|
+
ranked: !!(
|
|
276
|
+
res.ok &&
|
|
277
|
+
ack.verification_tier === "verified" &&
|
|
278
|
+
ack.persisted === true
|
|
279
|
+
),
|
|
177
280
|
snapshot_hash: payload.agent.snapshot_hash,
|
|
178
281
|
reason: res.ok ? null : ack.reason || ack.status || `http_${res.status}`,
|
|
179
282
|
detail: ack.detail ?? null,
|
|
180
|
-
|
|
283
|
+
preflight: pre,
|
|
284
|
+
};
|
|
181
285
|
}
|