@mnemom/mnemom 0.17.33 → 0.17.34-next.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.
Files changed (47) hide show
  1. package/dist/commands/agent-share-render.d.ts +1 -1
  2. package/dist/commands/agent-share-render.js +1 -1
  3. package/dist/commands/agent.d.ts +15 -0
  4. package/dist/commands/agent.js +83 -7
  5. package/dist/commands/protection.js +1 -1
  6. package/dist/commands/scan.d.ts +97 -0
  7. package/dist/commands/scan.js +238 -0
  8. package/dist/index.js +35 -0
  9. package/dist/lib/api.d.ts +13 -0
  10. package/dist/lib/api.js +8 -0
  11. package/dist/lib/scan/caps.d.ts +35 -0
  12. package/dist/lib/scan/caps.js +67 -0
  13. package/dist/lib/scan/credential.d.ts +37 -0
  14. package/dist/lib/scan/credential.js +104 -0
  15. package/dist/lib/scan/failure.d.ts +10 -0
  16. package/dist/lib/scan/failure.js +43 -0
  17. package/dist/lib/scan/pr-source.d.ts +57 -0
  18. package/dist/lib/scan/pr-source.js +130 -0
  19. package/dist/lib/scan/record.d.ts +49 -0
  20. package/dist/lib/scan/record.js +85 -0
  21. package/dist/lib/scan/report-email.d.ts +92 -0
  22. package/dist/lib/scan/report-email.js +136 -0
  23. package/dist/lib/scan/report.d.ts +13 -0
  24. package/dist/lib/scan/report.js +30 -0
  25. package/dist/lib/scan/scan.d.ts +64 -0
  26. package/dist/lib/scan/scan.js +182 -0
  27. package/dist/lib/scan/share.d.ts +38 -0
  28. package/dist/lib/scan/share.js +47 -0
  29. package/dist/lib/scan/spend/ai-tool-detect.d.ts +6 -0
  30. package/dist/lib/scan/spend/ai-tool-detect.js +18 -0
  31. package/dist/lib/scan/spend/anthropic-usage.d.ts +57 -0
  32. package/dist/lib/scan/spend/anthropic-usage.js +244 -0
  33. package/dist/lib/scan/spend/cost-corpus.v1.d.ts +56 -0
  34. package/dist/lib/scan/spend/cost-corpus.v1.js +64 -0
  35. package/dist/lib/scan/spend/prices.d.ts +33 -0
  36. package/dist/lib/scan/spend/prices.js +57 -0
  37. package/dist/lib/scan/spend/spend-estimate.d.ts +30 -0
  38. package/dist/lib/scan/spend/spend-estimate.js +70 -0
  39. package/dist/lib/scan/spend/spend-tiers.d.ts +25 -0
  40. package/dist/lib/scan/spend/spend-tiers.js +42 -0
  41. package/dist/lib/scan/spend/token-rate.d.ts +30 -0
  42. package/dist/lib/scan/spend/token-rate.js +54 -0
  43. package/dist/lib/session-key.d.ts +41 -0
  44. package/dist/lib/session-key.js +124 -0
  45. package/dist/rc-key-401.mjs +75 -0
  46. package/dist/rc-proxy.mjs +221 -25
  47. package/package.json +2 -2
@@ -0,0 +1,42 @@
1
+ export const ORG_SCAN_ENV = "MNEMOM_ORG_SCAN_MODE";
2
+ /** Default OFF: only the exact string "true" turns the tiers on. */
3
+ export const isSpendTiersEnabled = (env = process.env) => env[ORG_SCAN_ENV] === "true";
4
+ export const NOT_ENABLED_MSG = "The spend fallback is not enabled.";
5
+ export const SPEND_INVALID_MSG = "Monthly spend must be a dollar amount above zero, like 5000.";
6
+ export const SPEND_NO_TERMINAL_MSG = "Pass --monthly-spend <usd> (there is no terminal to ask on).";
7
+ export const MAX_MONTHLY_SPEND_USD = 10_000_000;
8
+ const SPEND_RE = /^\$?\s*(\d{1,3}(,\d{3})+|\d+)(\.\d{1,2})?$/;
9
+ /** Dollars, or null if the text is not a positive, plausible amount. */
10
+ export function parseMonthlySpendUsd(raw) {
11
+ const t = raw.trim();
12
+ if (!SPEND_RE.test(t))
13
+ return null;
14
+ const n = Number(t.replace(/[$,\s]/g, ""));
15
+ return Number.isFinite(n) && n > 0 && n <= MAX_MONTHLY_SPEND_USD ? n : null;
16
+ }
17
+ const usd = (n) => `$${n.toLocaleString("en-US", { maximumFractionDigits: 2 })}`;
18
+ const isUsd = (n) => typeof n === "number" && Number.isFinite(n) && n > 0;
19
+ const pct = (f) => `${Math.round(f * 100)}%`;
20
+ const isShare = (n) => typeof n === "number" && Number.isFinite(n) && n >= 0 && n <= 1;
21
+ export function selectSpend(i) {
22
+ if (isUsd(i.measuredUsd)) {
23
+ return {
24
+ tier: 1,
25
+ line: `Tier 1, measured: ${usd(i.measuredUsd)} of Anthropic usage over the period${isShare(i.over200kShare)
26
+ ? ` (${pct(i.over200kShare)} of tokens in requests over 200k context)`
27
+ : ""}.`,
28
+ shareable: true,
29
+ };
30
+ }
31
+ if (isUsd(i.selfReportedUsd)) {
32
+ return {
33
+ tier: 2,
34
+ line: `Tier 2, self-reported: about ${usd(i.selfReportedUsd)} a month (given by you; not verified, not sent, not saved).`,
35
+ shareable: false,
36
+ };
37
+ }
38
+ if (i.estimate.kind === "range") {
39
+ return { tier: 3, line: `Tier 3, ${i.estimate.line}`, shareable: true };
40
+ }
41
+ return { tier: null, line: null, shareable: false };
42
+ }
@@ -0,0 +1,30 @@
1
+ /** Rows needed before the rate is trusted at all (data sufficiency, not a group size). */
2
+ export declare const MIN_CORPUS_ROWS = 5;
3
+ export interface CostRowShape {
4
+ repo: string;
5
+ pr: number;
6
+ additions: number;
7
+ deletions: number;
8
+ cost_usd: number;
9
+ runs: number;
10
+ models: string[];
11
+ }
12
+ export interface TokenRate {
13
+ /** Price-equivalent tokens per changed (added + deleted) line: 25th percentile. */
14
+ low: number;
15
+ median: number;
16
+ /** 75th percentile. */
17
+ high: number;
18
+ rows: number;
19
+ }
20
+ /** Linear-interpolated quantile of a sorted array. */
21
+ export declare function quantile(sorted: number[], q: number): number;
22
+ /**
23
+ * Derive the rate from corpus rows: per-row tokens/line, then the 25th, 50th and 75th percentile.
24
+ * Rows that fail validation are dropped (not repaired); fewer than MIN_CORPUS_ROWS valid rows
25
+ * returns null, never a rate built on a handful.
26
+ */
27
+ export declare function deriveTokenRate(rows: unknown): TokenRate | null;
28
+ export declare const CORPUS_AS_OF: string;
29
+ /** The shipped rate: derived once from the committed fixture. */
30
+ export declare const DERIVED_TOKEN_RATE: TokenRate | null;
@@ -0,0 +1,54 @@
1
+ // MNE-9268: tokens-per-changed-line and its spread, DERIVED from our own agents' PRs whose run cost is
2
+ // known (cost-corpus.v1.json, built by build-cost-corpus.mjs from the ADW "Run cost" PR comments).
3
+ // Nothing here is a typed-in rate: change the fixture and the rate moves, and token-rate.test.ts pins
4
+ // the derivation. Tokens are price-equivalent: cost / (our model's blended list price per token).
5
+ import rawCorpus from "./cost-corpus.v1.js";
6
+ import { TOOL_PRICES, blendedUsdPerToken } from "./prices.js";
7
+ /** Rows needed before the rate is trusted at all (data sufficiency, not a group size). */
8
+ export const MIN_CORPUS_ROWS = 5;
9
+ /** Our agents ran on Sonnet-family models in every corpus row; price their tokens as that. */
10
+ const BASIS_USD_PER_TOKEN = blendedUsdPerToken(TOOL_PRICES.claude_code.low);
11
+ const isCount = (n) => typeof n === "number" && Number.isFinite(n) && n >= 0;
12
+ function validRow(r) {
13
+ if (typeof r !== "object" || r === null)
14
+ return false;
15
+ const o = r;
16
+ return (isCount(o.additions) &&
17
+ isCount(o.deletions) &&
18
+ o.additions + o.deletions > 0 &&
19
+ isCount(o.cost_usd) &&
20
+ o.cost_usd > 0);
21
+ }
22
+ /** Linear-interpolated quantile of a sorted array. */
23
+ export function quantile(sorted, q) {
24
+ if (sorted.length === 0)
25
+ return NaN;
26
+ const pos = (sorted.length - 1) * q;
27
+ const lo = Math.floor(pos);
28
+ const hi = Math.ceil(pos);
29
+ return sorted[lo] + (sorted[hi] - sorted[lo]) * (pos - lo);
30
+ }
31
+ /**
32
+ * Derive the rate from corpus rows: per-row tokens/line, then the 25th, 50th and 75th percentile.
33
+ * Rows that fail validation are dropped (not repaired); fewer than MIN_CORPUS_ROWS valid rows
34
+ * returns null, never a rate built on a handful.
35
+ */
36
+ export function deriveTokenRate(rows) {
37
+ if (!Array.isArray(rows))
38
+ return null;
39
+ const per = rows
40
+ .filter(validRow)
41
+ .map((r) => r.cost_usd / (r.additions + r.deletions) / BASIS_USD_PER_TOKEN)
42
+ .sort((a, b) => a - b);
43
+ if (per.length < MIN_CORPUS_ROWS)
44
+ return null;
45
+ return {
46
+ low: quantile(per, 0.25),
47
+ median: quantile(per, 0.5),
48
+ high: quantile(per, 0.75),
49
+ rows: per.length,
50
+ };
51
+ }
52
+ export const CORPUS_AS_OF = rawCorpus.as_of;
53
+ /** The shipped rate: derived once from the committed fixture. */
54
+ export const DERIVED_TOKEN_RATE = deriveTokenRate(rawCorpus.rows);
@@ -66,3 +66,44 @@ export declare function mintSessionGatewayKey(deps: SessionKeyDeps, args: {
66
66
  * one. Never called with the owner's own key id.
67
67
  */
68
68
  export declare function revokeSessionGatewayKey(deps: SessionKeyDeps, keyId: string): Promise<void>;
69
+ /** When to renew a key expiring at `expiresAt`, measured from `now` (ms). */
70
+ export declare function renewDelayMs(expiresAt: string, now: number): number;
71
+ /** What {@link mintSessionGatewayKey} mints for: the same arguments at launch and at each renewal. */
72
+ export interface SessionKeyMintArgs {
73
+ agentId: string;
74
+ orgId?: string;
75
+ sessionId?: string;
76
+ }
77
+ export interface SessionKeyRenewalOptions {
78
+ /** `say` is where renewal events go: the proxy log, never the terminal the session draws on. */
79
+ deps: SessionKeyDeps;
80
+ mintArgs: SessionKeyMintArgs;
81
+ initial: {
82
+ keyId: string;
83
+ expiresAt: string;
84
+ };
85
+ /** Hand the new key to the running proxy. Throws when it cannot (the pipe is gone). */
86
+ deliver: (minted: MintedSessionGatewayKey) => void;
87
+ /** Tell the proxy its re-mint request failed, so it stops waiting and answers the 401. */
88
+ refuse: (reason: string) => void;
89
+ now?: () => number;
90
+ }
91
+ export interface SessionKeyRenewal {
92
+ /**
93
+ * Re-mint now. `rejected` is the proxy's request after a gateway 401, naming the key id it sent;
94
+ * a request about a key that is no longer current is a no-op (the proxy already has the newer
95
+ * one). Resolves true when a new key was delivered. Never rejects.
96
+ */
97
+ renew(reason: "scheduled" | "rejected", rejectedKeyId?: string): Promise<boolean>;
98
+ /** The key the proxy holds now: the one to revoke on exit. */
99
+ currentKeyId(): string;
100
+ stop(): void;
101
+ }
102
+ /**
103
+ * Keep the proxy's session key fresh: renew at ~80% of its remaining lifetime (floor 60s), retry a
104
+ * failed renewal every 5 minutes, and after each swap revoke the old key (best effort, like the
105
+ * exit revoke). Every new key goes through {@link mintSessionGatewayKey}, so a renewal is held to
106
+ * the same fail-closed checks as the launch: a key that is not exactly a `['gateway']` session key
107
+ * is never delivered. Logs key ids and expiry times only, never a key.
108
+ */
109
+ export declare function startSessionKeyRenewal(opts: SessionKeyRenewalOptions): SessionKeyRenewal;
@@ -99,3 +99,127 @@ export async function revokeSessionGatewayKey(deps, keyId) {
99
99
  deps.say(`mnemom agent: could not revoke the session key (non-fatal — it expires on its own within 24h): ${err instanceof Error ? err.message : String(err)}`);
100
100
  }
101
101
  }
102
+ // ───────────────────────────────────────────────────────────────────────────
103
+ // Renewal for long-lived Remote Control sessions
104
+ // ───────────────────────────────────────────────────────────────────────────
105
+ //
106
+ // The minted key is 24h-TTL'd, and a `--remote-control` seat outlives that: before this, every
107
+ // model turn failed with the gateway's `401 Invalid Mnemom API key` exactly 24h after launch. The
108
+ // launcher (the CLI PARENT, which holds ambient auth) therefore re-mints before expiry and hands
109
+ // the new key to the running proxy over its control pipe — never argv, env or disk — then revokes
110
+ // the old one. The proxy can also ask for one immediate re-mint when the gateway rejects its key.
111
+ /** Renew once this share of the key's remaining lifetime has passed. */
112
+ const RENEW_AT_FRACTION = 0.8;
113
+ /** Never schedule a renewal sooner than this (a key that is nearly or already expired). */
114
+ const RENEW_FLOOR_MS = 60_000;
115
+ /** The lifetime assumed when `expiresAt` does not parse: the route's documented 24h. */
116
+ const ASSUMED_TTL_MS = 24 * 60 * 60 * 1000;
117
+ /** After a failed renewal, try again this much later. */
118
+ const RENEW_RETRY_MS = 5 * 60 * 1000;
119
+ /**
120
+ * A re-mint the proxy asks for (its key was rejected) is refused if one was attempted this
121
+ * recently, so a gateway that keeps rejecting fresh keys cannot turn every request into a mint.
122
+ */
123
+ const REJECTED_COOLDOWN_MS = 60_000;
124
+ /** setTimeout's ceiling (a larger delay fires at once). */
125
+ const MAX_TIMER_MS = 2 ** 31 - 1;
126
+ /** When to renew a key expiring at `expiresAt`, measured from `now` (ms). */
127
+ export function renewDelayMs(expiresAt, now) {
128
+ const exp = Date.parse(expiresAt);
129
+ const left = Number.isFinite(exp) ? exp - now : ASSUMED_TTL_MS;
130
+ return Math.min(MAX_TIMER_MS, Math.max(RENEW_FLOOR_MS, Math.floor(left * RENEW_AT_FRACTION)));
131
+ }
132
+ /**
133
+ * Keep the proxy's session key fresh: renew at ~80% of its remaining lifetime (floor 60s), retry a
134
+ * failed renewal every 5 minutes, and after each swap revoke the old key (best effort, like the
135
+ * exit revoke). Every new key goes through {@link mintSessionGatewayKey}, so a renewal is held to
136
+ * the same fail-closed checks as the launch: a key that is not exactly a `['gateway']` session key
137
+ * is never delivered. Logs key ids and expiry times only, never a key.
138
+ */
139
+ export function startSessionKeyRenewal(opts) {
140
+ const now = opts.now ?? Date.now;
141
+ const say = opts.deps.say;
142
+ let current = { ...opts.initial };
143
+ let timer;
144
+ let inflight;
145
+ let lastAttempt = Number.NEGATIVE_INFINITY;
146
+ let stopped = false;
147
+ const schedule = (ms) => {
148
+ if (stopped)
149
+ return;
150
+ clearTimeout(timer);
151
+ timer = setTimeout(() => void renew("scheduled"), ms);
152
+ timer.unref?.();
153
+ };
154
+ const attempt = async (reason) => {
155
+ lastAttempt = now();
156
+ let minted;
157
+ try {
158
+ minted = await mintSessionGatewayKey(opts.deps, opts.mintArgs);
159
+ }
160
+ catch (err) {
161
+ // mintSessionGatewayKey always throws an Error wrapping the mint's own failure in `cause`,
162
+ // with launch-refusal wording that is wrong mid-session: log the cause when it is an Error.
163
+ const e = err;
164
+ const msg = e.cause instanceof Error ? e.cause.message : e.message;
165
+ say(`session key renewal (${reason}) FAILED: ${msg}; keeping id=${current.keyId} expires=${current.expiresAt}, retrying in ${RENEW_RETRY_MS / 1000}s`);
166
+ schedule(RENEW_RETRY_MS);
167
+ return false;
168
+ }
169
+ if (stopped) {
170
+ await revokeSessionGatewayKey(opts.deps, minted.keyId); // nobody will ever use it
171
+ return false;
172
+ }
173
+ try {
174
+ opts.deliver(minted);
175
+ }
176
+ catch (err) {
177
+ say(`session key renewal (${reason}): could not hand id=${minted.keyId} to the proxy (${err instanceof Error ? err.message : String(err)}); revoking it and keeping id=${current.keyId}`);
178
+ await revokeSessionGatewayKey(opts.deps, minted.keyId);
179
+ schedule(RENEW_RETRY_MS);
180
+ return false;
181
+ }
182
+ const old = current;
183
+ current = { keyId: minted.keyId, expiresAt: minted.expiresAt };
184
+ say(`session key renewed (${reason}): id=${current.keyId} expires=${current.expiresAt}; revoking id=${old.keyId}`);
185
+ schedule(renewDelayMs(current.expiresAt, now()));
186
+ await revokeSessionGatewayKey(opts.deps, old.keyId);
187
+ return true;
188
+ };
189
+ const renew = (reason, rejectedKeyId) => {
190
+ if (stopped)
191
+ return Promise.resolve(false);
192
+ if (reason === "rejected") {
193
+ // The proxy sent a key that is no longer current: the newer one is already on its way.
194
+ if (rejectedKeyId && rejectedKeyId !== current.keyId)
195
+ return Promise.resolve(true);
196
+ if (!inflight && now() - lastAttempt < REJECTED_COOLDOWN_MS) {
197
+ say(`session key id=${current.keyId} rejected by the gateway; not re-minting (last attempt under ${REJECTED_COOLDOWN_MS / 1000}s ago)`);
198
+ opts.refuse("cooldown");
199
+ return Promise.resolve(false);
200
+ }
201
+ }
202
+ // One mint at a time: a rejection that arrives mid-renewal waits on that renewal.
203
+ inflight ??= attempt(reason).finally(() => {
204
+ inflight = undefined;
205
+ });
206
+ const p = inflight;
207
+ if (reason === "rejected") {
208
+ void p.then((ok) => {
209
+ if (!ok)
210
+ opts.refuse("mint failed");
211
+ });
212
+ }
213
+ return p;
214
+ };
215
+ // The launch key's id and expiry are logged by the proxy at startup.
216
+ schedule(renewDelayMs(current.expiresAt, now()));
217
+ return {
218
+ renew,
219
+ currentKeyId: () => current.keyId,
220
+ stop: () => {
221
+ stopped = true;
222
+ clearTimeout(timer);
223
+ },
224
+ };
225
+ }
@@ -0,0 +1,75 @@
1
+ // rc-key-401 — the Remote Control proxy's handling of a gateway 401 it may be able to fix.
2
+ //
3
+ // A 401 on a buffered /v1/messages request may be the Mnemom session key expiring under a long
4
+ // session (rc-proxy.mjs, SESSION KEY RENEWAL). The proxy holds that 401 back from the client until
5
+ // the whole body is in, then asks isMnemomKeyRejection() whether a fresh key would fix it. Split out
6
+ // of rc-proxy.mjs (a script that reads stdin and listens at import) so each branch is unit-tested
7
+ // (rc-key-401.test.ts). Node ≥ 18, no deps.
8
+ import { Buffer } from "node:buffer";
9
+ import zlib from "node:zlib";
10
+
11
+ // A held-back 401 body (and its decompressed form) is read up to this size. The gateway's key
12
+ // rejection is a few hundred bytes; anything larger is not one and is streamed to the client as is.
13
+ export const KEY_401_BODY_MAX = 64 * 1024;
14
+
15
+ // How long a key-rejected request waits for the launcher's re-mint (its mint call is bounded at
16
+ // 10s): MNEMOM_RC_REMINT_WAIT_MS when set and non-empty ("0" included), else 20s.
17
+ export const REMINT_WAIT_DEFAULT_MS = 20_000;
18
+ export function remintWaitMs(env) {
19
+ const v = env.MNEMOM_RC_REMINT_WAIT_MS;
20
+ return v ? Number(v) : REMINT_WAIT_DEFAULT_MS;
21
+ }
22
+
23
+ // Whether a held-back gateway 401 body says the Mnemom key itself was refused (unknown, revoked or
24
+ // expired): the only 401 a fresh key can fix. Anything it cannot decode is not one.
25
+ export function isMnemomKeyRejection(raw, enc, max = KEY_401_BODY_MAX) {
26
+ let text;
27
+ try {
28
+ const b =
29
+ enc === "gzip" || enc === "x-gzip"
30
+ ? zlib.gunzipSync(raw, { maxOutputLength: max })
31
+ : enc === "deflate"
32
+ ? zlib.inflateSync(raw, { maxOutputLength: max })
33
+ : enc === "br"
34
+ ? zlib.brotliDecompressSync(raw, { maxOutputLength: max })
35
+ : raw;
36
+ text = b.toString("utf8");
37
+ } catch {
38
+ return false;
39
+ }
40
+ return /invalid[ _]mnemom[ _](api[ _])?key/i.test(text);
41
+ }
42
+
43
+ // Hold the upstream response `ur` back from the client response `res`. Up to `max` bytes,
44
+ // `onBody(raw)` gets the whole body at its end and nothing has reached the client. Past `max` the
45
+ // body is not a key rejection: the status, headers and every byte so far go to the client, the rest
46
+ // streams through, and `onOverflow(bytes)` runs at the end. Every write first checks that the client
47
+ // is still there: a hang-up destroys `res` (and, via rc-proxy.mjs, the upstream request), and a
48
+ // chunk already in flight must not write to it.
49
+ export function holdBack401(ur, res, { max = KEY_401_BODY_MAX, onBody, onOverflow }) {
50
+ const chunks = [];
51
+ let held = 0;
52
+ let overflow = false;
53
+ ur.on("data", (c) => {
54
+ if (overflow) {
55
+ if (!res.destroyed) res.write(c);
56
+ return;
57
+ }
58
+ chunks.push(c);
59
+ held += c.length;
60
+ if (held > max) {
61
+ overflow = true;
62
+ if (res.destroyed) return;
63
+ res.writeHead(ur.statusCode, ur.headers);
64
+ for (const x of chunks.splice(0)) res.write(x);
65
+ }
66
+ });
67
+ ur.on("end", () => {
68
+ if (overflow) {
69
+ if (!res.destroyed) res.end();
70
+ onOverflow(held);
71
+ return;
72
+ }
73
+ onBody(Buffer.concat(chunks));
74
+ });
75
+ }