@genex-ai/cli-demo 1.36.5 → 1.36.6-dev.862

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/dist/index.js CHANGED
@@ -57,7 +57,7 @@ import {
57
57
  writeSecretFile,
58
58
  writeUserToken,
59
59
  writeWorkspace
60
- } from "./chunk-4BBTPRJW.js";
60
+ } from "./chunk-EABIVAGT.js";
61
61
  import {
62
62
  CLI_CHANNEL,
63
63
  DEFAULT_API_URL,
@@ -78,7 +78,7 @@ import {
78
78
  normalizeApiOrigin,
79
79
  originKey,
80
80
  resolveAgentTargets
81
- } from "./chunk-QI7FIYBY.js";
81
+ } from "./chunk-K4EHXOZK.js";
82
82
 
83
83
  // src/instrument.ts
84
84
  import * as Sentry from "@sentry/node";
@@ -981,7 +981,7 @@ Important note: put soul into your creations, with many details and love. Aim to
981
981
  21. A turn ends in exactly one of two ways: on a question the player must answer before the next step can be chosen, or on a handoff \u2014 the draft link, what changed, what to try, and what is still open. Never close a turn on a promise. "I'll keep building", "while I keep working", "adding that now" are things you say and then DO before you stop; if you are stopping, say that you have stopped and what remains. While the Build plan's \`Now:\` line has open work and no answer is needed from the player, the turn is not over: preview, hand off, and start the next milestone in the same turn, until the requested outcome is reached or the platform's own budget ends the session. If a stop was forced on you anyway, the first line of your next turn is the promise you left and where it stands.
982
982
  22. **What the player sees must read as the thing it is**, in this game's own style: a building as that building, a person as a person, a prop as that prop, a surface as its material \u2014 and a coloured box, a capsule, or a flat grey block standing in for one of them is never its finished version. Generation is the default route to that bar wherever code will not honestly reach it: the player's character, whatever the request names, whatever the player walks up to, enters, or interacts with, and the music and sound underneath \u2014 reach for the belt on your own judgment, without stopping to ask; a status line saying what you queued is enough, and \`--no-wait\` keeps you building while it lands. Procedural code is a first-class engine for what is structural, repeated, distant, or parametric \u2014 terrain, sky, fences, paving, modular kits, filler \u2014 and for anything else only when the result meets the same bar and you have looked at it in a capture before its row says \`landed\`. Start with one reusable implementation per distinct required gameplay or visual role. Background populations default to reusable procedural bodies and motion or compatible existing rigged assets. Catalog clips need a compatible skeleton and may be paid. Reserve paid custom bodies for the player and characters examined or interacted with closely, while honoring explicit user requests. Add variants only for an unmet requirement; unspent allowance is not unfinished work. Allowance questions use timeoutPolicy no-consent: silence does not raise the allowance. Record role, reuse and completion criteria in the existing Assets table. Decide per object, not per category, and write the route on each Assets row in \`DESIGN.md\` \u2014 the paid flow for generated pieces, the procedural flow with its local path for code-built ones. An Assets table with no generation in it is a decision, not a default: record it as \`Generation: none \u2014 <why code alone reaches the bar here>\` or generate.
983
983
  23. **The screenshots you take to look at your own work go in \`.genex/scratch/\`.** Captures, render comparisons, before/after strips, traces and metrics dumps are how you SEE what you built \u2014 they are not part of what you built, and \`genex preview\` pushes the whole folder to the game's source, every time, forever. \`.genex/scratch/\` already exists for this and is already ignored, so there is nothing to set up: write the capture as \`.genex/scratch/arena-before.png\` and read it back from there. Never put them in a folder of your own at the top level: \`progress/\`, \`reports/\`, \`shots/\` and their kind are pushed like source and become permanent weight in the game and in every remix of it \u2014 one real game reached 11 GB and 10,332 committed screenshots exactly this way. If you find such a folder already there, add it to \`.gitignore\`; never delete the player's files (law 18). One file in \`.genex/scratch/\` IS read, by the platform: \`.genex/scratch/cover.png\` is the game's cover \u2014 the frame you would put on its poster: real gameplay at its signature moment, well lit, no menus, popups or busy HUD, landscape 16:9, and never any text (the page sets the title beside it). The next \`genex preview\` makes it the cover; save a better moment over it whenever one comes along, and if your browser tool can set the cover itself, use that instead. Before the first publish, and whenever the game's look changes, load \`$genex-cover\`: it chooses, stages, lights and judges that frame.
984
- 24. **Benchmark before declaring an in-game LLM price.** If the game calls a language model while the player plays \u2014 an NPC answering in its own words, a quest written for this save, a judge reading what the player typed \u2014 the PLAYER pays for it, and \`estimateCoins\` is the FIXED price of a started attempt: charged whether it succeeds, fails, is canceled, or stops at its own budget. Never pick that number by judgement or from a vendor's rate card. Run the real prompt on your own coins \u2014 \`npx genex llm bench "<the prompt>" --schema <file> --samples 3 --max-coins <n> --user-approved\` \u2014 and declare the figure it recommends, recorded in \`DESIGN.md\` with its model and date; re-benchmark whenever the prompt, the schema or the model changes, because a prompt edit is a price change. Check the lane first with \`npx genex llm models\`: where it is off the routes 404, so build the feature behind a graceful unavailable state and say so in the handoff. A repeated call is a standing budget the player approves once (\`requestSpendGrant()\`, then \`generate({ grantId })\`), never a popup per turn, and its disclosed call rate is computed from the game's own loop. Model output is data the game validates against its own expectation \u2014 never executed, and never authority over coin, items, entitlements or rewards. Load \`$genex-llm-in-games\` before writing any of it.
984
+ 24. **Benchmark before declaring an in-game LLM ceiling.** If the game calls a language model while the player plays \u2014 an NPC answering in its own words, a quest written for this save, a judge reading what the player typed \u2014 the PLAYER pays for it from their Genex credits, billed as used, and the game declares no price, only a per-call CEILING: \`maxCredits\` on \`generate()\`, \`perCallMaxCredits\` on a standing budget. That ceiling bounds what one call may cost AND funds how long its answer may be, so never pick it by judgement or from a vendor's rate card. Run the real prompt on your own credits \u2014 \`npx genex llm bench "<the prompt>" --schema <file> --samples 3 --max-credits <n> --user-approved\` \u2014 and declare the figures it prints, recorded in \`DESIGN.md\` with its model and date; re-benchmark whenever the prompt, the schema or the model changes, because a prompt edit changes the ceiling. Check the lane first with \`npx genex llm models\`: where it is off the routes 404, so build the feature behind a graceful unavailable state and say so in the handoff. A repeated call is a standing budget the player approves once (\`requestSpendGrant()\`, then \`generate({ grantId })\`), never a popup per turn, and its disclosed call rate and credits per period are computed from the game's own loop and the bench's measured average. The coin spellings (\`estimateCoins\`, \`perCallMaxCoins\`) are deprecated \u2014 write the credit names. Model output is data the game validates against its own expectation \u2014 never executed, and never authority over coin, credits, items, entitlements or rewards. Load \`$genex-llm-in-games\` before writing any of it.
985
985
  ${CONTRACT_END}
986
986
  `;
987
987
  var TOOLS_CONTRACT_HEAD = `# Genex Tools (always in effect in this folder)
@@ -995,7 +995,7 @@ var TOOLS_CONTRACT_OFFER = `5. **When the game is built and playable \u2014 neve
995
995
  var TOOLS_CONTRACT_HOSTED_LAWS = `5. **This game is hosted on Genex now, and publishing is glue \u2014 never a rebuild.** The game ships exactly as the user built it: \`initEmbed()\` first in the boot code (the \`genex-threejs-embed-auth\` card has the call; the renderer draws before any \`await waitForPlayer()\`), a static build into \`dist/\` with relative asset paths, then \`npx genex preview\`. Never restructure, reformat or "improve" the game to ship it, never scaffold a new app around it, and never start a design document or a build plan for it \u2014 the user's own process is theirs. Load the \`genex-tool-publish\` card before the first preview; it owns the vocabulary, the links and the limits.
996
996
  6. **Ship first, then report.** \`preview\` prints a preflight \u2014 phone memory, a missing volume slider, an asset nobody wired, a viewport line. It never blocks a deploy, so push the build as it is, hand over the link, THEN relay each preflight line to the user in one plain sentence with an offer to fix it, and fix one only on their yes. The preflight is a report for the user, not a to-do list for you.
997
997
  7. **The link is the game's page, and there are two versions.** After every preview give the user \`<dashboard>/draft/<slug>\` \u2014 \`dashboardOrigins[0]\` and \`slug\` from \`.genex/project.json\` \u2014 never localhost, a file path or the bare play origin. \`preview\` updates the draft and never touches what players are on; the first release is \`npx genex publish\` (it lists the game); after that "publish it", "update it" and "yes" all mean \`npx genex promote\` \u2014 the exact draft build, no rebuild. Ask once per round of work, in one line, and keep working while you wait.`;
998
- var TOOLS_CONTRACT_LLM_OFFER = `A model running while people play is built into the Genex platform \u2014 the player pays, with Genex coins or their own Claude/ChatGPT subscription, and approves it on a Genex sheet; your game just calls \`generate()\`. Want it that way?`;
998
+ var TOOLS_CONTRACT_LLM_OFFER = `A model running while people play is built into the Genex platform \u2014 the player pays, with their Genex credits or their own Claude/ChatGPT subscription, and approves it on a Genex sheet; your game just calls \`generate()\`. Want it that way?`;
999
999
  function toolsContractLlmLaw(n, hosted) {
1000
1000
  const onYes = hosted ? "On a yes, load `$genex-tool-llm` and then `$genex-llm-in-games`, which owns the build." : "On a yes, load `$genex-tool-llm`, which owns the rest: it checks the lane with `npx genex llm models` first, then runs `npx genex init --convert` (a hosted game is a static browser build \u2014 a local server holding a key can never ship).";
1001
1001
  return `${n}. **A model running while people PLAY is a platform feature \u2014 never something you wire onto the user's own meter.** When a request implies one (NPCs that talk or decide in their own words, content written from what the player types, a prompt box in the game, "let the player pick a model"), recognise it, say in ONE line: "${TOOLS_CONTRACT_LLM_OFFER}" \u2014 then ASK and wait. ${onYes} On a no, build the authored version instead. Either way, never ship a game that calls a model on a key or an account of the user's own: every visitor would spend their money with nobody approving it, and the credential is readable in the bundle. Player-funded generation is text and JSON only \u2014 3D, images, video and audio stay the asset lanes above, on the user's meter.`;
@@ -2149,6 +2149,401 @@ async function countOutcomes(kind, cwd = process.cwd()) {
2149
2149
  import fs13 from "fs/promises";
2150
2150
  import path14 from "path";
2151
2151
 
2152
+ // ../../packages/mobile-scan/src/image-dims.ts
2153
+ function u32be(b, o) {
2154
+ return (b[o] << 24 | b[o + 1] << 16 | b[o + 2] << 8 | b[o + 3]) >>> 0;
2155
+ }
2156
+ function u32le(b, o) {
2157
+ return (b[o] | b[o + 1] << 8 | b[o + 2] << 16 | b[o + 3] << 24) >>> 0;
2158
+ }
2159
+ function u16be(b, o) {
2160
+ return b[o] << 8 | b[o + 1];
2161
+ }
2162
+ function u16le(b, o) {
2163
+ return b[o] | b[o + 1] << 8;
2164
+ }
2165
+ function isPng(b) {
2166
+ return b.length > 24 && b[0] === 137 && b[1] === 80 && b[2] === 78 && b[3] === 71 && b[4] === 13 && b[5] === 10 && b[6] === 26 && b[7] === 10;
2167
+ }
2168
+ var KTX2_MAGIC = [171, 75, 84, 88, 32, 50, 48, 187, 13, 10, 26, 10];
2169
+ function parseImageDims(bytes) {
2170
+ if (bytes.length < 30) return null;
2171
+ if (isPng(bytes)) {
2172
+ return { width: u32be(bytes, 16), height: u32be(bytes, 20) };
2173
+ }
2174
+ if (bytes[0] === 255 && bytes[1] === 216) {
2175
+ let i = 2;
2176
+ while (i + 9 < bytes.length) {
2177
+ if (bytes[i] !== 255) {
2178
+ i++;
2179
+ continue;
2180
+ }
2181
+ const marker = bytes[i + 1];
2182
+ if (marker === 255) {
2183
+ i++;
2184
+ continue;
2185
+ }
2186
+ if (marker === 216 || marker >= 208 && marker <= 217) {
2187
+ i += 2;
2188
+ continue;
2189
+ }
2190
+ const isSof = marker >= 192 && marker <= 195 || marker >= 197 && marker <= 199 || marker >= 201 && marker <= 203 || marker >= 205 && marker <= 207;
2191
+ if (isSof) {
2192
+ return { width: u16be(bytes, i + 7), height: u16be(bytes, i + 5) };
2193
+ }
2194
+ i += 2 + u16be(bytes, i + 2);
2195
+ }
2196
+ return null;
2197
+ }
2198
+ if (bytes[0] === 82 && // RIFF….WEBP
2199
+ bytes[1] === 73 && bytes[2] === 70 && bytes[3] === 70 && bytes[8] === 87 && bytes[9] === 69 && bytes[10] === 66 && bytes[11] === 80) {
2200
+ const fourcc = String.fromCharCode(bytes[12], bytes[13], bytes[14], bytes[15]);
2201
+ if (fourcc === "VP8X" && bytes.length >= 30) {
2202
+ const w = 1 + (bytes[24] | bytes[25] << 8 | bytes[26] << 16);
2203
+ const h = 1 + (bytes[27] | bytes[28] << 8 | bytes[29] << 16);
2204
+ return { width: w, height: h };
2205
+ }
2206
+ if (fourcc === "VP8 " && bytes.length >= 30) {
2207
+ return { width: u16le(bytes, 26) & 16383, height: u16le(bytes, 28) & 16383 };
2208
+ }
2209
+ if (fourcc === "VP8L" && bytes.length >= 25 && bytes[20] === 47) {
2210
+ const b0 = bytes[21];
2211
+ const b1 = bytes[22];
2212
+ const b2 = bytes[23];
2213
+ const b3 = bytes[24];
2214
+ const width = 1 + ((b1 & 63) << 8 | b0);
2215
+ const height = 1 + ((b3 & 15) << 10 | b2 << 2 | (b1 & 192) >> 6);
2216
+ return { width, height };
2217
+ }
2218
+ return null;
2219
+ }
2220
+ if (KTX2_MAGIC.every((v, idx) => bytes[idx] === v)) {
2221
+ return { width: u32le(bytes, 20), height: u32le(bytes, 24), ktx2: true };
2222
+ }
2223
+ return null;
2224
+ }
2225
+
2226
+ // ../../packages/mobile-scan/src/glb.ts
2227
+ function u32le2(b, o) {
2228
+ return (b[o] | b[o + 1] << 8 | b[o + 2] << 16 | b[o + 3] << 24) >>> 0;
2229
+ }
2230
+ function scanGlb(bytes) {
2231
+ if (bytes.length < 20 || u32le2(bytes, 0) !== 1179937895) return null;
2232
+ try {
2233
+ const jsonLen = u32le2(bytes, 12);
2234
+ if (u32le2(bytes, 16) !== 1313821514) return null;
2235
+ const json = JSON.parse(new TextDecoder().decode(bytes.subarray(20, 20 + jsonLen)));
2236
+ let bin = null;
2237
+ const binHeader = 20 + jsonLen;
2238
+ if (binHeader + 8 <= bytes.length && u32le2(bytes, binHeader + 4) === 5130562) {
2239
+ bin = bytes.subarray(binHeader + 8, binHeader + 8 + u32le2(bytes, binHeader));
2240
+ }
2241
+ const images = [];
2242
+ for (const img of json.images ?? []) {
2243
+ if (img.bufferView == null || !bin) continue;
2244
+ const view = json.bufferViews?.[img.bufferView];
2245
+ if (!view?.byteLength) continue;
2246
+ const slice = bin.subarray(view.byteOffset ?? 0, (view.byteOffset ?? 0) + view.byteLength);
2247
+ const dims = parseImageDims(slice);
2248
+ if (dims) images.push({ ...dims, mimeType: img.mimeType, bytes: view.byteLength });
2249
+ }
2250
+ let triangles = 0;
2251
+ for (const mesh of json.meshes ?? []) {
2252
+ for (const prim of mesh.primitives ?? []) {
2253
+ const accessor = prim.indices != null ? json.accessors?.[prim.indices] : json.accessors?.[prim.attributes?.POSITION ?? -1];
2254
+ if (accessor?.count) triangles += Math.floor(accessor.count / 3);
2255
+ }
2256
+ }
2257
+ return {
2258
+ triangles,
2259
+ binBytes: bin?.byteLength ?? 0,
2260
+ images,
2261
+ extensions: json.extensionsUsed ?? []
2262
+ };
2263
+ } catch {
2264
+ return null;
2265
+ }
2266
+ }
2267
+
2268
+ // ../../packages/mobile-scan/src/scan.ts
2269
+ var GENEX_GENERATION_URL_RE = /https:\/\/[A-Za-z0-9.-]+\/generations\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._@-]+)/g;
2270
+ var SKYBOX_ROLE = "skybox-equirect";
2271
+ var MOBILE_RUNG_SUFFIX_RE = /@(\d+)$/;
2272
+ var MOBILE_BUDGET_MB = { broad: 300, modern: 700 };
2273
+ var KNOWN_CAPABILITIES = ["microphone", "camera", "geolocation"];
2274
+ var CAPABILITY_LITERALS = {
2275
+ microphone: ["__GENEX_VOICE__", "__GENEX_CAP_MICROPHONE__"],
2276
+ camera: ["__GENEX_CAP_CAMERA__"],
2277
+ geolocation: ["__GENEX_CAP_GEOLOCATION__"]
2278
+ };
2279
+ var CONSTRAINTS_MAX_CHARS = 4e3;
2280
+ var MEDIA_CALL = "getUserMedia";
2281
+ var KEY_START = /[A-Za-z_$]/;
2282
+ var KEY_PART = /[\w$]/;
2283
+ var SPACE = /\s/;
2284
+ function mediaRequestsInText(text) {
2285
+ const out = [];
2286
+ let from = 0;
2287
+ for (let at = text.indexOf(MEDIA_CALL); at !== -1; at = text.indexOf(MEDIA_CALL, from)) {
2288
+ from = at + MEDIA_CALL.length;
2289
+ if (at > 0 && KEY_PART.test(text[at - 1])) continue;
2290
+ const paren = skipSpace(text, from, text.length);
2291
+ if (text[paren] !== "(") continue;
2292
+ const open = skipSpace(text, paren + 1, text.length);
2293
+ if (text[open] !== "{") {
2294
+ out.push({ index: at, audio: false, video: false, read: false });
2295
+ continue;
2296
+ }
2297
+ const { stop: stop2, ...constraints } = readConstraints(text, open);
2298
+ out.push({ index: at, ...constraints });
2299
+ from = Math.max(from, stop2);
2300
+ }
2301
+ return out;
2302
+ }
2303
+ function skipSpace(text, from, end) {
2304
+ let at = from;
2305
+ while (at < end && SPACE.test(text[at])) at++;
2306
+ return at;
2307
+ }
2308
+ function indexWithin(text, needle, from, end) {
2309
+ for (let at = from; at + needle.length <= end; at++) {
2310
+ if (text.startsWith(needle, at)) return at;
2311
+ }
2312
+ return -1;
2313
+ }
2314
+ function readConstraints(text, open) {
2315
+ const end = Math.min(text.length, open + 1 + CONSTRAINTS_MAX_CHARS);
2316
+ const seen = { audio: false, video: false, read: true };
2317
+ const afterKey = (key, at) => {
2318
+ const colon = skipSpace(text, at, end);
2319
+ if (text[colon] !== ":") {
2320
+ seen.read = false;
2321
+ return;
2322
+ }
2323
+ if (key !== "audio" && key !== "video") return;
2324
+ const value = skipSpace(text, colon + 1, end);
2325
+ if (text[value] === "{" || text.startsWith("!0", value) || /^true\b/.test(text.slice(value, value + 5))) {
2326
+ seen[key] = true;
2327
+ } else if (!text.startsWith("!1", value) && !/^false\b/.test(text.slice(value, value + 6))) {
2328
+ seen.read = false;
2329
+ }
2330
+ };
2331
+ let depth = 1;
2332
+ let keyNext = true;
2333
+ for (let i = open + 1; i < end; i++) {
2334
+ const ch = text[i];
2335
+ if (ch === '"' || ch === "'" || ch === "`") {
2336
+ let close = i + 1;
2337
+ while (close < end && text[close] !== ch) close += text[close] === "\\" ? 2 : 1;
2338
+ if (close >= end) break;
2339
+ if (depth === 1 && keyNext) {
2340
+ keyNext = false;
2341
+ afterKey(text.slice(i + 1, close), close + 1);
2342
+ }
2343
+ i = close;
2344
+ } else if (ch === "/" && (text[i + 1] === "/" || text[i + 1] === "*")) {
2345
+ const line = text[i + 1] === "/";
2346
+ const close = indexWithin(text, line ? "\n" : "*/", i + 2, end);
2347
+ if (close === -1) break;
2348
+ i = line ? close : close + 1;
2349
+ } else if (ch === "{" || ch === "[" || ch === "(") {
2350
+ depth++;
2351
+ } else if (ch === "}" || ch === "]" || ch === ")") {
2352
+ if (--depth === 0) return { ...seen, stop: i + 1 };
2353
+ } else if (depth === 1 && ch === ",") {
2354
+ keyNext = true;
2355
+ } else if (depth === 1 && keyNext && !SPACE.test(ch)) {
2356
+ keyNext = false;
2357
+ if (!KEY_START.test(ch)) {
2358
+ seen.read = false;
2359
+ continue;
2360
+ }
2361
+ let stop2 = i + 1;
2362
+ while (stop2 < end && KEY_PART.test(text[stop2])) stop2++;
2363
+ afterKey(text.slice(i, stop2), stop2);
2364
+ i = stop2 - 1;
2365
+ }
2366
+ }
2367
+ return { ...seen, read: false, stop: end };
2368
+ }
2369
+ var CAPABILITY_PATTERNS = {
2370
+ camera: [/\bfacingMode\b/],
2371
+ geolocation: [
2372
+ /\bnavigator\??\.geolocation\b/,
2373
+ /\.geolocation\??\.(?:getCurrentPosition|watchPosition)\b/
2374
+ ]
2375
+ };
2376
+ function capabilitiesInText(text) {
2377
+ const opensCamera = mediaRequestsInText(text).some((request) => request.video);
2378
+ return KNOWN_CAPABILITIES.filter(
2379
+ (capability) => CAPABILITY_LITERALS[capability].some((literal) => text.includes(literal)) || (CAPABILITY_PATTERNS[capability] ?? []).some((pattern) => pattern.test(text)) || capability === "camera" && opensCamera
2380
+ );
2381
+ }
2382
+ function knownCapabilities(input) {
2383
+ if (!Array.isArray(input)) return [];
2384
+ return KNOWN_CAPABILITIES.filter((capability) => input.includes(capability));
2385
+ }
2386
+ var TEXT_EXT = /\.(js|mjs|cjs|html|css|json|txt)$/i;
2387
+ var CAPABILITY_FILE_RE = /\.(js|mjs|cjs|html)$/i;
2388
+ var TEXT_HEAD_BYTES = 8 * 1024 * 1024;
2389
+ var CAPABILITY_WINDOW_BYTES = TEXT_HEAD_BYTES;
2390
+ var CAPABILITY_OVERLAP_BYTES = 64 * 1024;
2391
+ var IMAGE_EXT = /\.(png|jpe?g|webp|ktx2)$/i;
2392
+ var VIDEO_EXT = /\.(mp4|webm|mov)$/i;
2393
+ var AUDIO_EXT = /\.(mp3|ogg|wav|m4a)$/i;
2394
+ var GLB_EXT = /\.(glb|gltf)$/i;
2395
+ function scanBundle(files) {
2396
+ const images = [];
2397
+ const glbs = [];
2398
+ const videos = [];
2399
+ const audio = [];
2400
+ const urls = /* @__PURE__ */ new Set();
2401
+ const declared = /* @__PURE__ */ new Set();
2402
+ let capabilitiesIncomplete = false;
2403
+ const decoder = new TextDecoder();
2404
+ let text = "";
2405
+ let totalBytes = 0;
2406
+ for (const f of files) {
2407
+ totalBytes += f.bytes.byteLength;
2408
+ if (IMAGE_EXT.test(f.relPath)) {
2409
+ const dims = parseImageDims(f.bytes);
2410
+ if (dims) images.push({ path: f.relPath, bytes: f.bytes.byteLength, ...dims });
2411
+ continue;
2412
+ }
2413
+ if (GLB_EXT.test(f.relPath)) {
2414
+ glbs.push({ path: f.relPath, bytes: f.bytes.byteLength, scan: scanGlb(f.bytes) });
2415
+ continue;
2416
+ }
2417
+ if (VIDEO_EXT.test(f.relPath)) {
2418
+ videos.push({ path: f.relPath, bytes: f.bytes.byteLength });
2419
+ continue;
2420
+ }
2421
+ if (AUDIO_EXT.test(f.relPath)) {
2422
+ audio.push({ path: f.relPath, bytes: f.bytes.byteLength });
2423
+ continue;
2424
+ }
2425
+ if (TEXT_EXT.test(f.relPath)) {
2426
+ const head = decoder.decode(f.bytes.subarray(0, TEXT_HEAD_BYTES));
2427
+ text += head;
2428
+ text += "\n";
2429
+ if (!CAPABILITY_FILE_RE.test(f.relPath)) continue;
2430
+ try {
2431
+ for (const capability of capabilitiesInText(head)) declared.add(capability);
2432
+ for (let start = CAPABILITY_WINDOW_BYTES - CAPABILITY_OVERLAP_BYTES; start < f.bytes.byteLength - CAPABILITY_OVERLAP_BYTES && declared.size < KNOWN_CAPABILITIES.length; start += CAPABILITY_WINDOW_BYTES) {
2433
+ const window = f.bytes.subarray(start, start + CAPABILITY_WINDOW_BYTES + CAPABILITY_OVERLAP_BYTES);
2434
+ for (const capability of capabilitiesInText(decoder.decode(window))) declared.add(capability);
2435
+ }
2436
+ } catch {
2437
+ capabilitiesIncomplete = true;
2438
+ }
2439
+ }
2440
+ }
2441
+ for (const m of text.matchAll(GENEX_GENERATION_URL_RE)) urls.add(m[0]);
2442
+ const capabilities = KNOWN_CAPABILITIES.filter((capability) => declared.has(capability));
2443
+ const dprMatch = text.match(/setPixelRatio\(\s*Math\.min\([^,)]+,\s*([\d.]+)\s*\)/);
2444
+ const markers = {
2445
+ rendererBackend: text.includes("WebGPURenderer") || text.includes("getContext('webgpu')") || text.includes('"webgpu"') ? "webgpu" : "webgl",
2446
+ postStack: /EffectComposer|postprocessing|PostProcessing|RenderPipeline/.test(text),
2447
+ dprCapLiteral: dprMatch?.[1] ? Number(dprMatch[1]) : null,
2448
+ contextLossHandler: text.includes("webglcontextlost") || text.includes("device.lost"),
2449
+ ktx2Loader: text.includes("KTX2Loader") || text.includes(".ktx2"),
2450
+ touchHints: /joystick/i.test(text) || text.includes("touchstart") && text.includes("touch-action"),
2451
+ adaptiveQuality: text.includes("__GENEX_QUALITY__"),
2452
+ modelRungs: text.includes("__GENEX_MODEL_RUNGS__"),
2453
+ voice: capabilities.includes("microphone")
2454
+ };
2455
+ return {
2456
+ images,
2457
+ glbs,
2458
+ videos,
2459
+ audio,
2460
+ genexAssetUrls: [...urls],
2461
+ markers,
2462
+ capabilities,
2463
+ capabilitiesIncomplete,
2464
+ totalBytes,
2465
+ fileCount: files.length
2466
+ };
2467
+ }
2468
+
2469
+ // ../../packages/mobile-scan/src/estimate.ts
2470
+ var MB = 1024 * 1024;
2471
+ var CSS_W = 430;
2472
+ var CSS_H = 932;
2473
+ var WORST_SKYBOX_BYTES = 8192 * 4096 * 4 * 1.33;
2474
+ var WORST_IMAGE_BYTES = 2048 * 2048 * 4 * 1.33;
2475
+ var KTX2_DISCOUNT = 6;
2476
+ function textureBytes(w, h, ktx2) {
2477
+ const raw = w * h * 4 * 1.33;
2478
+ return ktx2 ? raw / KTX2_DISCOUNT : raw;
2479
+ }
2480
+ function estimateVram(scan, externalDims) {
2481
+ let bundled = 0;
2482
+ let maxDim = 0;
2483
+ for (const img of scan.images) {
2484
+ bundled += textureBytes(img.width, img.height, img.ktx2);
2485
+ maxDim = Math.max(maxDim, img.width, img.height);
2486
+ }
2487
+ let glbTex = 0;
2488
+ let geometry = 0;
2489
+ for (const glb of scan.glbs) {
2490
+ if (!glb.scan) {
2491
+ geometry += glb.bytes;
2492
+ continue;
2493
+ }
2494
+ const ktx2 = glb.scan.extensions.some((e) => e.includes("KHR_texture_basisu"));
2495
+ for (const img of glb.scan.images) {
2496
+ glbTex += textureBytes(img.width, img.height, img.ktx2 || ktx2);
2497
+ maxDim = Math.max(maxDim, img.width, img.height);
2498
+ }
2499
+ geometry += Math.max(0, glb.scan.binBytes - glb.scan.images.reduce((s, i) => s + i.bytes, 0));
2500
+ }
2501
+ let external = 0;
2502
+ const unresolved = [];
2503
+ for (const url of scan.genexAssetUrls) {
2504
+ const role = url.split("/").pop() ?? "";
2505
+ if (MOBILE_RUNG_SUFFIX_RE.test(role)) continue;
2506
+ const dims = externalDims.get(url);
2507
+ if (dims) {
2508
+ const ktx2 = dims.ktx2 || dims.ktx2Available === true && scan.markers.ktx2Loader;
2509
+ if (dims.glb) {
2510
+ external += textureBytes(dims.width, dims.height, ktx2) + (dims.bytes ?? 0);
2511
+ maxDim = Math.max(maxDim, dims.width);
2512
+ } else {
2513
+ external += textureBytes(dims.width, dims.height, ktx2);
2514
+ maxDim = Math.max(maxDim, dims.width, dims.height);
2515
+ }
2516
+ } else {
2517
+ unresolved.push(url);
2518
+ external += role === SKYBOX_ROLE ? WORST_SKYBOX_BYTES : WORST_IMAGE_BYTES;
2519
+ maxDim = Math.max(maxDim, role === SKYBOX_ROLE ? 8192 : 2048);
2520
+ }
2521
+ }
2522
+ const dpr = Math.min(scan.markers.dprCapLiteral ?? 2, 3);
2523
+ const surface = CSS_W * CSS_H * dpr * dpr * 4;
2524
+ const framebuffers = surface * (3 + (scan.markers.postStack ? 4 : 0));
2525
+ const shadow = 2048 * 2048 * 4;
2526
+ const total = bundled + glbTex + external + framebuffers + shadow + geometry;
2527
+ return {
2528
+ estVramMb: Math.round(total / MB),
2529
+ breakdown: {
2530
+ bundledTexturesMb: Math.round(bundled / MB),
2531
+ glbTexturesMb: Math.round(glbTex / MB),
2532
+ externalTexturesMb: Math.round(external / MB),
2533
+ framebuffersMb: Math.round(framebuffers / MB),
2534
+ shadowDefaultMb: Math.round(shadow / MB),
2535
+ geometryMb: Math.round(geometry / MB)
2536
+ },
2537
+ unresolvedExternals: unresolved,
2538
+ textureMaxDim: maxDim
2539
+ };
2540
+ }
2541
+ function tierFor(estVramMb) {
2542
+ if (estVramMb < MOBILE_BUDGET_MB.broad) return "mobile-broad";
2543
+ if (estVramMb < MOBILE_BUDGET_MB.modern) return "mobile-modern";
2544
+ return "over-budget";
2545
+ }
2546
+
2152
2547
  // src/lib/download-assets.ts
2153
2548
  import fs12 from "fs/promises";
2154
2549
  import path13 from "path";
@@ -2345,7 +2740,54 @@ function splitArgs(text) {
2345
2740
  }
2346
2741
  var lineOf = (content, index) => content.slice(0, index).split("\n").length;
2347
2742
  function blankComments(raw) {
2348
- return raw.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " ")).replace(/(^|[^:])\/\/[^\n]*/gm, (m, p1) => p1 + " ".repeat(m.length - p1.length));
2743
+ const out = [];
2744
+ let kept = 0;
2745
+ const blank = (from, to) => {
2746
+ out.push(raw.slice(kept, from), raw.slice(from, to).replace(/[^\n]/g, " "));
2747
+ kept = to;
2748
+ };
2749
+ const templates = [];
2750
+ let depth = 0;
2751
+ let i = 0;
2752
+ while (i < raw.length) {
2753
+ const ch = raw[i];
2754
+ if (ch === '"' || ch === "'") {
2755
+ i++;
2756
+ while (i < raw.length && raw[i] !== ch && raw[i] !== "\n") i += raw[i] === "\\" ? 2 : 1;
2757
+ i++;
2758
+ } else if (ch === "`" || ch === "}" && templates.at(-1) === depth) {
2759
+ if (ch === "}") templates.pop();
2760
+ i++;
2761
+ while (i < raw.length && raw[i] !== "`" && !(raw[i] === "$" && raw[i + 1] === "{")) {
2762
+ i += raw[i] === "\\" ? 2 : 1;
2763
+ }
2764
+ if (raw[i] === "$") {
2765
+ templates.push(depth);
2766
+ i += 2;
2767
+ } else {
2768
+ i++;
2769
+ }
2770
+ } else if (ch === "{") {
2771
+ depth++;
2772
+ i++;
2773
+ } else if (ch === "}") {
2774
+ depth--;
2775
+ i++;
2776
+ } else if (ch === "/" && raw[i + 1] === "*" && raw.indexOf("*/", i + 2) !== -1) {
2777
+ const end = raw.indexOf("*/", i + 2) + 2;
2778
+ blank(i, end);
2779
+ i = end;
2780
+ } else if (ch === "/" && raw[i + 1] === "/" && raw[i - 1] !== ":") {
2781
+ const eol = raw.indexOf("\n", i);
2782
+ const end = eol === -1 ? raw.length : eol;
2783
+ blank(i, end);
2784
+ i = end;
2785
+ } else {
2786
+ i++;
2787
+ }
2788
+ }
2789
+ out.push(raw.slice(kept));
2790
+ return out.join("");
2349
2791
  }
2350
2792
  var IDENTITY_AWAIT = /\bawait\s+(waitForPlayer|waitForAuth)\s*\(/;
2351
2793
  var RENDERER_CTOR = /\bnew\s+(?:THREE\.)?(?:WebGLRenderer|WebGPURenderer)\s*\(/;
@@ -2406,8 +2848,10 @@ async function detectEmbedBoot(cwd = process.cwd()) {
2406
2848
  }
2407
2849
  var CONTROL_HEADS = /* @__PURE__ */ new Set(["if", "for", "while", "switch", "catch", "with", "await"]);
2408
2850
  function isFunctionBrace(tail) {
2409
- const t = tail.trimEnd();
2851
+ let t = tail.trimEnd();
2410
2852
  if (t.endsWith("=>")) return true;
2853
+ const typed = /^([\s\S]*\))[ \t]*:[ \t]*[^\s;=?()][^;=?()\n]*$/.exec(t);
2854
+ if (typed) t = typed[1];
2411
2855
  if (!t.endsWith(")")) return false;
2412
2856
  let depth = 0;
2413
2857
  let i = t.length - 1;
@@ -2618,6 +3062,199 @@ async function detectGenerationAudit(cwd = process.cwd()) {
2618
3062
  musicNoSlider: audited.some((e) => e.kind === "music" && e.status === "completed") && !/type\s*=\s*["'`]range["'`]/.test(haystack)
2619
3063
  };
2620
3064
  }
3065
+ async function readOwnSource(cwd) {
3066
+ const out = [];
3067
+ const page = await fs13.readFile(path14.join(cwd, "index.html"), "utf8").catch(() => "");
3068
+ if (page) {
3069
+ out.push({
3070
+ rel: "index.html",
3071
+ content: page.replace(/<!--[\s\S]*?-->/g, (m) => m.replace(/[^\n]/g, " "))
3072
+ });
3073
+ }
3074
+ const srcDir = path14.join(cwd, "src");
3075
+ let entries;
3076
+ try {
3077
+ entries = await fs13.readdir(srcDir, { recursive: true });
3078
+ } catch {
3079
+ return out;
3080
+ }
3081
+ for (const nativeRel of entries.sort()) {
3082
+ if (nativeRel.includes("node_modules")) continue;
3083
+ if (nativeRel.split(path14.sep)[0] === "controllers") continue;
3084
+ if (!/\.(ts|js|mts|mjs|tsx|jsx)$/.test(nativeRel)) continue;
3085
+ const raw = await fs13.readFile(path14.join(srcDir, nativeRel), "utf8").catch(() => "");
3086
+ if (!raw) continue;
3087
+ out.push({ rel: nativeRel.split(path14.sep).join("/"), content: blankComments(raw) });
3088
+ }
3089
+ return out;
3090
+ }
3091
+ async function readManifestCapabilities(cwd) {
3092
+ try {
3093
+ const pkg = JSON.parse(await fs13.readFile(path14.join(cwd, "package.json"), "utf8"));
3094
+ const raw = pkg.genex?.capabilities;
3095
+ if (raw === void 0 || raw === null) return { entries: [], notAList: false };
3096
+ return Array.isArray(raw) ? { entries: raw, notAList: false } : { entries: [], notAList: true };
3097
+ } catch {
3098
+ return { entries: [], notAList: false };
3099
+ }
3100
+ }
3101
+ var SOURCE_MICROPHONE = [
3102
+ /\.voice\s*\??\.\s*join\s*\(\s*(?:\)|\{)/,
3103
+ /\b(?:webkit)?SpeechRecognition\b/
3104
+ ];
3105
+ function opensMicrophone(content) {
3106
+ return SOURCE_MICROPHONE.some((evidence) => evidence.test(content)) || mediaRequestsInText(content).some((request) => request.audio);
3107
+ }
3108
+ async function detectCapabilities(cwd = process.cwd()) {
3109
+ const found = [...(await readManifestCapabilities(cwd)).entries];
3110
+ for (const { content } of await readOwnSource(cwd)) {
3111
+ found.push(...capabilitiesInText(content));
3112
+ if (opensMicrophone(content)) found.push("microphone");
3113
+ }
3114
+ return knownCapabilities(found);
3115
+ }
3116
+ var SHARE_CALL = /\bnavigator\s*\??\.\s*share\s*\(/g;
3117
+ var CLIPBOARD_USE = /\bnavigator\s*\??\.\s*clipboard\b/g;
3118
+ var WAKE_LOCK_USE = /\bnavigator\s*\??\.\s*wakeLock\b/g;
3119
+ var NEW_WINDOW = /\bwindow\s*\??\.\s*open\s*\(|["'`]_blank["'`]/g;
3120
+ var DOWNLOAD_ASSIGN = /\.download\s*=(?!=)/g;
3121
+ var DOWNLOAD_ATTR = /<a\b[^>]*\sdownload\b|\bsetAttribute\(\s*["'`]download["'`]/g;
3122
+ var MAKES_A_LINK = /\bcreateElement\(\s*["'`]a["'`]\s*\)|\bcreateObjectURL\b|\btoDataURL\b|\btoBlob\b|<a\b/;
3123
+ var SERVICE_WORKER = /\bserviceWorker\s*\??\.\s*register\s*\(/g;
3124
+ var MOTION_USE = /\b(?:DeviceOrientationEvent|DeviceMotionEvent)\b|["'`]device(?:orientation(?:absolute)?|motion)["'`]|\bondevice(?:orientation(?:absolute)?|motion)\b/g;
3125
+ var HASH_READ = /(?<![\w$.])(?:(?:window|document|globalThis|self)\s*\.\s*)?location\s*\.\s*hash\b(?!\s*(?:=(?!=)|\+=))/g;
3126
+ var DIALOG_CALL = /(?<![\w$.])((?:window|globalThis|self)\s*\.\s*)?(alert|confirm|prompt)\(/g;
3127
+ var MEMBER_HEAD = /^(?:(?:export|declare|abstract|async|public|private|protected|static|readonly|override|get|set)\s+|\*\s*)*$/;
3128
+ function afterCloseParen(content, from) {
3129
+ let depth = 1;
3130
+ let i = from;
3131
+ while (i < content.length && depth > 0) {
3132
+ const ch = content[i];
3133
+ if (ch === "(") depth++;
3134
+ else if (ch === ")") depth--;
3135
+ i++;
3136
+ }
3137
+ return i;
3138
+ }
3139
+ function dialogCalls(content) {
3140
+ const out = [];
3141
+ for (const m of content.matchAll(DIALOG_CALL)) {
3142
+ const index = m.index;
3143
+ const name = m[2];
3144
+ if (!m[1]) {
3145
+ const own = new RegExp(
3146
+ [
3147
+ `\\b(?:function\\*?|const|let|var|class)\\s+${name}\\b`,
3148
+ `\\bimport\\s+${name}\\s+from\\b`,
3149
+ // `{ prompt, toast }`, `(text, confirm)`, `(confirm: () => void)`,
3150
+ // `{ prompt: text }`: a named import, a destructuring, a parameter.
3151
+ `[{,(]\\s*${name}\\s*(?:[,}):]|=(?!=))`
3152
+ ].join("|")
3153
+ );
3154
+ if (own.test(content)) continue;
3155
+ const head = content.slice(content.lastIndexOf("\n", index) + 1, index).trimStart();
3156
+ const next = content.slice(afterCloseParen(content, index + m[0].length)).trimStart()[0];
3157
+ if (MEMBER_HEAD.test(head) && (next === "{" || next === ":")) continue;
3158
+ }
3159
+ out.push({ index, name });
3160
+ }
3161
+ return out;
3162
+ }
3163
+ function inBracelessArrow(content, index) {
3164
+ let start = index;
3165
+ while (start > 0 && !";{}".includes(content[start - 1])) start--;
3166
+ const span = content.slice(start, index);
3167
+ const arrow = span.lastIndexOf("=>");
3168
+ if (arrow === -1) return false;
3169
+ let depth = 0;
3170
+ let begun = false;
3171
+ for (const ch of span.slice(arrow + 2)) {
3172
+ if (ch === "(" || ch === "[") depth++;
3173
+ else if (ch === ")" || ch === "]") depth--;
3174
+ if (ch === "\n") {
3175
+ if (begun && depth <= 0) return false;
3176
+ } else if (ch.trim()) {
3177
+ begun = true;
3178
+ }
3179
+ }
3180
+ return true;
3181
+ }
3182
+ function hashReadsBeforeInit(content) {
3183
+ const reads = [...content.matchAll(HASH_READ)].map((m) => m.index);
3184
+ if (reads.length === 0) return [];
3185
+ const inits = [...content.matchAll(new RegExp(INIT_EMBED_CALL.source, "g"))].map((m) => ({
3186
+ index: m.index,
3187
+ flowEnd: enclosingFlowEnd(content, m.index)
3188
+ }));
3189
+ let depth = 0;
3190
+ let scanned = 0;
3191
+ return reads.filter((at) => {
3192
+ for (; scanned < at; scanned++) {
3193
+ const ch = content[scanned];
3194
+ if (ch === "{") depth++;
3195
+ else if (ch === "}") depth--;
3196
+ }
3197
+ if (depth <= 0) {
3198
+ return !inBracelessArrow(content, at) && !inits.some((init2) => init2.index < at);
3199
+ }
3200
+ const flowEnd = enclosingFlowEnd(content, at);
3201
+ return flowEnd !== content.length && inits.some((init2) => init2.flowEnd === flowEnd && init2.index > at);
3202
+ });
3203
+ }
3204
+ async function detectShellApis(cwd = process.cwd()) {
3205
+ const manifest = await readManifestCapabilities(cwd);
3206
+ const found = {
3207
+ share: [],
3208
+ clipboard: [],
3209
+ wakeLock: [],
3210
+ newWindow: [],
3211
+ download: [],
3212
+ dialogs: [],
3213
+ serviceWorker: [],
3214
+ motionNoPermission: [],
3215
+ hashBeforeInit: [],
3216
+ capture: [],
3217
+ manifestCapabilities: knownCapabilities(manifest.entries),
3218
+ unknownCapabilities: manifest.entries.filter((entry) => !KNOWN_CAPABILITIES.includes(entry)).map((entry) => {
3219
+ const quoted = JSON.stringify(entry).replace(
3220
+ /[^\x20-\x7e]/g,
3221
+ (ch) => `\\u${ch.charCodeAt(0).toString(16).padStart(4, "0")}`
3222
+ );
3223
+ return quoted.length > 40 ? `${quoted.slice(0, 40)}\u2026` : quoted;
3224
+ }),
3225
+ capabilitiesNotAList: manifest.notAList
3226
+ };
3227
+ let asksMotionPermission = false;
3228
+ for (const file of await readOwnSource(cwd)) {
3229
+ const { rel, content } = file;
3230
+ const add = (into, index, detail) => {
3231
+ const where = `${rel}:${lineOf(content, index)}`;
3232
+ if (!into.some((d) => d.where === where)) into.push({ where, detail });
3233
+ };
3234
+ for (const m of content.matchAll(SHARE_CALL)) add(found.share, m.index, "navigator.share");
3235
+ for (const m of content.matchAll(CLIPBOARD_USE)) add(found.clipboard, m.index, "navigator.clipboard");
3236
+ for (const m of content.matchAll(WAKE_LOCK_USE)) add(found.wakeLock, m.index, "navigator.wakeLock");
3237
+ for (const m of content.matchAll(NEW_WINDOW)) {
3238
+ add(found.newWindow, m.index, m[0].includes("open") ? "window.open" : 'target "_blank"');
3239
+ }
3240
+ if (MAKES_A_LINK.test(content)) {
3241
+ for (const m of content.matchAll(DOWNLOAD_ASSIGN)) add(found.download, m.index, "a.download");
3242
+ }
3243
+ for (const m of content.matchAll(DOWNLOAD_ATTR)) add(found.download, m.index, "a download link");
3244
+ for (const call of dialogCalls(content)) add(found.dialogs, call.index, `${call.name}()`);
3245
+ for (const m of content.matchAll(SERVICE_WORKER)) {
3246
+ add(found.serviceWorker, m.index, "serviceWorker.register");
3247
+ }
3248
+ for (const m of content.matchAll(MOTION_USE)) add(found.motionNoPermission, m.index, "device motion");
3249
+ if (/\brequestPermission\b/.test(content)) asksMotionPermission = true;
3250
+ for (const at of hashReadsBeforeInit(content)) add(found.hashBeforeInit, at, "location.hash");
3251
+ for (const request of mediaRequestsInText(content)) {
3252
+ if (!request.read) add(found.capture, request.index, "getUserMedia");
3253
+ }
3254
+ }
3255
+ if (asksMotionPermission) found.motionNoPermission = [];
3256
+ return found;
3257
+ }
2621
3258
  async function detectFeatures(log, cwd = process.cwd()) {
2622
3259
  return {
2623
3260
  embedSdkVersion: await detectEmbedSdkVersion(cwd),
@@ -2627,28 +3264,114 @@ async function detectFeatures(log, cwd = process.cwd()) {
2627
3264
  mobileControls: await detectMobileControls(cwd),
2628
3265
  surfaces: await detectSurfaceScan(cwd),
2629
3266
  generations: await detectGenerationAudit(cwd),
2630
- embedBoot: await detectEmbedBoot(cwd)
3267
+ embedBoot: await detectEmbedBoot(cwd),
3268
+ capabilities: await detectCapabilities(cwd),
3269
+ shellApis: await detectShellApis(cwd)
2631
3270
  };
2632
3271
  }
2633
3272
  function advisoryNudges(log, d) {
2634
3273
  if (!d.embedSdkVersion) {
2635
3274
  log.warn(
2636
- "This game has no player identity or saves \u2014 players lose everything on refresh. The genex-threejs-embed-auth skill wires it with one call (initEmbed) \u2014 load it before publishing."
3275
+ "This game has no player identity or saves \u2014 players lose everything on refresh. The genex-threejs-embed-auth skill wires it with one call (initEmbed) \u2014 load it before publishing."
3276
+ );
3277
+ }
3278
+ if (d.multiplayer && !d.embedSdkVersion) {
3279
+ log.warn(
3280
+ "Multiplayer will NOT work: @genex-ai/multiplayer needs the embed-sdk token (getColyseusAuth) \u2014 the relay rejects tokenless joins. Wire genex-threejs-embed-auth first."
3281
+ );
3282
+ }
3283
+ if (d.matchmaking && !d.multiplayer) {
3284
+ log.warn(
3285
+ "package.json declares genex.matchmaking but @genex-ai/multiplayer is not installed \u2014 matchmaking cannot work. Install the SDK and use matchmake() (see genex-threejs-multiplayer)."
3286
+ );
3287
+ }
3288
+ embedBootNudges(log, d);
3289
+ shellNudges(log, d);
3290
+ surfaceNudges(log, d.surfaces);
3291
+ generationNudges(log, d.generations);
3292
+ }
3293
+ function places(found, withDetail = false) {
3294
+ const shown = found.slice(0, 4).map((d) => withDetail ? `${d.where} (${d.detail})` : d.where);
3295
+ const rest = found.length - shown.length;
3296
+ return shown.join(", ") + (rest > 0 ? ` (+${rest} more)` : "");
3297
+ }
3298
+ function shellNudges(log, d) {
3299
+ const s = d.shellApis;
3300
+ const helper = "from @genex-ai/embed-sdk";
3301
+ let namedHelper = false;
3302
+ if (s.capabilitiesNotAList) {
3303
+ log.warn(
3304
+ `package.json \`genex.capabilities\` is not a list, so it declares nothing. Write the names in a list: "genex": { "capabilities": ["camera"] } \u2014 the names are ${KNOWN_CAPABILITIES.join(", ")}.`
3305
+ );
3306
+ }
3307
+ if (s.unknownCapabilities.length) {
3308
+ log.warn(
3309
+ `package.json \`genex.capabilities\` lists ${s.unknownCapabilities.slice(0, 6).join(", ")}, which Genex does not know, so it gives the game nothing. The names are ${KNOWN_CAPABILITIES.join(", ")}, spelled exactly so.`
3310
+ );
3311
+ }
3312
+ const mediaDeclared = ["microphone", "camera"].filter((name) => d.capabilities.includes(name));
3313
+ if (s.capture.length && mediaDeclared.length < 2 && !s.manifestCapabilities.some((name) => name === "microphone" || name === "camera")) {
3314
+ const declared = mediaDeclared.length ? `only the ${mediaDeclared[0]} is declared for this build` : "nothing in this game declares the microphone or the camera";
3315
+ log.warn(
3316
+ `${places(s.capture)} calls getUserMedia, and ${declared}: the constraints are not written out at the call, so the scan cannot tell which it opens. On the game's Genex page an undeclared microphone or camera is refused without the player being asked, while the same build works on its own link. Say which the game uses in package.json: "genex": { "capabilities": ["microphone"] } (or "camera", or both).`
3317
+ );
3318
+ }
3319
+ if (s.share.length) {
3320
+ namedHelper = true;
3321
+ log.warn(
3322
+ `${places(s.share)} calls navigator.share. It shares whatever the game hands it, and on the game's Genex page \`location.href\` is the bare game address, never the page players should be sent to; some shells have no share sheet at all. share({ text, params }) ${helper} builds the link to the game's page itself, opens the sheet where there is one and copies the link where there is not. Call it inside the click or tap.`
3323
+ );
3324
+ }
3325
+ if (s.clipboard.length) {
3326
+ namedHelper = true;
3327
+ log.warn(
3328
+ `${places(s.clipboard)} uses navigator.clipboard. The game's Genex page gives a framed game clipboard writing and never reading, and a write is still refused without a tap behind it, or in a frame on a page that does not pass it on. copyText(text) ${helper} tries it and falls back to the older copy a frame is allowed after a tap; call it inside the click. To read text, let the player paste into an <input>.`
3329
+ );
3330
+ }
3331
+ if (s.wakeLock.length) {
3332
+ namedHelper = true;
3333
+ log.warn(
3334
+ `${places(s.wakeLock)} uses navigator.wakeLock. The game's Genex page gives it to every framed game, but the browser drops the lock every time the tab is hidden and some browsers have none to give. keepAwake(true) ${helper} takes the lock again when the player comes back, says so where there is none, and answers ok in the Genex app, where the screen already stays on.`
3335
+ );
3336
+ }
3337
+ if (s.newWindow.length) {
3338
+ namedHelper = true;
3339
+ log.warn(
3340
+ `${places(s.newWindow, true)} opens a new window. The game's Genex page gives a game no new windows and the Genex app blocks links out of a game, so there the click does nothing. openExternal(url) ${helper}, called inside the click, is the one call for every shell: on the game's page Genex asks the player and opens the link, on the game's own link it is a new tab, and where links out are not available it answers \`unsupported\` \u2014 show the address instead.`
3341
+ );
3342
+ }
3343
+ if (s.download.length) {
3344
+ namedHelper = true;
3345
+ log.warn(
3346
+ `${places(s.download)} saves a file through a download link. The game's Genex page drops a download started inside a game, with no error. saveFile(blob, name) ${helper}, called inside the click, saves an image, a video, JSON or plain text wherever a shell can: on the game's page Genex asks the player first, and where saving is not available it answers \`unsupported\`.`
3347
+ );
3348
+ }
3349
+ if (s.hashBeforeInit.length && d.embedSdkVersion) {
3350
+ namedHelper = true;
3351
+ log.warn(
3352
+ `${places(s.hashBeforeInit)} reads location.hash where it can run before initEmbed(). On the game's own link the address bar still holds Genex's sign-in fragment at that point, and initEmbed() puts the game's own fragment back without a hashchange event, so that first read is wrong and nothing corrects it. Read the link with getEntry() ${helper}: getEntry().hash and getEntry().params answer the same in every shell, whenever they are asked.`
2637
3353
  );
2638
3354
  }
2639
- if (d.multiplayer && !d.embedSdkVersion) {
3355
+ if (namedHelper) {
3356
+ log.dim(
3357
+ " share, copyText, keepAwake, openExternal, saveFile and getEntry ship in the current @genex-ai/embed-sdk. If an import is missing: npm i @genex-ai/embed-sdk@latest"
3358
+ );
3359
+ }
3360
+ if (s.dialogs.length) {
2640
3361
  log.warn(
2641
- "Multiplayer will NOT work: @genex-ai/multiplayer needs the embed-sdk token (getColyseusAuth) \u2014 the relay rejects tokenless joins. Wire genex-threejs-embed-auth first."
3362
+ `${places(s.dialogs, true)} calls a browser dialog. The game's Genex page shows none for a game: alert() does nothing, confirm() answers false and prompt() answers null, with no error, while the same build works on its own link. Draw the dialog in the game's own interface (genex-threejs-game-ui).`
2642
3363
  );
2643
3364
  }
2644
- if (d.matchmaking && !d.multiplayer) {
3365
+ if (s.motionNoPermission.length) {
2645
3366
  log.warn(
2646
- "package.json declares genex.matchmaking but @genex-ai/multiplayer is not installed \u2014 matchmaking cannot work. Install the SDK and use matchmake() (see genex-threejs-multiplayer)."
3367
+ `${places(s.motionNoPermission)} uses device tilt or motion, and nothing in the game calls requestPermission(). An iPhone sends no motion events until the player allows them, and it asks only when DeviceOrientationEvent.requestPermission() is called from a tap inside the game. Ask in the Start tap, treat a refusal or no events at all as "no sensor", and keep the on-screen controls as the fallback (genex-threejs-touch-controls).`
3368
+ );
3369
+ }
3370
+ if (s.serviceWorker.length) {
3371
+ log.warn(
3372
+ `${places(s.serviceWorker)} registers a service worker. A returning player's browser then serves the copy it kept: an older build after an update, or a game that has since been taken down, and no deploy reaches it. A Genex game does not register one.`
2647
3373
  );
2648
3374
  }
2649
- embedBootNudges(log, d);
2650
- surfaceNudges(log, d.surfaces);
2651
- generationNudges(log, d.generations);
2652
3375
  }
2653
3376
  function embedBootNudges(log, d) {
2654
3377
  const boot = d.embedBoot;
@@ -2912,359 +3635,98 @@ function auditGenerationPlan(input) {
2912
3635
  const owed = /* @__PURE__ */ new Map();
2913
3636
  for (const row of rows) {
2914
3637
  if (row.procedural || row.lane === null || reconciled.has(row)) continue;
2915
- if (row.status !== "planned" && row.status !== "generating") continue;
2916
- if (lanesRun.has(row.lane)) {
2917
- if (rows.filter((other) => other.lane === row.lane).length > 1) warnings.push(`DESIGN.md asset ${row.name} has no matched generation id. Other ${row.lane} work completed, but that does not establish this asset's delivery. Record its id or the substitution reason.`);
2918
- continue;
2919
- }
2920
- if (lanesPending.has(row.lane)) {
2921
- warnings.push(`DESIGN.md plans ${row.name}; its ${row.lane} lane has pending work. Check the existing request and record its id; do not enqueue a duplicate.`);
2922
- continue;
2923
- }
2924
- if (row.lane === "skybox") continue;
2925
- const held = owed.get(row.lane) ?? [];
2926
- held.push(row.status === "generating" ? `${row.name} (says generating, not in this project's ledger)` : row.name);
2927
- owed.set(row.lane, held);
2928
- }
2929
- for (const lane of declaredLanes) {
2930
- if (lanesRun.has(lane) || lanesPending.has(lane) || owed.has(lane) || lane === "skybox") continue;
2931
- if (!rows.some((r) => r.lane === lane)) owed.set(lane, ["declared as this subsystem's lane"]);
2932
- }
2933
- for (const [lane, names2] of owed) {
2934
- const shown = names2.slice(0, 3).join(", ") + (names2.length > 3 ? `, +${names2.length - 3} more` : "");
2935
- warnings.push(
2936
- `DESIGN.md plans generated ${lane} work (${shown}) and this project has no ${lane} generation that landed \u2014 the plan says so and the generation ledger has nothing completed. Generate it (${LANE_COMMAND[lane] ?? `npx genex ${lane}`} "<prompt>" --no-wait), or change the row to say what happens instead and why.`
2937
- );
2938
- }
2939
- const paidRows = rows.filter((r) => !r.procedural);
2940
- const codeRows = rows.filter((r) => r.procedural);
2941
- if (paidRows.length === 0 && codeRows.length >= 1 && ledger.length === 0 && !explained) {
2942
- warnings.push(
2943
- "DESIGN.md's asset plan is all code-built rows and this project has never run a generation. What the player sees must read as the thing it is (build contract, law 22): a coloured box or a capsule is not a finished building, person, or prop. Generate what the request names and what the player looks at up close (npx genex model / texture / music \u2026 --no-wait), or write a `Generation: none \u2014` line beside the table ending in your own sentence about why code alone reaches the bar in THIS game. " + (noneMarker ? "The line is there but its reason is still the unfilled template \u2014 replace it with the actual reason." : "")
2944
- );
2945
- }
2946
- return warnings;
2947
- }
2948
-
2949
- // src/lib/deploy.ts
2950
- import crypto2 from "crypto";
2951
- import fs17 from "fs/promises";
2952
- import os5 from "os";
2953
- import path17 from "path";
2954
-
2955
- // src/lib/source-size.ts
2956
- import fs14 from "fs/promises";
2957
- import path15 from "path";
2958
- var FALLBACK_SOURCE_LIMITS = {
2959
- maxTotalBytes: 2 * 1024 * 1024 * 1024,
2960
- maxFiles: 5e3
2961
- };
2962
- function isOversized(p, limits) {
2963
- return p.bytes >= limits.maxTotalBytes || p.files >= limits.maxFiles;
2964
- }
2965
- function formatBytes2(n) {
2966
- if (n >= 1024 ** 3) return `${(n / 1024 ** 3).toFixed(1)} GB`;
2967
- if (n >= 1024 ** 2) return `${Math.round(n / 1024 ** 2)} MB`;
2968
- if (n >= 1024) return `${Math.round(n / 1024)} KB`;
2969
- return `${n} B`;
2970
- }
2971
- function groupByDir(entries, limit = 5) {
2972
- const acc = /* @__PURE__ */ new Map();
2973
- for (const [file, bytes] of entries) {
2974
- const dirParts = file.split("/").slice(0, -1).slice(0, 2);
2975
- const dir = dirParts.length > 0 ? dirParts.join("/") : "(root)";
2976
- const row = acc.get(dir) ?? { dir, files: 0, bytes: 0 };
2977
- row.files += 1;
2978
- row.bytes += bytes;
2979
- acc.set(dir, row);
2980
- }
2981
- return [...acc.values()].sort((a, b) => b.bytes - a.bytes).slice(0, limit);
2982
- }
2983
- function oversizedLines(p, limits) {
2984
- return [
2985
- `This is ${formatBytes2(p.bytes)} across ${p.files} files, past the ${formatBytes2(limits.maxTotalBytes)} / ${limits.maxFiles} file limit on this account.`,
2986
- "Heaviest directories:",
2987
- ...p.top.map((d) => ` ${formatBytes2(d.bytes).padStart(8)} ${String(d.files).padStart(6)} files ${d.dir}`),
2988
- "Screenshots, traces and reports made while building are not part of the game \u2014",
2989
- "put them in .genex/scratch/ (already ignored), or add the folders above to .gitignore.",
2990
- "If this really is the game, ask an admin to raise the source limit on your account."
2991
- ];
2992
- }
2993
- async function measurePayload(cwd, staged) {
2994
- const entries = [];
2995
- let bytes = 0;
2996
- for (const file of staged) {
2997
- let size = 0;
2998
- try {
2999
- size = (await fs14.stat(path15.join(cwd, file))).size;
3000
- } catch {
3001
- size = 0;
3002
- }
3003
- bytes += size;
3004
- entries.push([file, size]);
3005
- }
3006
- return { files: staged.length, bytes, top: groupByDir(entries) };
3007
- }
3008
-
3009
- // ../../packages/mobile-scan/src/image-dims.ts
3010
- function u32be(b, o) {
3011
- return (b[o] << 24 | b[o + 1] << 16 | b[o + 2] << 8 | b[o + 3]) >>> 0;
3012
- }
3013
- function u32le(b, o) {
3014
- return (b[o] | b[o + 1] << 8 | b[o + 2] << 16 | b[o + 3] << 24) >>> 0;
3015
- }
3016
- function u16be(b, o) {
3017
- return b[o] << 8 | b[o + 1];
3018
- }
3019
- function u16le(b, o) {
3020
- return b[o] | b[o + 1] << 8;
3021
- }
3022
- function isPng(b) {
3023
- return b.length > 24 && b[0] === 137 && b[1] === 80 && b[2] === 78 && b[3] === 71 && b[4] === 13 && b[5] === 10 && b[6] === 26 && b[7] === 10;
3024
- }
3025
- var KTX2_MAGIC = [171, 75, 84, 88, 32, 50, 48, 187, 13, 10, 26, 10];
3026
- function parseImageDims(bytes) {
3027
- if (bytes.length < 30) return null;
3028
- if (isPng(bytes)) {
3029
- return { width: u32be(bytes, 16), height: u32be(bytes, 20) };
3030
- }
3031
- if (bytes[0] === 255 && bytes[1] === 216) {
3032
- let i = 2;
3033
- while (i + 9 < bytes.length) {
3034
- if (bytes[i] !== 255) {
3035
- i++;
3036
- continue;
3037
- }
3038
- const marker = bytes[i + 1];
3039
- if (marker === 255) {
3040
- i++;
3041
- continue;
3042
- }
3043
- if (marker === 216 || marker >= 208 && marker <= 217) {
3044
- i += 2;
3045
- continue;
3046
- }
3047
- const isSof = marker >= 192 && marker <= 195 || marker >= 197 && marker <= 199 || marker >= 201 && marker <= 203 || marker >= 205 && marker <= 207;
3048
- if (isSof) {
3049
- return { width: u16be(bytes, i + 7), height: u16be(bytes, i + 5) };
3050
- }
3051
- i += 2 + u16be(bytes, i + 2);
3052
- }
3053
- return null;
3054
- }
3055
- if (bytes[0] === 82 && // RIFF….WEBP
3056
- bytes[1] === 73 && bytes[2] === 70 && bytes[3] === 70 && bytes[8] === 87 && bytes[9] === 69 && bytes[10] === 66 && bytes[11] === 80) {
3057
- const fourcc = String.fromCharCode(bytes[12], bytes[13], bytes[14], bytes[15]);
3058
- if (fourcc === "VP8X" && bytes.length >= 30) {
3059
- const w = 1 + (bytes[24] | bytes[25] << 8 | bytes[26] << 16);
3060
- const h = 1 + (bytes[27] | bytes[28] << 8 | bytes[29] << 16);
3061
- return { width: w, height: h };
3062
- }
3063
- if (fourcc === "VP8 " && bytes.length >= 30) {
3064
- return { width: u16le(bytes, 26) & 16383, height: u16le(bytes, 28) & 16383 };
3065
- }
3066
- if (fourcc === "VP8L" && bytes.length >= 25 && bytes[20] === 47) {
3067
- const b0 = bytes[21];
3068
- const b1 = bytes[22];
3069
- const b2 = bytes[23];
3070
- const b3 = bytes[24];
3071
- const width = 1 + ((b1 & 63) << 8 | b0);
3072
- const height = 1 + ((b3 & 15) << 10 | b2 << 2 | (b1 & 192) >> 6);
3073
- return { width, height };
3074
- }
3075
- return null;
3076
- }
3077
- if (KTX2_MAGIC.every((v, idx) => bytes[idx] === v)) {
3078
- return { width: u32le(bytes, 20), height: u32le(bytes, 24), ktx2: true };
3079
- }
3080
- return null;
3081
- }
3082
-
3083
- // ../../packages/mobile-scan/src/glb.ts
3084
- function u32le2(b, o) {
3085
- return (b[o] | b[o + 1] << 8 | b[o + 2] << 16 | b[o + 3] << 24) >>> 0;
3086
- }
3087
- function scanGlb(bytes) {
3088
- if (bytes.length < 20 || u32le2(bytes, 0) !== 1179937895) return null;
3089
- try {
3090
- const jsonLen = u32le2(bytes, 12);
3091
- if (u32le2(bytes, 16) !== 1313821514) return null;
3092
- const json = JSON.parse(new TextDecoder().decode(bytes.subarray(20, 20 + jsonLen)));
3093
- let bin = null;
3094
- const binHeader = 20 + jsonLen;
3095
- if (binHeader + 8 <= bytes.length && u32le2(bytes, binHeader + 4) === 5130562) {
3096
- bin = bytes.subarray(binHeader + 8, binHeader + 8 + u32le2(bytes, binHeader));
3097
- }
3098
- const images = [];
3099
- for (const img of json.images ?? []) {
3100
- if (img.bufferView == null || !bin) continue;
3101
- const view = json.bufferViews?.[img.bufferView];
3102
- if (!view?.byteLength) continue;
3103
- const slice = bin.subarray(view.byteOffset ?? 0, (view.byteOffset ?? 0) + view.byteLength);
3104
- const dims = parseImageDims(slice);
3105
- if (dims) images.push({ ...dims, mimeType: img.mimeType, bytes: view.byteLength });
3106
- }
3107
- let triangles = 0;
3108
- for (const mesh of json.meshes ?? []) {
3109
- for (const prim of mesh.primitives ?? []) {
3110
- const accessor = prim.indices != null ? json.accessors?.[prim.indices] : json.accessors?.[prim.attributes?.POSITION ?? -1];
3111
- if (accessor?.count) triangles += Math.floor(accessor.count / 3);
3112
- }
3113
- }
3114
- return {
3115
- triangles,
3116
- binBytes: bin?.byteLength ?? 0,
3117
- images,
3118
- extensions: json.extensionsUsed ?? []
3119
- };
3120
- } catch {
3121
- return null;
3122
- }
3123
- }
3124
-
3125
- // ../../packages/mobile-scan/src/scan.ts
3126
- var GENEX_GENERATION_URL_RE = /https:\/\/[A-Za-z0-9.-]+\/generations\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._@-]+)/g;
3127
- var SKYBOX_ROLE = "skybox-equirect";
3128
- var MOBILE_RUNG_SUFFIX_RE = /@(\d+)$/;
3129
- var MOBILE_BUDGET_MB = { broad: 300, modern: 700 };
3130
- var TEXT_EXT = /\.(js|mjs|cjs|html|css|json|txt)$/i;
3131
- var IMAGE_EXT = /\.(png|jpe?g|webp|ktx2)$/i;
3132
- var VIDEO_EXT = /\.(mp4|webm|mov)$/i;
3133
- var AUDIO_EXT = /\.(mp3|ogg|wav|m4a)$/i;
3134
- var GLB_EXT = /\.(glb|gltf)$/i;
3135
- function scanBundle(files) {
3136
- const images = [];
3137
- const glbs = [];
3138
- const videos = [];
3139
- const audio = [];
3140
- const urls = /* @__PURE__ */ new Set();
3141
- let text = "";
3142
- let totalBytes = 0;
3143
- for (const f of files) {
3144
- totalBytes += f.bytes.byteLength;
3145
- if (IMAGE_EXT.test(f.relPath)) {
3146
- const dims = parseImageDims(f.bytes);
3147
- if (dims) images.push({ path: f.relPath, bytes: f.bytes.byteLength, ...dims });
3148
- continue;
3149
- }
3150
- if (GLB_EXT.test(f.relPath)) {
3151
- glbs.push({ path: f.relPath, bytes: f.bytes.byteLength, scan: scanGlb(f.bytes) });
3152
- continue;
3153
- }
3154
- if (VIDEO_EXT.test(f.relPath)) {
3155
- videos.push({ path: f.relPath, bytes: f.bytes.byteLength });
3638
+ if (row.status !== "planned" && row.status !== "generating") continue;
3639
+ if (lanesRun.has(row.lane)) {
3640
+ if (rows.filter((other) => other.lane === row.lane).length > 1) warnings.push(`DESIGN.md asset ${row.name} has no matched generation id. Other ${row.lane} work completed, but that does not establish this asset's delivery. Record its id or the substitution reason.`);
3156
3641
  continue;
3157
3642
  }
3158
- if (AUDIO_EXT.test(f.relPath)) {
3159
- audio.push({ path: f.relPath, bytes: f.bytes.byteLength });
3643
+ if (lanesPending.has(row.lane)) {
3644
+ warnings.push(`DESIGN.md plans ${row.name}; its ${row.lane} lane has pending work. Check the existing request and record its id; do not enqueue a duplicate.`);
3160
3645
  continue;
3161
3646
  }
3162
- if (TEXT_EXT.test(f.relPath)) {
3163
- text += new TextDecoder().decode(f.bytes.subarray(0, 8 * 1024 * 1024));
3164
- text += "\n";
3165
- }
3647
+ if (row.lane === "skybox") continue;
3648
+ const held = owed.get(row.lane) ?? [];
3649
+ held.push(row.status === "generating" ? `${row.name} (says generating, not in this project's ledger)` : row.name);
3650
+ owed.set(row.lane, held);
3166
3651
  }
3167
- for (const m of text.matchAll(GENEX_GENERATION_URL_RE)) urls.add(m[0]);
3168
- const dprMatch = text.match(/setPixelRatio\(\s*Math\.min\([^,)]+,\s*([\d.]+)\s*\)/);
3169
- const markers = {
3170
- rendererBackend: text.includes("WebGPURenderer") || text.includes("getContext('webgpu')") || text.includes('"webgpu"') ? "webgpu" : "webgl",
3171
- postStack: /EffectComposer|postprocessing|PostProcessing|RenderPipeline/.test(text),
3172
- dprCapLiteral: dprMatch?.[1] ? Number(dprMatch[1]) : null,
3173
- contextLossHandler: text.includes("webglcontextlost") || text.includes("device.lost"),
3174
- ktx2Loader: text.includes("KTX2Loader") || text.includes(".ktx2"),
3175
- touchHints: /joystick/i.test(text) || text.includes("touchstart") && text.includes("touch-action"),
3176
- adaptiveQuality: text.includes("__GENEX_QUALITY__"),
3177
- modelRungs: text.includes("__GENEX_MODEL_RUNGS__"),
3178
- voice: text.includes("__GENEX_VOICE__")
3179
- };
3180
- return {
3181
- images,
3182
- glbs,
3183
- videos,
3184
- audio,
3185
- genexAssetUrls: [...urls],
3186
- markers,
3187
- totalBytes,
3188
- fileCount: files.length
3189
- };
3652
+ for (const lane of declaredLanes) {
3653
+ if (lanesRun.has(lane) || lanesPending.has(lane) || owed.has(lane) || lane === "skybox") continue;
3654
+ if (!rows.some((r) => r.lane === lane)) owed.set(lane, ["declared as this subsystem's lane"]);
3655
+ }
3656
+ for (const [lane, names2] of owed) {
3657
+ const shown = names2.slice(0, 3).join(", ") + (names2.length > 3 ? `, +${names2.length - 3} more` : "");
3658
+ warnings.push(
3659
+ `DESIGN.md plans generated ${lane} work (${shown}) and this project has no ${lane} generation that landed \u2014 the plan says so and the generation ledger has nothing completed. Generate it (${LANE_COMMAND[lane] ?? `npx genex ${lane}`} "<prompt>" --no-wait), or change the row to say what happens instead and why.`
3660
+ );
3661
+ }
3662
+ const paidRows = rows.filter((r) => !r.procedural);
3663
+ const codeRows = rows.filter((r) => r.procedural);
3664
+ if (paidRows.length === 0 && codeRows.length >= 1 && ledger.length === 0 && !explained) {
3665
+ warnings.push(
3666
+ "DESIGN.md's asset plan is all code-built rows and this project has never run a generation. What the player sees must read as the thing it is (build contract, law 22): a coloured box or a capsule is not a finished building, person, or prop. Generate what the request names and what the player looks at up close (npx genex model / texture / music \u2026 --no-wait), or write a `Generation: none \u2014` line beside the table ending in your own sentence about why code alone reaches the bar in THIS game. " + (noneMarker ? "The line is there but its reason is still the unfilled template \u2014 replace it with the actual reason." : "")
3667
+ );
3668
+ }
3669
+ return warnings;
3190
3670
  }
3191
3671
 
3192
- // ../../packages/mobile-scan/src/estimate.ts
3193
- var MB = 1024 * 1024;
3194
- var CSS_W = 430;
3195
- var CSS_H = 932;
3196
- var WORST_SKYBOX_BYTES = 8192 * 4096 * 4 * 1.33;
3197
- var WORST_IMAGE_BYTES = 2048 * 2048 * 4 * 1.33;
3198
- var KTX2_DISCOUNT = 6;
3199
- function textureBytes(w, h, ktx2) {
3200
- const raw = w * h * 4 * 1.33;
3201
- return ktx2 ? raw / KTX2_DISCOUNT : raw;
3672
+ // src/lib/deploy.ts
3673
+ import crypto2 from "crypto";
3674
+ import fs17 from "fs/promises";
3675
+ import os5 from "os";
3676
+ import path17 from "path";
3677
+
3678
+ // src/lib/source-size.ts
3679
+ import fs14 from "fs/promises";
3680
+ import path15 from "path";
3681
+ var FALLBACK_SOURCE_LIMITS = {
3682
+ maxTotalBytes: 2 * 1024 * 1024 * 1024,
3683
+ maxFiles: 5e3
3684
+ };
3685
+ function isOversized(p, limits) {
3686
+ return p.bytes >= limits.maxTotalBytes || p.files >= limits.maxFiles;
3202
3687
  }
3203
- function estimateVram(scan, externalDims) {
3204
- let bundled = 0;
3205
- let maxDim = 0;
3206
- for (const img of scan.images) {
3207
- bundled += textureBytes(img.width, img.height, img.ktx2);
3208
- maxDim = Math.max(maxDim, img.width, img.height);
3209
- }
3210
- let glbTex = 0;
3211
- let geometry = 0;
3212
- for (const glb of scan.glbs) {
3213
- if (!glb.scan) {
3214
- geometry += glb.bytes;
3215
- continue;
3216
- }
3217
- const ktx2 = glb.scan.extensions.some((e) => e.includes("KHR_texture_basisu"));
3218
- for (const img of glb.scan.images) {
3219
- glbTex += textureBytes(img.width, img.height, img.ktx2 || ktx2);
3220
- maxDim = Math.max(maxDim, img.width, img.height);
3221
- }
3222
- geometry += Math.max(0, glb.scan.binBytes - glb.scan.images.reduce((s, i) => s + i.bytes, 0));
3688
+ function formatBytes2(n) {
3689
+ if (n >= 1024 ** 3) return `${(n / 1024 ** 3).toFixed(1)} GB`;
3690
+ if (n >= 1024 ** 2) return `${Math.round(n / 1024 ** 2)} MB`;
3691
+ if (n >= 1024) return `${Math.round(n / 1024)} KB`;
3692
+ return `${n} B`;
3693
+ }
3694
+ function groupByDir(entries, limit = 5) {
3695
+ const acc = /* @__PURE__ */ new Map();
3696
+ for (const [file, bytes] of entries) {
3697
+ const dirParts = file.split("/").slice(0, -1).slice(0, 2);
3698
+ const dir = dirParts.length > 0 ? dirParts.join("/") : "(root)";
3699
+ const row = acc.get(dir) ?? { dir, files: 0, bytes: 0 };
3700
+ row.files += 1;
3701
+ row.bytes += bytes;
3702
+ acc.set(dir, row);
3223
3703
  }
3224
- let external = 0;
3225
- const unresolved = [];
3226
- for (const url of scan.genexAssetUrls) {
3227
- const role = url.split("/").pop() ?? "";
3228
- if (MOBILE_RUNG_SUFFIX_RE.test(role)) continue;
3229
- const dims = externalDims.get(url);
3230
- if (dims) {
3231
- const ktx2 = dims.ktx2 || dims.ktx2Available === true && scan.markers.ktx2Loader;
3232
- if (dims.glb) {
3233
- external += textureBytes(dims.width, dims.height, ktx2) + (dims.bytes ?? 0);
3234
- maxDim = Math.max(maxDim, dims.width);
3235
- } else {
3236
- external += textureBytes(dims.width, dims.height, ktx2);
3237
- maxDim = Math.max(maxDim, dims.width, dims.height);
3238
- }
3239
- } else {
3240
- unresolved.push(url);
3241
- external += role === SKYBOX_ROLE ? WORST_SKYBOX_BYTES : WORST_IMAGE_BYTES;
3242
- maxDim = Math.max(maxDim, role === SKYBOX_ROLE ? 8192 : 2048);
3704
+ return [...acc.values()].sort((a, b) => b.bytes - a.bytes).slice(0, limit);
3705
+ }
3706
+ function oversizedLines(p, limits) {
3707
+ return [
3708
+ `This is ${formatBytes2(p.bytes)} across ${p.files} files, past the ${formatBytes2(limits.maxTotalBytes)} / ${limits.maxFiles} file limit on this account.`,
3709
+ "Heaviest directories:",
3710
+ ...p.top.map((d) => ` ${formatBytes2(d.bytes).padStart(8)} ${String(d.files).padStart(6)} files ${d.dir}`),
3711
+ "Screenshots, traces and reports made while building are not part of the game \u2014",
3712
+ "put them in .genex/scratch/ (already ignored), or add the folders above to .gitignore.",
3713
+ "If this really is the game, ask an admin to raise the source limit on your account."
3714
+ ];
3715
+ }
3716
+ async function measurePayload(cwd, staged) {
3717
+ const entries = [];
3718
+ let bytes = 0;
3719
+ for (const file of staged) {
3720
+ let size = 0;
3721
+ try {
3722
+ size = (await fs14.stat(path15.join(cwd, file))).size;
3723
+ } catch {
3724
+ size = 0;
3243
3725
  }
3726
+ bytes += size;
3727
+ entries.push([file, size]);
3244
3728
  }
3245
- const dpr = Math.min(scan.markers.dprCapLiteral ?? 2, 3);
3246
- const surface = CSS_W * CSS_H * dpr * dpr * 4;
3247
- const framebuffers = surface * (3 + (scan.markers.postStack ? 4 : 0));
3248
- const shadow = 2048 * 2048 * 4;
3249
- const total = bundled + glbTex + external + framebuffers + shadow + geometry;
3250
- return {
3251
- estVramMb: Math.round(total / MB),
3252
- breakdown: {
3253
- bundledTexturesMb: Math.round(bundled / MB),
3254
- glbTexturesMb: Math.round(glbTex / MB),
3255
- externalTexturesMb: Math.round(external / MB),
3256
- framebuffersMb: Math.round(framebuffers / MB),
3257
- shadowDefaultMb: Math.round(shadow / MB),
3258
- geometryMb: Math.round(geometry / MB)
3259
- },
3260
- unresolvedExternals: unresolved,
3261
- textureMaxDim: maxDim
3262
- };
3263
- }
3264
- function tierFor(estVramMb) {
3265
- if (estVramMb < MOBILE_BUDGET_MB.broad) return "mobile-broad";
3266
- if (estVramMb < MOBILE_BUDGET_MB.modern) return "mobile-modern";
3267
- return "over-budget";
3729
+ return { files: staged.length, bytes, top: groupByDir(entries) };
3268
3730
  }
3269
3731
 
3270
3732
  // src/commands/ui.ts
@@ -5093,9 +5555,25 @@ async function uiAudit(opts, log) {
5093
5555
  }
5094
5556
 
5095
5557
  // src/lib/deploy.ts
5558
+ function goLiveCapabilities(declared, scanned) {
5559
+ const own = knownCapabilities(declared ?? []);
5560
+ if (scanned === void 0) return own.length > 0 ? own : void 0;
5561
+ return knownCapabilities([...own, ...scanned]);
5562
+ }
5563
+ function printCapabilityPreflight(capabilities, log) {
5564
+ if (capabilities === void 0) return;
5565
+ if (capabilities.length > 0) {
5566
+ log.info(`On its Genex page this build is given: ${capabilities.join(", ")}.`);
5567
+ } else {
5568
+ log.dim(
5569
+ ` On its Genex page this build is given nothing that needs a permission (${KNOWN_CAPABILITIES.join(", ")}).`
5570
+ );
5571
+ }
5572
+ }
5096
5573
  function printMobilePreflight(files, log) {
5574
+ let scan = null;
5097
5575
  try {
5098
- const scan = scanBundle(files.map((f) => ({ relPath: f.relPath, bytes: f.bytes })));
5576
+ scan = scanBundle(files.map((f) => ({ relPath: f.relPath, bytes: f.bytes })));
5099
5577
  const externalDims = /* @__PURE__ */ new Map();
5100
5578
  for (const url of scan.genexAssetUrls) {
5101
5579
  const role = url.split("/").pop() ?? "";
@@ -5112,7 +5590,7 @@ function printMobilePreflight(files, log) {
5112
5590
  const line = `Mobile preflight: ~${est.estVramMb} MB est. GPU memory on phones (budget: <${MOBILE_BUDGET_MB.broad} MB broad, <${MOBILE_BUDGET_MB.modern} MB modern) \u2014 ${tier}.`;
5113
5591
  if (tier === "mobile-broad") {
5114
5592
  log.dim(` ${line}`);
5115
- return;
5593
+ return scan;
5116
5594
  }
5117
5595
  log.warn(line);
5118
5596
  for (const url of est.unresolvedExternals) {
@@ -5140,6 +5618,7 @@ function printMobilePreflight(files, log) {
5140
5618
  }
5141
5619
  } catch {
5142
5620
  }
5621
+ return scan;
5143
5622
  }
5144
5623
  async function printUiAuditPreflight(log) {
5145
5624
  try {
@@ -5246,7 +5725,9 @@ async function deployGame(ctx, opts, log) {
5246
5725
  if (lfsPointers.length > 8) log.dim(` \u2026and ${lfsPointers.length - 8} more`);
5247
5726
  return false;
5248
5727
  }
5249
- printMobilePreflight(files, log);
5728
+ const scan = printMobilePreflight(files, log);
5729
+ const capabilities = scan?.capabilitiesIncomplete ? goLiveCapabilities([...opts.capabilities ?? [], ...scan.capabilities], void 0) : goLiveCapabilities(opts.capabilities, scan?.capabilities);
5730
+ printCapabilityPreflight(capabilities, log);
5250
5731
  await printUiAuditPreflight(log);
5251
5732
  if (await workspaceMode(cwd) === "game") await printPipelineStatePreflight(log);
5252
5733
  const commit = contentCommit(files);
@@ -5293,7 +5774,7 @@ async function deployGame(ctx, opts, log) {
5293
5774
  );
5294
5775
  if (pushed !== true) return false;
5295
5776
  log.step("Publishing\u2026");
5296
- const published = await callPublish(ctx, commit, opts, log, sourceCommitSha);
5777
+ const published = await callPublish(ctx, commit, opts, log, sourceCommitSha, capabilities);
5297
5778
  if (!published) return false;
5298
5779
  if (opts.channel === "staging") {
5299
5780
  const tree = await sourceTreeHash(cwd);
@@ -5515,7 +5996,7 @@ async function uploadPartWithRetry(base, encoded, auth, uploadId, partNumber, by
5515
5996
  await new Promise((resolve) => setTimeout(resolve, attempt === 0 ? 500 : 2e3));
5516
5997
  }
5517
5998
  }
5518
- async function callPublish(ctx, commit, opts, log, sourceCommitSha) {
5999
+ async function callPublish(ctx, commit, opts, log, sourceCommitSha, capabilities) {
5519
6000
  let res;
5520
6001
  try {
5521
6002
  res = await apiFetch(`${ctx.apiUrl}/api/games/${ctx.projectId}/publish`, {
@@ -5531,6 +6012,11 @@ async function callPublish(ctx, commit, opts, log, sourceCommitSha) {
5531
6012
  ...opts.embedSdkVersion ? { embedSdkVersion: opts.embedSdkVersion } : {},
5532
6013
  ...opts.multiplayer !== void 0 ? { multiplayer: opts.multiplayer } : {},
5533
6014
  ...opts.mobileControls !== void 0 ? { mobileControls: opts.mobileControls } : {},
6015
+ // The build's declared capabilities, on both channels: a preview's list
6016
+ // is what the owner's own preview frame is given, and a promotion
6017
+ // carries it to production with the build. An API from before the
6018
+ // field strips it, so the two can deploy in either order.
6019
+ ...capabilities !== void 0 ? { capabilities } : {},
5534
6020
  // Recorded into the release history and acted on by nothing. Absent when
5535
6021
  // the source push produced no commit (a user's own repo, or a failure
5536
6022
  // that already stopped the deploy), which the server accepts as null.
@@ -6147,6 +6633,9 @@ async function runPreview(opts) {
6147
6633
  // declared preset doesn't silently run the default (null clears a removed
6148
6634
  // config), embedSdkVersion/multiplayer so the dashboard's Publish button can
6149
6635
  // list the draft without wrongly treating it as a pre-embed-auth bundle.
6636
+ // Capabilities ride the preview for a different reason: the staging build's
6637
+ // own list is what the owner's preview frame is given, so a microphone or
6638
+ // camera can be tried before anything is published.
6150
6639
  {
6151
6640
  // Preview goes to the STAGING channel — the whole point of the release
6152
6641
  // cycle. Before channels this same call replaced the build every player
@@ -6157,7 +6646,8 @@ async function runPreview(opts) {
6157
6646
  matchmaking: detections.matchmaking,
6158
6647
  embedSdkVersion: detections.embedSdkVersion,
6159
6648
  multiplayer: detections.multiplayer,
6160
- mobileControls: detections.mobileControls
6649
+ mobileControls: detections.mobileControls,
6650
+ capabilities: detections.capabilities
6161
6651
  },
6162
6652
  log
6163
6653
  );
@@ -7968,7 +8458,10 @@ async function runPublish(opts) {
7968
8458
  matchmaking: detections.matchmaking,
7969
8459
  embedSdkVersion: detections.embedSdkVersion,
7970
8460
  multiplayer: detections.multiplayer,
7971
- mobileControls: detections.mobileControls
8461
+ mobileControls: detections.mobileControls,
8462
+ // Declared on the staging go-live; the promotion below copies that
8463
+ // build's own list to production with it.
8464
+ capabilities: detections.capabilities
7972
8465
  },
7973
8466
  log
7974
8467
  );
@@ -22948,7 +23441,7 @@ async function runBlender(opts) {
22948
23441
  return 1;
22949
23442
  }
22950
23443
  if (sub === "serve") {
22951
- const { serveLocalBlender } = await import("./blender-serve-66OHC4ML.js");
23444
+ const { serveLocalBlender } = await import("./blender-serve-EH2MOW3U.js");
22952
23445
  const port = Number(process.env.GENEX_BLENDER_PORT ?? 8088);
22953
23446
  log.step(`Starting a local Blender service on port ${port}`);
22954
23447
  log.plain(
@@ -22957,7 +23450,7 @@ async function runBlender(opts) {
22957
23450
  return serveLocalBlender({ port, log });
22958
23451
  }
22959
23452
  if (sub === "mcp") {
22960
- const { runBlenderMcp } = await import("./blender-mcp-QVEGHE5L.js");
23453
+ const { runBlenderMcp } = await import("./blender-mcp-WSURPZET.js");
22961
23454
  return runBlenderMcp();
22962
23455
  }
22963
23456
  if (sub === "seat") {
@@ -23465,7 +23958,12 @@ import fs35 from "fs/promises";
23465
23958
  import path36 from "path";
23466
23959
  import { randomUUID as randomUUID2 } from "crypto";
23467
23960
  var SUBS3 = ["models", "bench", "price", "status", "cancel"];
23468
- var LLM_BENCH_APPROVAL_REQUIRED = "STOP: `genex llm bench` runs the real model and spends YOUR OWN coin \u2014 your wallet, not a player's, and one this build's asset allowance cannot see. Re-run with --max-coins <n> --user-approved only after the person at the keyboard agreed to the number.";
23961
+ var LLM_BENCH_APPROVAL_REQUIRED = "STOP: `genex llm bench` runs the real model and spends YOUR OWN credits \u2014 your balance, not a player's, and a spend this build's asset allowance does not count. Re-run with --max-credits <n> --user-approved only after the person at the keyboard agreed to the number.";
23962
+ var LLM_MAX_COINS_DEPRECATED = "--max-coins is deprecated: it is read as --max-credits, the same number \u2014 a bench spends credits now.";
23963
+ function benchBalanceShortLine(spendable, maxCredits) {
23964
+ return `Your spendable credits (${spendable}) cannot cover one attempt at --max-credits ${maxCredits} \u2014 nothing was sent, nothing was spent. Add credits, or re-run with a lower --max-credits once the person at the keyboard agrees to it.`;
23965
+ }
23966
+ var LLM_BENCH_UNVERIFIED_LINE = "Credits pay only for a verified email, and this account's is not verified yet (credits_unverified) \u2014 nothing was sent, nothing was spent. Verify it, then re-run.";
23469
23967
  var LLM_LANE_OFF_LINE = "The runtime LLM lane is off on this stand \u2014 in-game generate() answers 404 here, and nothing can be benchmarked.";
23470
23968
  var LLM_TOOLS_CONVERT_HINT = [
23471
23969
  "In-game model calls need a hosted game. This folder is a Genex Tools workspace, so nothing here",
@@ -23478,7 +23976,7 @@ function serverSlotCounts(body) {
23478
23976
  return { held: body.slotsHeld, limit: body.slotLimit };
23479
23977
  }
23480
23978
  var PENDING_SLOT_RELEASE_MINUTES = 10;
23481
- var LLM_STATUS_LEGEND = `"awaiting bill" = the call has stopped but the provider's bill is not final, so its coin stays held until the bill resolves; such a row stops holding a slot ${PENDING_SLOT_RELEASE_MINUTES} minutes after dispatch.`;
23979
+ var LLM_STATUS_LEGEND = `"awaiting bill" = the call has stopped but the provider's bill is not final, so what it reserved stays held until the bill resolves; such a row stops holding a slot ${PENDING_SLOT_RELEASE_MINUTES} minutes after dispatch.`;
23482
23980
  var LLM_STATUS_NO_COUNT_LINE = "This stand does not report how many slots are held across your projects, so no count is shown.";
23483
23981
  var LLM_CANCEL_AWAITING_BILL = "this call already stopped; its bill is awaiting the provider";
23484
23982
  var LLM_CANCEL_ALREADY_FINAL = "this call is already final";
@@ -23492,11 +23990,33 @@ var DEFAULT_SAMPLE_TIMEOUT_SEC = 180;
23492
23990
  function parseProviderKey(value) {
23493
23991
  return value === "ok" || value === "rejected" || value === "unknown" ? value : null;
23494
23992
  }
23993
+ function bpsOrNull(value) {
23994
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
23995
+ }
23996
+ function standingCreditGrantOrNull(value) {
23997
+ if (!value || typeof value !== "object") return null;
23998
+ const v = value;
23999
+ const whole = (x) => typeof x === "number" && Number.isSafeInteger(x) && x >= 0;
24000
+ if (!whole(v.minCredits) || !whole(v.maxCredits) || !whole(v.stepCredits) || !whole(v.defaultCredits)) return null;
24001
+ return {
24002
+ minCredits: v.minCredits,
24003
+ maxCredits: v.maxCredits,
24004
+ stepCredits: v.stepCredits,
24005
+ defaultCredits: v.defaultCredits,
24006
+ ...whole(v.minUsdCents) ? { minUsdCents: v.minUsdCents } : {},
24007
+ ...whole(v.maxUsdCents) ? { maxUsdCents: v.maxUsdCents } : {},
24008
+ ...whole(v.stepUsdCents) ? { stepUsdCents: v.stepUsdCents } : {}
24009
+ };
24010
+ }
23495
24011
  async function readRuntimeLane(apiUrl, token) {
23496
24012
  const empty = (state) => ({
23497
24013
  state,
23498
24014
  models: null,
23499
24015
  recommendedDeclaredHeadroomBps: null,
24016
+ currency: null,
24017
+ feeBps: null,
24018
+ usdCentsPerCredit: null,
24019
+ standingCreditGrant: null,
23500
24020
  externalProviders: null,
23501
24021
  total: null,
23502
24022
  featuredCount: null,
@@ -23515,7 +24035,11 @@ async function readRuntimeLane(apiUrl, token) {
23515
24035
  return {
23516
24036
  state: "live",
23517
24037
  models: body.models,
23518
- recommendedDeclaredHeadroomBps: typeof body.recommendedDeclaredHeadroomBps === "number" && Number.isFinite(body.recommendedDeclaredHeadroomBps) ? body.recommendedDeclaredHeadroomBps : null,
24038
+ recommendedDeclaredHeadroomBps: bpsOrNull(body.recommendedDeclaredHeadroomBps),
24039
+ currency: body.currency === "credits" || body.currency === "coins" ? body.currency : null,
24040
+ feeBps: bpsOrNull(body.feeBps),
24041
+ usdCentsPerCredit: typeof body.usdCentsPerCredit === "number" && Number.isSafeInteger(body.usdCentsPerCredit) && body.usdCentsPerCredit >= 1 ? body.usdCentsPerCredit : null,
24042
+ standingCreditGrant: standingCreditGrantOrNull(body.standingCreditGrant),
23519
24043
  externalProviders: Array.isArray(body.externalProviders) ? body.externalProviders : null,
23520
24044
  total: typeof body.total === "number" && Number.isFinite(body.total) ? body.total : null,
23521
24045
  featuredCount: typeof body.featuredCount === "number" && Number.isFinite(body.featuredCount) ? body.featuredCount : null,
@@ -23531,18 +24055,45 @@ function percentile2(values, p) {
23531
24055
  const rank2 = Math.ceil(p / 100 * sorted.length);
23532
24056
  return sorted[Math.min(sorted.length - 1, Math.max(0, rank2 - 1))];
23533
24057
  }
23534
- function recommendEstimateCoins(p95, headroomBps) {
23535
- return Math.max(1, Math.ceil(p95 * (1e4 + headroomBps) / 1e4));
24058
+ function percentilePicos(values, p) {
24059
+ if (values.length === 0) return null;
24060
+ const sorted = [...values].sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
24061
+ const rank2 = Math.ceil(p / 100 * sorted.length);
24062
+ return sorted[Math.min(sorted.length - 1, Math.max(0, rank2 - 1))];
23536
24063
  }
23537
- function recommendPerCallMaxCoins(p95, max, headroomBps) {
23538
- return Math.max(recommendEstimateCoins(p95, headroomBps), recommendEstimateCoins(max, headroomBps));
24064
+ var PICOS_PER_CENT = 10000000000n;
24065
+ function ceilingCreditsFor(costPicos, terms) {
24066
+ if (costPicos <= 0n) return 1;
24067
+ const num = costPicos * BigInt(1e4 + terms.feeBps) * BigInt(1e4 + terms.headroomBps);
24068
+ const den = 100000000n * BigInt(terms.usdCentsPerCredit) * PICOS_PER_CENT;
24069
+ return Math.max(1, Number((num + den - 1n) / den));
24070
+ }
24071
+ function averageCreditsPerCall(costsPicos, terms) {
24072
+ if (costsPicos.length === 0) return null;
24073
+ const sum = costsPicos.reduce((a, b) => a + b, 0n);
24074
+ return Number(sum * BigInt(1e4 + terms.feeBps)) / Number(BigInt(costsPicos.length) * 10000n * BigInt(terms.usdCentsPerCredit) * PICOS_PER_CENT);
24075
+ }
24076
+ function perCallEstimateCreditsFor(costsPicos, terms) {
24077
+ if (costsPicos.length === 0) return null;
24078
+ const sum = costsPicos.reduce((a, b) => a + b, 0n);
24079
+ const num = sum * BigInt(1e4 + terms.feeBps);
24080
+ const den = BigInt(costsPicos.length) * 10000n * BigInt(terms.usdCentsPerCredit) * PICOS_PER_CENT;
24081
+ return Math.max(1, Number((num + den - 1n) / den));
24082
+ }
24083
+ function formatCredits(value) {
24084
+ if (!Number.isFinite(value)) return "\u2014";
24085
+ if (value >= 100) return String(Math.round(value));
24086
+ return String(Number(value.toPrecision(value < 1 ? 2 : 3)));
24087
+ }
24088
+ function usdFromPicos(picos) {
24089
+ if (picos === null || picos === void 0) return "unknown";
24090
+ const value = typeof picos === "bigint" ? picos : /^\d+$/.test(picos) ? BigInt(picos) : null;
24091
+ if (value === null) return "unknown";
24092
+ return `$${(Number(value) / 1e12).toFixed(6)}`;
23539
24093
  }
23540
24094
  function usdPerMillion(value) {
23541
24095
  return `$${value.toFixed(value < 1 ? 4 : 2)}`;
23542
24096
  }
23543
- function usd(value) {
23544
- return value === null ? "unknown" : `$${value.toFixed(6)}`;
23545
- }
23546
24097
  async function runLlm(opts = {}) {
23547
24098
  const log = createLogger({ quiet: opts.quiet || opts.json });
23548
24099
  const cwd = opts.cwd ?? process.cwd();
@@ -23568,16 +24119,23 @@ async function runLlm(opts = {}) {
23568
24119
  return;
23569
24120
  }
23570
24121
  if (sub === "bench") {
23571
- if (opts.maxCoins === void 0 || opts.userApproved !== true) {
24122
+ if (opts.maxCoins !== void 0) process.stderr.write(`${c.yellow("!")} ${LLM_MAX_COINS_DEPRECATED}
24123
+ `);
24124
+ if (opts.maxCoins !== void 0 && opts.maxCredits !== void 0 && opts.maxCoins !== opts.maxCredits) {
24125
+ fail5("llm bench", `Pass --max-credits alone \u2014 --max-coins is its deprecated name, and the two disagree (${opts.maxCredits} vs ${opts.maxCoins}).`);
24126
+ return;
24127
+ }
24128
+ const approvedCredits = opts.maxCredits ?? opts.maxCoins;
24129
+ if (approvedCredits === void 0 || opts.userApproved !== true) {
23572
24130
  fail5("llm bench", LLM_BENCH_APPROVAL_REQUIRED);
23573
24131
  return;
23574
24132
  }
23575
- if (!Number.isInteger(opts.maxCoins) || opts.maxCoins < 1) {
23576
- fail5("llm bench", `--max-coins takes a whole number of coin, 1 or more (got ${String(opts.maxCoins)}).`);
24133
+ if (!Number.isInteger(approvedCredits) || approvedCredits < 1) {
24134
+ fail5("llm bench", `--max-credits takes a whole number of credits, 1 or more (got ${String(approvedCredits)}).`);
23577
24135
  return;
23578
24136
  }
23579
24137
  if (!opts.benchPrompt?.trim()) {
23580
- fail5("llm bench", '`genex llm bench` needs the prompt your game would send, e.g. genex llm bench "<prompt>" --max-coins <n> --user-approved.');
24138
+ fail5("llm bench", '`genex llm bench` needs the prompt your game would send, e.g. genex llm bench "<prompt>" --max-credits <n> --user-approved.');
23581
24139
  return;
23582
24140
  }
23583
24141
  if (opts.jsonOutput && opts.textOutput) {
@@ -23669,6 +24227,10 @@ async function reportModels(apiUrl, token, opts, log, toolsOnly = false) {
23669
24227
  models: null,
23670
24228
  externalProviders: null,
23671
24229
  recommendedDeclaredHeadroomBps: null,
24230
+ currency: null,
24231
+ feeBps: null,
24232
+ usdCentsPerCredit: null,
24233
+ standingCreditGrant: null,
23672
24234
  total: null,
23673
24235
  featuredCount: null,
23674
24236
  // The stand was never asked, so the verdict is unknown — but the key is
@@ -23694,6 +24256,12 @@ async function reportModels(apiUrl, token, opts, log, toolsOnly = false) {
23694
24256
  models: lane.models,
23695
24257
  externalProviders: lane.externalProviders,
23696
24258
  recommendedDeclaredHeadroomBps: lane.recommendedDeclaredHeadroomBps,
24259
+ // The credit terms a call is billed on, the server's — null on a stand
24260
+ // that predates credit billing, never a number the CLI filled in.
24261
+ currency: lane.currency,
24262
+ feeBps: lane.feeBps,
24263
+ usdCentsPerCredit: lane.usdCentsPerCredit,
24264
+ standingCreditGrant: lane.standingCreditGrant,
23697
24265
  total: lane.total ?? lane.models?.length ?? null,
23698
24266
  featuredCount: lane.featuredCount ?? (lane.models ? lane.models.filter((m) => m.featured).length : null),
23699
24267
  // `null` when the lane is not live or the server predates the probe.
@@ -23740,8 +24308,9 @@ async function reportModels(apiUrl, token, opts, log, toolsOnly = false) {
23740
24308
  );
23741
24309
  for (const m of shown) {
23742
24310
  const plan = m.personalPlan ? ` \xB7 personal plan: ${m.personalPlan}` : "";
24311
+ const kind = m.kind === "typesafe" ? " \xB7 judge (classifier): judge() under a budget, not generate() or bench" : "";
23743
24312
  log.plain(
23744
- ` ${c.cyan(m.id)} ${m.label} \u2014 ${usdPerMillion(m.inputUsdPerMillion)} in / ${usdPerMillion(m.outputUsdPerMillion)} out per million tokens${plan}`
24313
+ ` ${c.cyan(m.id)} ${m.label} \u2014 ${usdPerMillion(m.inputUsdPerMillion)} in / ${usdPerMillion(m.outputUsdPerMillion)} out per million tokens${plan}${kind}`
23745
24314
  );
23746
24315
  }
23747
24316
  if (hiddenCount > 0) {
@@ -23757,74 +24326,104 @@ async function reportModels(apiUrl, token, opts, log, toolsOnly = false) {
23757
24326
  }
23758
24327
  log.plain("");
23759
24328
  if (lane.recommendedDeclaredHeadroomBps === null) {
23760
- log.dim(" This stand serves no recommended headroom, so no price can be recommended from a bench.");
24329
+ log.dim(" This stand serves no recommended headroom, so no ceiling can be recommended from a bench.");
24330
+ } else {
24331
+ log.dim(` Recommended headroom over a benchmarked call's cost on this stand: ${lane.recommendedDeclaredHeadroomBps} bps.`);
24332
+ }
24333
+ if (lane.feeBps === null) {
24334
+ log.dim(" This stand serves no platform fee \u2014 it predates credit billing, so no ceiling can be recommended from a bench.");
23761
24335
  } else {
23762
- log.dim(` Recommended headroom over benchmarked coins on this stand: ${lane.recommendedDeclaredHeadroomBps} bps.`);
24336
+ log.dim(` In-game calls are billed in the player's credits, as used: the provider's cost plus this stand's platform fee (${lane.feeBps} bps).`);
23763
24337
  }
23764
24338
  if (toolsOnly) {
23765
24339
  printConvertHint();
23766
24340
  return;
23767
24341
  }
23768
- log.dim(" Those per-million rates are the PROVIDER's; what your game is charged is the platform");
23769
- log.dim(` tariff on top, which only a real call reveals: ${c.cyan('genex llm bench "<prompt>" --max-coins <n> --user-approved')}`);
24342
+ log.dim(" Those per-million rates are the PROVIDER's list; what one call of YOUR prompt really costs,");
24343
+ log.dim(` and the per-call ceiling to declare, only a real call measures: ${c.cyan('genex llm bench "<prompt>" --max-credits <n> --user-approved')}`);
23770
24344
  }
23771
24345
  function wholeOrNull(value) {
23772
24346
  return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
23773
24347
  }
24348
+ function costPicosOf(usage) {
24349
+ if (typeof usage?.costUsdPicos === "string" && /^\d+$/.test(usage.costUsdPicos)) return usage.costUsdPicos;
24350
+ if (typeof usage?.costUsd === "number" && Number.isFinite(usage.costUsd) && usage.costUsd >= 0) {
24351
+ return String(BigInt(Math.round(usage.costUsd * 1e12)));
24352
+ }
24353
+ return null;
24354
+ }
23774
24355
  function sampleFrom(id, settled) {
23775
24356
  const usage = settled?.usage;
24357
+ const picos = costPicosOf(usage);
23776
24358
  return {
23777
24359
  id,
23778
24360
  status: settled?.status ?? null,
23779
24361
  billingStatus: settled?.billingStatus ?? null,
23780
- chargedCoins: typeof settled?.chargedCoins === "number" ? settled.chargedCoins : null,
23781
- costUsd: typeof usage?.costUsd === "number" ? usage.costUsd : null,
24362
+ chargedCredits: wholeOrNull(settled?.chargedCredits),
24363
+ costUsd: typeof usage?.costUsd === "number" ? usage.costUsd : picos !== null ? Number(picos) / 1e12 : null,
24364
+ costUsdPicos: picos,
23782
24365
  error: settled?.error ?? (settled ? null : "timed_out"),
23783
24366
  providerMessage: typeof settled?.providerMessage === "string" && settled.providerMessage.trim() ? settled.providerMessage.trim() : null,
23784
24367
  inputTokens: wholeOrNull(usage?.inputTokens),
23785
24368
  outputTokens: wholeOrNull(usage?.outputTokens),
23786
24369
  maxOutputTokens: wholeOrNull(usage?.maxOutputTokens),
23787
- fitCoins: typeof usage?.fitCoins === "number" && Number.isSafeInteger(usage.fitCoins) && usage.fitCoins >= 1 ? usage.fitCoins : null,
24370
+ fitCredits: typeof usage?.fitCredits === "number" && Number.isSafeInteger(usage.fitCredits) && usage.fitCredits >= 1 ? usage.fitCredits : null,
23788
24371
  fitExceedsCap: typeof usage?.fitExceedsCap === "boolean" ? usage.fitExceedsCap : null,
23789
24372
  truncated: typeof usage?.truncated === "boolean" ? usage.truncated : settled?.error === "provider_token_limit" ? true : null
23790
24373
  };
23791
24374
  }
23792
24375
  var PROVIDER_TOKEN_LIMIT = "provider_token_limit";
23793
- function benchRecommendation(results, headroomBps) {
23794
- const settled = results.filter((r) => r.status === "succeeded" && r.billingStatus === "final" && typeof r.chargedCoins === "number");
23795
- const charged = settled.map((r) => r.chargedCoins);
23796
- const fits = settled.map((r) => r.fitCoins).filter((v) => typeof v === "number");
23797
- const p95 = percentile2(charged, 95);
23798
- const max = charged.length ? Math.max(...charged) : null;
24376
+ function benchRecommendation(results, terms) {
24377
+ const settled = results.filter(
24378
+ (r) => r.status === "succeeded" && r.billingStatus === "final" && r.chargedCredits !== null && r.costUsdPicos !== null
24379
+ );
24380
+ const costs = settled.map((r) => BigInt(r.costUsdPicos));
24381
+ const charged = settled.map((r) => r.chargedCredits);
24382
+ const fits = settled.map((r) => r.fitCredits).filter((v) => typeof v === "number");
24383
+ const costP95 = percentilePicos(costs, 95);
24384
+ const costMax = costs.length ? costs.reduce((a, b) => b > a ? b : a) : null;
23799
24385
  const fitP95 = percentile2(fits, 95);
23800
24386
  const fitMax = fits.length ? Math.max(...fits) : null;
23801
24387
  const exceedsStandCap = results.some((r) => r.fitExceedsCap === true);
23802
24388
  const cutOffSamples = results.filter((r) => r.error === PROVIDER_TOKEN_LIMIT && r.fitExceedsCap !== true).length;
23803
24389
  const noRecommendationReason = exceedsStandCap ? "answer_exceeds_stand_cap" : cutOffSamples > 0 ? "answer_cut_off" : null;
23804
- const chargedBased = p95 !== null && headroomBps !== null ? recommendEstimateCoins(p95, headroomBps) : null;
23805
- const recommended = chargedBased === null || noRecommendationReason !== null ? null : Math.max(chargedBased, fitP95 ?? 0);
23806
- const ceiling = recommended === null || p95 === null || max === null || headroomBps === null ? null : Math.max(recommendPerCallMaxCoins(p95, max, headroomBps), recommended, fitMax ?? 0);
24390
+ const { headroomBps, feeBps, usdCentsPerCredit } = terms;
24391
+ const missingTerms = headroomBps === null ? "headroom" : feeBps === null || usdCentsPerCredit === null ? "credit_terms" : null;
24392
+ const full = headroomBps !== null && feeBps !== null && usdCentsPerCredit !== null ? { headroomBps, feeBps, usdCentsPerCredit } : null;
24393
+ const billing = feeBps !== null && usdCentsPerCredit !== null ? { feeBps, usdCentsPerCredit } : null;
24394
+ const costBased = full !== null && costP95 !== null ? ceilingCreditsFor(costP95, full) : null;
24395
+ const recommended = costBased === null || noRecommendationReason !== null ? null : Math.max(costBased, fitP95 ?? 0);
24396
+ const ceiling = recommended === null || costMax === null || full === null ? null : Math.max(ceilingCreditsFor(costMax, full), recommended, fitMax ?? 0);
24397
+ const average = billing !== null ? averageCreditsPerCall(costs, billing) : null;
24398
+ const estimate = ceiling === null || billing === null ? null : Math.min(ceiling, perCallEstimateCreditsFor(costs, billing) ?? 1);
23807
24399
  return {
24400
+ costs,
23808
24401
  charged,
23809
- p50: percentile2(charged, 50),
23810
- p95,
23811
- max,
24402
+ costP50: percentilePicos(costs, 50),
24403
+ costP95,
24404
+ costMax,
24405
+ chargedP50: percentile2(charged, 50),
24406
+ chargedP95: percentile2(charged, 95),
24407
+ chargedMax: charged.length ? Math.max(...charged) : null,
23812
24408
  fits,
23813
24409
  fitP95,
23814
24410
  fitMax,
23815
- chargedBasedEstimateCoins: chargedBased,
23816
- recommendedEstimateCoins: recommended,
23817
- recommendedPerCallMaxCoins: ceiling,
23818
- lengthRaisedPrice: recommended !== null && chargedBased !== null && recommended > chargedBased,
24411
+ costBasedMaxCredits: costBased,
24412
+ recommendedMaxCredits: recommended,
24413
+ recommendedPerCallMaxCredits: ceiling,
24414
+ averageCreditsPerCall: average,
24415
+ recommendedPerCallEstimateCredits: estimate,
24416
+ lengthRaisedCeiling: recommended !== null && costBased !== null && recommended > costBased,
23819
24417
  cutOffSamples,
23820
24418
  exceedsStandCap,
23821
- noRecommendationReason
24419
+ noRecommendationReason,
24420
+ missingTerms
23822
24421
  };
23823
24422
  }
23824
- var LLM_LENGTH_RAISED_LINE = "The declared price also pays for how long the answer may be: at the charged-based price the answer would be cut off, so declare this one.";
23825
- var LLM_EXCEEDS_STAND_CAP_LINE = "No price recommended: this answer is longer than one call on this stand may produce. Ask for a shorter answer \u2014 fewer fields, shorter strings, a length the prompt states \u2014 and benchmark again.";
23826
- function benchCutOffLine(cutOff, maxCoins) {
23827
- return `No price recommended: ${cutOff} sample${cutOff === 1 ? " was" : "s were"} cut off at this ceiling (--max-coins ${maxCoins}) before the answer was finished, so its real length is unknown. Re-run with a higher --max-coins.`;
24423
+ var LLM_LENGTH_RAISED_LINE = "The ceiling also decides how long the answer may be: at the cost-based ceiling the answer would be cut off, so declare this one.";
24424
+ var LLM_EXCEEDS_STAND_CAP_LINE = "No ceiling recommended: this answer is longer than one call on this stand may produce. Ask for a shorter answer \u2014 fewer fields, shorter strings, a length the prompt states \u2014 and benchmark again.";
24425
+ function benchCutOffLine(cutOff, maxCredits) {
24426
+ return `No ceiling recommended: ${cutOff} sample${cutOff === 1 ? " was" : "s were"} cut off at this ceiling (--max-credits ${maxCredits}) before the answer was finished, so its real length is unknown. Re-run with a higher --max-credits.`;
23828
24427
  }
23829
24428
  var ACTIVE_STATUSES = /* @__PURE__ */ new Set(["requires_confirmation", "queued", "dispatching", "awaiting_external"]);
23830
24429
  function rowState(row) {
@@ -23835,7 +24434,22 @@ function rowState(row) {
23835
24434
  function rowHoldsSlot(row) {
23836
24435
  if (typeof row.slotHeld === "boolean") return row.slotHeld;
23837
24436
  const state = rowState(row);
23838
- return state === "active" || state === "awaiting bill" && (row.reservedCoins ?? 0) > 0;
24437
+ return state === "active" || state === "awaiting bill" && ((row.reservedCoins ?? 0) > 0 || (row.reservedCredits ?? 0) > 0);
24438
+ }
24439
+ function rowMoney(row) {
24440
+ const credits = row.currency === "credits" || row.currency === void 0 && typeof row.reservedCredits === "number" && typeof row.reservedCoins !== "number";
24441
+ if (credits) {
24442
+ return {
24443
+ unit: "credits",
24444
+ reserved: typeof row.reservedCredits === "number" ? row.reservedCredits : null,
24445
+ charged: typeof row.chargedCredits === "number" ? row.chargedCredits : null
24446
+ };
24447
+ }
24448
+ return {
24449
+ unit: "coin",
24450
+ reserved: typeof row.reservedCoins === "number" ? row.reservedCoins : null,
24451
+ charged: typeof row.chargedCoins === "number" ? row.chargedCoins : null
24452
+ };
23839
24453
  }
23840
24454
  function providerRefusalStatus(error) {
23841
24455
  const m = /^provider_http_(\d{3})$/.exec(error ?? "");
@@ -23854,7 +24468,7 @@ function formatAge(iso, now = Date.now()) {
23854
24468
  }
23855
24469
  async function runBench(args) {
23856
24470
  const { apiUrl, token, projectId, cwd, opts, log } = args;
23857
- const maxCoins = opts.maxCoins;
24471
+ const maxCredits = opts.maxCredits ?? opts.maxCoins;
23858
24472
  const samples = opts.samples ?? DEFAULT_BENCH_SAMPLES;
23859
24473
  const prompt = opts.benchPrompt.trim();
23860
24474
  const auth = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
@@ -23876,7 +24490,7 @@ async function runBench(args) {
23876
24490
  const lane = await readRuntimeLane(apiUrl, token);
23877
24491
  if (lane.state !== "live" || !lane.models || lane.models.length === 0) {
23878
24492
  if (lane.state === "off") {
23879
- if (opts.json) writeJsonLine({ ...emptyBenchJson(apiUrl, projectId, prompt, outputFormat, samples, maxCoins, opts), status: "off", error: null });
24493
+ if (opts.json) writeJsonLine({ ...emptyBenchJson(apiUrl, projectId, prompt, outputFormat, samples, maxCredits, opts), status: "off", error: null });
23880
24494
  else {
23881
24495
  log.plain(LLM_LANE_OFF_LINE);
23882
24496
  log.dim(" Nothing was spent.");
@@ -23903,17 +24517,27 @@ async function runBench(args) {
23903
24517
  );
23904
24518
  return;
23905
24519
  }
23906
- const balanceBefore = await readCoinBalance(apiUrl, token);
24520
+ const walletBefore = await fetchCreditsSnapshot(apiUrl, token);
24521
+ if (walletBefore?.emailVerified === false) {
24522
+ benchFailed(opts, log, LLM_BENCH_UNVERIFIED_LINE);
24523
+ return;
24524
+ }
24525
+ if (walletBefore !== null && walletBefore.spendable < maxCredits) {
24526
+ benchFailed(opts, log, benchBalanceShortLine(walletBefore.spendable, maxCredits));
24527
+ return;
24528
+ }
24529
+ const balanceBefore = walletBefore?.spendable ?? null;
23907
24530
  if (!opts.json) {
23908
24531
  log.plain(c.bold("genex llm bench"));
23909
24532
  log.dim(` ${apiUrl}`);
23910
24533
  log.plain("");
23911
24534
  log.plain(` Model ${c.cyan(modelId)}`);
23912
24535
  log.plain(` Samples ${samples} real attempt${samples === 1 ? "" : "s"}, ${outputFormat} output`);
23913
- log.plain(` Approved ${maxCoins} coin per attempt \u2014 at worst ${maxCoins * samples} coin for this run`);
24536
+ log.plain(` Approved ${maxCredits} credit${maxCredits === 1 ? "" : "s"} per attempt \u2014 at worst ${maxCredits * samples} credits for this run, from your own balance`);
23914
24537
  log.plain(
23915
- ` Balance ${balanceBefore === null ? "couldn't be read" : `${balanceBefore} coin spendable`}`
24538
+ ` Balance ${balanceBefore === null ? "couldn't be read" : `${balanceBefore} credits spendable`}`
23916
24539
  );
24540
+ log.dim(" Billed as used: each attempt is charged its real cost plus the platform fee, rounded up to a whole credit.");
23917
24541
  log.plain("");
23918
24542
  }
23919
24543
  const base = `${apiUrl}/api/runtime/development/projects/${encodeURIComponent(projectId)}/generations`;
@@ -23939,7 +24563,7 @@ async function runBench(args) {
23939
24563
  const res = await call(base, {
23940
24564
  method: "POST",
23941
24565
  body: JSON.stringify({
23942
- maxCoins,
24566
+ maxCredits,
23943
24567
  request: {
23944
24568
  idempotencyKey: `bench-${randomUUID2()}`,
23945
24569
  modelId,
@@ -23969,13 +24593,14 @@ async function runBench(args) {
23969
24593
  if (refusedAt !== null) providerRefused++;
23970
24594
  if (!opts.json) {
23971
24595
  const length = row.outputTokens !== null ? ` \xB7 ${row.outputTokens}${row.maxOutputTokens !== null ? ` of ${row.maxOutputTokens}` : ""} tokens out` : "";
24596
+ const charged = row.chargedCredits === null ? "\u2014" : String(row.chargedCredits);
23972
24597
  log.plain(
23973
- ` ${row.status === "succeeded" ? c.green("\u2713") : c.yellow("!")} sample ${i + 1} ${String(row.chargedCoins ?? "\u2014").padStart(4)} coin charged \xB7 provider ${usd(row.costUsd)}${length}${row.error ? ` \xB7 ${row.error}` : ""}`
24598
+ ` ${row.status === "succeeded" ? c.green("\u2713") : c.yellow("!")} sample ${i + 1} ${charged.padStart(4)} credit${row.chargedCredits === 1 ? "" : "s"} charged \xB7 cost ${usdFromPicos(row.costUsdPicos)}${length}${row.error ? ` \xB7 ${row.error}` : ""}`
23974
24599
  );
23975
24600
  if (refusedAt !== null) printProviderRefusal(log, refusedAt, row.providerMessage, row.status === "unknown");
23976
24601
  if (row.error === PROVIDER_TOKEN_LIMIT) {
23977
24602
  log.dim(
23978
- row.fitExceedsCap === true ? " The answer was cut off at this stand's own output limit \u2014 no price makes room for it; it is not a sample." : ` The answer was cut off at this ceiling (--max-coins ${maxCoins}) before it was finished \u2014 it was charged, and it is not a sample.`
24603
+ row.fitExceedsCap === true ? " The answer was cut off at this stand's own output limit \u2014 no ceiling makes room for it; it is not a sample." : ` The answer was cut off at this ceiling (--max-credits ${maxCredits}) before it was finished \u2014 it was charged, and it is not a sample.`
23979
24604
  );
23980
24605
  }
23981
24606
  }
@@ -23983,16 +24608,19 @@ async function runBench(args) {
23983
24608
  } finally {
23984
24609
  process.removeListener("SIGINT", onSigint);
23985
24610
  }
23986
- const headroomBps = lane.recommendedDeclaredHeadroomBps;
23987
- const rec = benchRecommendation(results, headroomBps);
23988
- const { charged, p50, p95, max } = rec;
23989
- const settledCount = charged.length;
23990
- const recommended = rec.recommendedEstimateCoins;
23991
- const ceiling = rec.recommendedPerCallMaxCoins;
24611
+ const terms = {
24612
+ headroomBps: lane.recommendedDeclaredHeadroomBps,
24613
+ feeBps: lane.feeBps,
24614
+ usdCentsPerCredit: lane.usdCentsPerCredit
24615
+ };
24616
+ const rec = benchRecommendation(results, terms);
24617
+ const settledCount = rec.charged.length;
23992
24618
  const lengthBlocked = rec.noRecommendationReason !== null;
23993
- const balanceAfter = await readCoinBalance(apiUrl, token);
24619
+ const balanceAfter = (await fetchCreditsSnapshot(apiUrl, token))?.spendable ?? null;
24620
+ const picosOrNull = (v) => v === null ? null : String(v);
23994
24621
  const record = {
23995
- v: 1,
24622
+ v: 2,
24623
+ currency: "credits",
23996
24624
  ranAt: (/* @__PURE__ */ new Date()).toISOString(),
23997
24625
  apiUrl,
23998
24626
  projectId,
@@ -24001,28 +24629,34 @@ async function runBench(args) {
24001
24629
  outputFormat,
24002
24630
  samples,
24003
24631
  completed: settledCount,
24004
- chargedCoins: charged,
24005
- p50,
24006
- p95,
24007
- max,
24008
- recommendedDeclaredHeadroomBps: headroomBps,
24009
- recommendedEstimateCoins: recommended,
24010
- recommendedPerCallMaxCoins: ceiling,
24011
- maxCoinsPerSample: maxCoins,
24012
- fitCoins: rec.fits,
24632
+ maxCreditsPerSample: maxCredits,
24633
+ costUsdPicos: rec.costs.map(String),
24634
+ chargedCredits: rec.charged,
24635
+ cost: { p50: picosOrNull(rec.costP50), p95: picosOrNull(rec.costP95), max: picosOrNull(rec.costMax) },
24636
+ charged: { p50: rec.chargedP50, p95: rec.chargedP95, max: rec.chargedMax },
24637
+ fitCredits: rec.fits,
24013
24638
  fitP95: rec.fitP95,
24014
- chargedBasedEstimateCoins: rec.chargedBasedEstimateCoins,
24015
- lengthRaisedPrice: rec.lengthRaisedPrice,
24639
+ fitMax: rec.fitMax,
24640
+ recommendedDeclaredHeadroomBps: terms.headroomBps,
24641
+ feeBps: terms.feeBps,
24642
+ usdCentsPerCredit: terms.usdCentsPerCredit,
24643
+ costBasedMaxCredits: rec.costBasedMaxCredits,
24644
+ recommendedMaxCredits: rec.recommendedMaxCredits,
24645
+ recommendedPerCallMaxCredits: rec.recommendedPerCallMaxCredits,
24646
+ averageCreditsPerCall: rec.averageCreditsPerCall,
24647
+ recommendedPerCallEstimateCredits: rec.recommendedPerCallEstimateCredits,
24648
+ lengthRaisedCeiling: rec.lengthRaisedCeiling,
24016
24649
  cutOffSamples: rec.cutOffSamples,
24017
24650
  noRecommendationReason: rec.noRecommendationReason
24018
24651
  };
24019
24652
  const savedTo = await saveBench(cwd, record);
24020
24653
  if (opts.json) {
24021
- const ok = charged.length > 0 && !lengthBlocked;
24654
+ const ok = settledCount > 0 && !lengthBlocked;
24022
24655
  writeJsonLine({
24023
24656
  command: "llm bench",
24024
24657
  status: ok ? "ok" : "failed",
24025
24658
  error: ok ? null : rec.noRecommendationReason ?? "no_sample_settled",
24659
+ currency: "credits",
24026
24660
  apiUrl,
24027
24661
  projectId,
24028
24662
  modelId,
@@ -24031,35 +24665,54 @@ async function runBench(args) {
24031
24665
  schemaPath: opts.schemaPath ?? null,
24032
24666
  samples,
24033
24667
  completed: settledCount,
24034
- maxCoinsPerSample: maxCoins,
24668
+ maxCreditsPerSample: maxCredits,
24669
+ // Spendable credits before and after the run; null when unreadable.
24035
24670
  balanceBefore,
24036
24671
  balanceAfter,
24037
24672
  results,
24038
24673
  // The create-time refusal that ended the run, or null. Beside the
24039
24674
  // samples, never among them: no attempt existed and nothing was spent.
24040
24675
  refusal: refusal2,
24041
- chargedCoins: { p50, p95, max },
24042
- // The answer's LENGTH, priced by the server per sample (`fitCoins`,
24676
+ // Over the settled samples: the real provider cost (pico-dollar strings
24677
+ // and USD) and the whole credits charged.
24678
+ costUsdPicos: record.cost,
24679
+ costUsd: {
24680
+ p50: rec.costP50 === null ? null : Number(rec.costP50) / 1e12,
24681
+ p95: rec.costP95 === null ? null : Number(rec.costP95) / 1e12,
24682
+ max: rec.costMax === null ? null : Number(rec.costMax) / 1e12
24683
+ },
24684
+ chargedCredits: record.charged,
24685
+ // The answer's LENGTH, priced by the server per sample (`fitCredits`,
24043
24686
  // headroom already on the tokens), over the settled samples.
24044
- fitCoins: { p95: rec.fitP95, max: rec.fitMax },
24045
- chargedBasedEstimateCoins: rec.chargedBasedEstimateCoins,
24046
- lengthRaisedPrice: rec.lengthRaisedPrice,
24687
+ fitCredits: { p95: rec.fitP95, max: rec.fitMax },
24688
+ recommendedDeclaredHeadroomBps: terms.headroomBps,
24689
+ feeBps: terms.feeBps,
24690
+ usdCentsPerCredit: terms.usdCentsPerCredit,
24691
+ missingTerms: rec.missingTerms,
24692
+ costBasedMaxCredits: rec.costBasedMaxCredits,
24693
+ lengthRaisedCeiling: rec.lengthRaisedCeiling,
24047
24694
  cutOffSamples: rec.cutOffSamples,
24048
24695
  exceedsStandCap: rec.exceedsStandCap,
24049
24696
  noRecommendationReason: rec.noRecommendationReason,
24050
- recommendedDeclaredHeadroomBps: headroomBps,
24051
- recommendedEstimateCoins: recommended,
24052
- recommendedPerCallMaxCoins: ceiling,
24697
+ recommendedMaxCredits: rec.recommendedMaxCredits,
24698
+ recommendedPerCallMaxCredits: rec.recommendedPerCallMaxCredits,
24699
+ averageCreditsPerCall: rec.averageCreditsPerCall,
24700
+ recommendedPerCallEstimateCredits: rec.recommendedPerCallEstimateCredits,
24053
24701
  savedTo
24054
24702
  });
24055
24703
  if (!ok) process.exitCode = 1;
24056
24704
  return;
24057
24705
  }
24058
24706
  log.plain("");
24707
+ const printMeasured = () => {
24708
+ log.plain(c.bold(" Real cost per call"));
24709
+ log.plain(` p50 ${usdFromPicos(rec.costP50)} p95 ${usdFromPicos(rec.costP95)} max ${usdFromPicos(rec.costMax)} (${settledCount} of ${samples} settled)`);
24710
+ log.plain(c.bold(" Charged credits"));
24711
+ log.plain(` p50 ${rec.chargedP50} p95 ${rec.chargedP95} max ${rec.chargedMax} (whole credits, rounded up per call)`);
24712
+ };
24059
24713
  if (lengthBlocked) {
24060
- if (charged.length > 0) {
24061
- log.plain(c.bold(" Charged coins"));
24062
- log.plain(` p50 ${p50} p95 ${p95} max ${max} (${charged.length} of ${samples} settled)`);
24714
+ if (settledCount > 0) {
24715
+ printMeasured();
24063
24716
  log.plain("");
24064
24717
  }
24065
24718
  printRecommendation(log, record);
@@ -24067,17 +24720,16 @@ async function runBench(args) {
24067
24720
  process.exitCode = 1;
24068
24721
  return;
24069
24722
  }
24070
- if (charged.length === 0) {
24723
+ if (settledCount === 0) {
24071
24724
  log.error(
24072
- providerRefused > 0 && providerRefused === results.length ? " No sample ran \u2014 the provider refused every attempt at its door \u2014 so there is nothing to price from." : " No sample settled, so there is nothing to price from."
24725
+ providerRefused > 0 && providerRefused === results.length ? " No sample ran \u2014 the provider refused every attempt at its door \u2014 so there is nothing to measure from." : " No sample settled, so there is nothing to measure from."
24073
24726
  );
24074
24727
  process.exitCode = 1;
24075
24728
  return;
24076
24729
  }
24077
- log.plain(c.bold(" Charged coins"));
24078
- log.plain(` p50 ${p50} p95 ${p95} max ${max} (${charged.length} of ${samples} settled)`);
24730
+ printMeasured();
24079
24731
  if (balanceBefore !== null && balanceAfter !== null) {
24080
- log.plain(` Balance ${balanceBefore} \u2192 ${balanceAfter} coin spendable`);
24732
+ log.plain(` Balance ${balanceBefore} \u2192 ${balanceAfter} credits spendable`);
24081
24733
  }
24082
24734
  log.plain("");
24083
24735
  printRecommendation(log, record);
@@ -24089,43 +24741,58 @@ function printRecommendation(log, record) {
24089
24741
  return;
24090
24742
  }
24091
24743
  if (record.noRecommendationReason === "answer_cut_off") {
24092
- log.warn(` ${benchCutOffLine(record.cutOffSamples ?? 1, record.maxCoinsPerSample ?? 0)}`);
24744
+ log.warn(` ${benchCutOffLine(record.cutOffSamples || 1, record.maxCreditsPerSample)}`);
24093
24745
  return;
24094
24746
  }
24095
- if (record.recommendedEstimateCoins === null || record.p95 === null) {
24096
- log.warn(" No price recommended.");
24747
+ const attempts = `${record.completed} settled attempt${record.completed === 1 ? "" : "s"} on ${record.modelId}`;
24748
+ if (record.recommendedMaxCredits === null || record.cost.p95 === null) {
24749
+ log.warn(" No ceiling recommended.");
24097
24750
  log.dim(
24098
- record.recommendedDeclaredHeadroomBps === null ? " This stand served no recommended headroom, and the multiplier is the server's to set \u2014" : " No sample settled, so there is no p95 to build a price on \u2014"
24751
+ record.cost.p95 === null ? " No sample settled, so there is no p95 to build a ceiling on \u2014" : record.recommendedDeclaredHeadroomBps === null ? " This stand served no recommended headroom, and the multiplier is the server's to set \u2014" : " This stand served no platform fee or credit value, and those are the server's to set \u2014"
24099
24752
  );
24100
24753
  log.dim(" declaring a number from this run would be a guess dressed as a measurement.");
24754
+ printAverage(log, record, attempts);
24101
24755
  return;
24102
24756
  }
24103
- log.plain(c.bold(` Declare estimateCoins: ${record.recommendedEstimateCoins}`));
24104
- if (record.lengthRaisedPrice) {
24757
+ log.plain(c.bold(` Declare maxCredits: ${record.recommendedMaxCredits}`));
24758
+ if (record.lengthRaisedCeiling) {
24105
24759
  log.plain(` ${LLM_LENGTH_RAISED_LINE}`);
24106
24760
  log.dim(
24107
- ` = the smallest price whose answer allowance holds the answer plus this stand's headroom (p95 ${record.fitP95 ?? "\u2014"}), above the`
24761
+ ` = the smallest ceiling whose answer allowance holds the answer plus this stand's headroom (p95 ${record.fitP95 ?? "\u2014"}), above the`
24108
24762
  );
24109
24763
  log.dim(
24110
- ` charged-based ${record.chargedBasedEstimateCoins ?? "\u2014"} (p95 of charged coins ${record.p95} plus ${record.recommendedDeclaredHeadroomBps} bps), over ${record.completed} settled attempt${record.completed === 1 ? "" : "s"} on ${record.modelId}.`
24764
+ ` cost-based ${record.costBasedMaxCredits ?? "\u2014"} (p95 real cost ${usdFromPicos(record.cost.p95)} with the platform fee, ${record.feeBps} bps, and ${record.recommendedDeclaredHeadroomBps} bps of headroom), over ${attempts}.`
24111
24765
  );
24112
24766
  } else {
24113
24767
  log.dim(
24114
- ` = p95 of charged coins (${record.p95}) plus this stand's recommended headroom (${record.recommendedDeclaredHeadroomBps} bps),`
24768
+ ` = p95 real cost (${usdFromPicos(record.cost.p95)}) with this stand's platform fee (${record.feeBps} bps) and recommended headroom (${record.recommendedDeclaredHeadroomBps} bps), in whole credits,`
24115
24769
  );
24116
- log.dim(` measured over ${record.completed} settled attempt${record.completed === 1 ? "" : "s"} on ${record.modelId}.`);
24770
+ log.dim(` measured over ${attempts}.`);
24117
24771
  }
24118
- log.dim(" That number is a PRICE: a started attempt is charged in full, including one that fails.");
24119
- if (record.recommendedPerCallMaxCoins != null) {
24120
- log.plain(c.bold(` Grant perCallMaxCoins: ${record.recommendedPerCallMaxCoins}`));
24772
+ log.dim(" That number is a CEILING, not a price: a call is billed its real cost as used, never more than it \u2014");
24773
+ log.dim(" and it also sizes how long the answer may be.");
24774
+ if (record.recommendedPerCallMaxCredits !== null) {
24775
+ log.plain(c.bold(` Grant perCallMaxCredits: ${record.recommendedPerCallMaxCredits}`));
24121
24776
  log.dim(
24122
- record.lengthRaisedPrice ? ` = the same headroom over the worst sample (${record.max}) or the longest answer's price, whichever is larger, and never below the price above it.` : ` = the same headroom over the worst sample (${record.max}), and never below the price above it.`
24777
+ record.lengthRaisedCeiling ? ` = the same over the worst sample (${usdFromPicos(record.cost.max)}) or the longest answer's ceiling, whichever is larger, and never below the ceiling above it.` : ` = the same over the worst sample (${usdFromPicos(record.cost.max)}), and never below the ceiling above it.`
24123
24778
  );
24124
- log.dim(" The ceiling is the price's room to be wrong; declaring one under the price is refused.");
24779
+ }
24780
+ printAverage(log, record, attempts);
24781
+ }
24782
+ function printAverage(log, record, attempts) {
24783
+ if (record.averageCreditsPerCall === null) return;
24784
+ log.plain(` Per-call estimate: about ${formatCredits(record.averageCreditsPerCall)} credits per call`);
24785
+ log.dim(` = the average real cost with the platform fee, no headroom, over ${attempts}.`);
24786
+ if (record.recommendedPerCallEstimateCredits !== null) {
24787
+ log.plain(c.bold(` Grant perCallEstimateCredits: ${record.recommendedPerCallEstimateCredits}`));
24788
+ log.dim(` = that average rounded up to a whole credit. disclosure.estimatedCreditsPerPeriod = your calls per period \xD7 ${formatCredits(record.averageCreditsPerCall)}, rounded up.`);
24125
24789
  }
24126
24790
  }
24127
24791
  async function explainBenchRefusal(res, call, base, log, opts, index, settledSoFar) {
24128
- if (printedStructuredError(res)) return { stop: true, refusal: { status: res.status, error: null, slotsHeld: null, slotLimit: null } };
24792
+ if (printedStructuredError(res)) {
24793
+ const printed = await res.json().catch(() => ({}));
24794
+ return { stop: true, refusal: { status: res.status, error: typeof printed.error === "string" ? printed.error : null, slotsHeld: null, slotLimit: null } };
24795
+ }
24129
24796
  const body = await res.json().catch(() => ({}));
24130
24797
  const refusal2 = { status: res.status, error: body.error ?? null, slotsHeld: null, slotLimit: null };
24131
24798
  if (res.status === 429 && body.error === "generation_limit") {
@@ -24135,6 +24802,10 @@ async function explainBenchRefusal(res, call, base, log, opts, index, settledSoF
24135
24802
  if (!opts.json) log.error(` Sample ${index + 1} ${benchSlotsHeldSentence(counts)}`);
24136
24803
  return { stop: true, refusal: refusal2 };
24137
24804
  }
24805
+ if (res.status === 403 && body.error === "credits_unverified") {
24806
+ if (!opts.json) log.error(` ${LLM_BENCH_UNVERIFIED_LINE}`);
24807
+ return { stop: true, refusal: refusal2 };
24808
+ }
24138
24809
  if (opts.json) return { stop: res.status !== 429, refusal: refusal2 };
24139
24810
  if (res.status === 404 && body.error === "not_found") {
24140
24811
  log.error(` ${LLM_LANE_OFF_LINE}`);
@@ -24150,7 +24821,7 @@ async function explainBenchRefusal(res, call, base, log, opts, index, settledSoF
24150
24821
  return { stop: true, refusal: refusal2 };
24151
24822
  }
24152
24823
  if (res.status === 402) {
24153
- log.error(" Not enough coin to start the attempt.");
24824
+ log.error(" Not enough credits to start the attempt \u2014 a bench is paid from your own balance.");
24154
24825
  return { stop: true, refusal: refusal2 };
24155
24826
  }
24156
24827
  log.error(
@@ -24159,7 +24830,7 @@ async function explainBenchRefusal(res, call, base, log, opts, index, settledSoF
24159
24830
  return { stop: res.status >= 500 || res.status === 401 || res.status === 403, refusal: refusal2 };
24160
24831
  }
24161
24832
  function printProviderRefusal(log, status2, message, awaitingBill = false) {
24162
- if (awaitingBill) log.dim(` The provider answered ${status2} after routing \u2014 its bill is not final, so this call's coin stays held until it resolves (see \`genex llm status\`); it is not a sample.`);
24833
+ if (awaitingBill) log.dim(` The provider answered ${status2} after routing \u2014 its bill is not final, so what this call reserved stays held until it resolves (see \`genex llm status\`); it is not a sample.`);
24163
24834
  else log.dim(" The provider refused this call at its door \u2014 no inference ran, it cost nothing, and it is not a sample.");
24164
24835
  if (message) log.dim(` Provider said: ${message}`);
24165
24836
  if (status2 === 401 || status2 === 403) {
@@ -24185,19 +24856,6 @@ async function pollSettled(call, url, timeoutSec) {
24185
24856
  }
24186
24857
  return last;
24187
24858
  }
24188
- async function readCoinBalance(apiUrl, token) {
24189
- try {
24190
- const res = await apiFetch(`${apiUrl}/api/coin/balance`, {
24191
- headers: { Authorization: `Bearer ${token}` },
24192
- signal: AbortSignal.timeout(6e3)
24193
- });
24194
- if (!res.ok) return null;
24195
- const body = await res.json().catch(() => null);
24196
- return typeof body?.spendable === "number" ? body.spendable : null;
24197
- } catch {
24198
- return null;
24199
- }
24200
- }
24201
24859
  async function saveBench(cwd, record) {
24202
24860
  try {
24203
24861
  const file = path36.join(cwd, LLM_BENCH_FILE);
@@ -24213,9 +24871,10 @@ function benchFailed(opts, log, message) {
24213
24871
  else log.error(message);
24214
24872
  process.exitCode = 1;
24215
24873
  }
24216
- function emptyBenchJson(apiUrl, projectId, prompt, outputFormat, samples, maxCoins, opts) {
24874
+ function emptyBenchJson(apiUrl, projectId, prompt, outputFormat, samples, maxCredits, opts) {
24217
24875
  return {
24218
24876
  command: "llm bench",
24877
+ currency: "credits",
24219
24878
  apiUrl,
24220
24879
  projectId,
24221
24880
  modelId: opts.modelId ?? null,
@@ -24224,21 +24883,28 @@ function emptyBenchJson(apiUrl, projectId, prompt, outputFormat, samples, maxCoi
24224
24883
  schemaPath: opts.schemaPath ?? null,
24225
24884
  samples,
24226
24885
  completed: 0,
24227
- maxCoinsPerSample: maxCoins,
24886
+ maxCreditsPerSample: maxCredits,
24228
24887
  balanceBefore: null,
24229
24888
  balanceAfter: null,
24230
24889
  results: [],
24231
24890
  refusal: null,
24232
- chargedCoins: { p50: null, p95: null, max: null },
24233
- fitCoins: { p95: null, max: null },
24234
- chargedBasedEstimateCoins: null,
24235
- lengthRaisedPrice: false,
24891
+ costUsdPicos: { p50: null, p95: null, max: null },
24892
+ costUsd: { p50: null, p95: null, max: null },
24893
+ chargedCredits: { p50: null, p95: null, max: null },
24894
+ fitCredits: { p95: null, max: null },
24895
+ recommendedDeclaredHeadroomBps: null,
24896
+ feeBps: null,
24897
+ usdCentsPerCredit: null,
24898
+ missingTerms: null,
24899
+ costBasedMaxCredits: null,
24900
+ lengthRaisedCeiling: false,
24236
24901
  cutOffSamples: 0,
24237
24902
  exceedsStandCap: false,
24238
24903
  noRecommendationReason: null,
24239
- recommendedDeclaredHeadroomBps: null,
24240
- recommendedEstimateCoins: null,
24241
- recommendedPerCallMaxCoins: null,
24904
+ recommendedMaxCredits: null,
24905
+ recommendedPerCallMaxCredits: null,
24906
+ averageCreditsPerCall: null,
24907
+ recommendedPerCallEstimateCredits: null,
24242
24908
  savedTo: null
24243
24909
  };
24244
24910
  }
@@ -24301,7 +24967,8 @@ async function reportStatus(args) {
24301
24967
  for (const row of rows) {
24302
24968
  const state = rowState(row);
24303
24969
  const mark = state === "active" ? c.cyan("\u25CF") : state === "awaiting bill" ? c.yellow("\u25D0") : c.green("\u25CB");
24304
- const reserved = typeof row.reservedCoins === "number" ? `${row.reservedCoins} coin reserved` : "reserved \u2014";
24970
+ const money2 = rowMoney(row);
24971
+ const reserved = money2.reserved === null ? "reserved \u2014" : `${money2.reserved} ${money2.unit} reserved`;
24305
24972
  log.plain(
24306
24973
  ` ${mark} ${row.id ?? "?"} ${row.modelId ?? "?"} ${state.padEnd(13)} ${formatAge(row.createdAt, now).padStart(4)} old ${reserved}${row.error ? ` ${row.error}` : ""}${rowHoldsSlot(row) ? "" : c.dim(" (no slot)")}`
24307
24974
  );
@@ -24373,10 +25040,11 @@ async function cancelCall(args) {
24373
25040
  if (!opts.json) {
24374
25041
  log.warn(` ${row.id} (${row.status ?? "?"}) \u2014 ${sentence}.`);
24375
25042
  if (state === "awaiting bill") {
24376
- log.dim(` Nothing to cancel: its coin releases when the bill resolves, and it stops holding a slot ${PENDING_SLOT_RELEASE_MINUTES} minutes after dispatch.`);
25043
+ log.dim(` Nothing to cancel: what it reserved releases when the bill resolves, and it stops holding a slot ${PENDING_SLOT_RELEASE_MINUTES} minutes after dispatch.`);
24377
25044
  if (row.providerMessage?.trim()) log.dim(` Provider said: ${row.providerMessage.trim()}`);
24378
25045
  } else {
24379
- log.dim(` ${typeof row.chargedCoins === "number" ? `${row.chargedCoins} coin charged` : "charge unknown"}${row.error ? ` \xB7 ${row.error}` : ""}.`);
25046
+ const money2 = rowMoney(row);
25047
+ log.dim(` ${money2.charged === null ? "charge unknown" : `${money2.charged} ${money2.unit} charged`}${row.error ? ` \xB7 ${row.error}` : ""}.`);
24380
25048
  }
24381
25049
  }
24382
25050
  return done("ok", null, state === "awaiting bill" ? "awaiting_bill" : "already_final", row);
@@ -24398,78 +25066,126 @@ async function cancelCall(args) {
24398
25066
  const after = await res.json().catch(() => null) ?? row;
24399
25067
  if (!opts.json) {
24400
25068
  log.success(` Cancelled ${row.id} (was ${row.status ?? "active"}).`);
24401
- log.dim(` Its coin releases once the bill settles; ${c.cyan("genex llm status")} shows it until then.`);
25069
+ log.dim(` What it reserved releases once the bill settles; ${c.cyan("genex llm status")} shows it until then.`);
24402
25070
  }
24403
25071
  return done("ok", null, "cancelled", after);
24404
25072
  }
24405
- async function reportSavedPrice(cwd, opts, log) {
24406
- let record = null;
25073
+ async function readSavedBench(cwd) {
25074
+ let parsed;
24407
25075
  try {
24408
- record = JSON.parse(await fs35.readFile(path36.join(cwd, LLM_BENCH_FILE), "utf8"));
25076
+ parsed = JSON.parse(await fs35.readFile(path36.join(cwd, LLM_BENCH_FILE), "utf8"));
24409
25077
  } catch {
24410
- record = null;
25078
+ return null;
24411
25079
  }
24412
- if (!record) {
25080
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
25081
+ const v = parsed;
25082
+ if (v.v === 2 && v.currency === "credits" && v.cost && v.charged) return { kind: "credits", record: parsed };
25083
+ return { kind: "coins", record: parsed };
25084
+ }
25085
+ var LLM_PRICE_COIN_TERMS_LINE = "This bench was measured under the old COIN terms, when a game declared a fixed price per call. In-game calls are now billed in the player's credits, as used, and a game declares a per-call CEILING (maxCredits) instead \u2014 a coin price is not that number. Re-run the bench for the figures to declare.";
25086
+ async function reportSavedPrice(cwd, opts, log) {
25087
+ const saved = await readSavedBench(cwd);
25088
+ const rerun = 'genex llm bench "<prompt>" --max-credits <n> --user-approved';
25089
+ const creditKeys = (record2) => ({
25090
+ costUsdPicos: record2?.cost ?? { p50: null, p95: null, max: null },
25091
+ chargedCredits: record2?.charged ?? { p50: null, p95: null, max: null },
25092
+ fitP95: record2?.fitP95 ?? null,
25093
+ fitMax: record2?.fitMax ?? null,
25094
+ recommendedDeclaredHeadroomBps: record2?.recommendedDeclaredHeadroomBps ?? null,
25095
+ feeBps: record2?.feeBps ?? null,
25096
+ usdCentsPerCredit: record2?.usdCentsPerCredit ?? null,
25097
+ costBasedMaxCredits: record2?.costBasedMaxCredits ?? null,
25098
+ lengthRaisedCeiling: record2?.lengthRaisedCeiling ?? false,
25099
+ cutOffSamples: record2?.cutOffSamples ?? 0,
25100
+ noRecommendationReason: record2?.noRecommendationReason ?? null,
25101
+ recommendedMaxCredits: record2?.recommendedMaxCredits ?? null,
25102
+ recommendedPerCallMaxCredits: record2?.recommendedPerCallMaxCredits ?? null,
25103
+ averageCreditsPerCall: record2?.averageCreditsPerCall ?? null,
25104
+ recommendedPerCallEstimateCredits: record2?.recommendedPerCallEstimateCredits ?? null
25105
+ });
25106
+ if (!saved) {
24413
25107
  if (opts.json) {
24414
25108
  writeJsonLine({
24415
25109
  command: "llm price",
24416
25110
  status: "failed",
24417
25111
  error: "no_bench",
25112
+ currency: null,
24418
25113
  ranAt: null,
24419
25114
  modelId: null,
24420
25115
  samples: null,
24421
25116
  completed: null,
24422
- p50: null,
24423
- p95: null,
24424
- max: null,
24425
- fitP95: null,
24426
- chargedBasedEstimateCoins: null,
24427
- lengthRaisedPrice: false,
24428
- cutOffSamples: 0,
24429
- noRecommendationReason: null,
24430
- recommendedDeclaredHeadroomBps: null,
24431
- recommendedEstimateCoins: null,
24432
- recommendedPerCallMaxCoins: null,
25117
+ ...creditKeys(null),
25118
+ legacyCoinTerms: null,
24433
25119
  savedTo: null
24434
25120
  });
24435
25121
  } else {
24436
25122
  log.error("Nothing has been benchmarked in this folder yet.");
24437
- log.dim(` Run ${c.cyan('genex llm bench "<prompt>" --max-coins <n> --user-approved')} first.`);
25123
+ log.dim(` Run ${c.cyan(rerun)} first.`);
25124
+ }
25125
+ process.exitCode = 1;
25126
+ return;
25127
+ }
25128
+ if (saved.kind === "coins") {
25129
+ const old = saved.record;
25130
+ const legacy = {
25131
+ p50: old.p50 ?? null,
25132
+ p95: old.p95 ?? null,
25133
+ max: old.max ?? null,
25134
+ recommendedEstimateCoins: old.recommendedEstimateCoins ?? null,
25135
+ recommendedPerCallMaxCoins: old.recommendedPerCallMaxCoins ?? null,
25136
+ maxCoinsPerSample: old.maxCoinsPerSample ?? null,
25137
+ noRecommendationReason: old.noRecommendationReason ?? null
25138
+ };
25139
+ if (opts.json) {
25140
+ writeJsonLine({
25141
+ command: "llm price",
25142
+ status: "failed",
25143
+ error: "coin_terms",
25144
+ currency: "coins",
25145
+ ranAt: old.ranAt ?? null,
25146
+ modelId: old.modelId ?? null,
25147
+ samples: old.samples ?? null,
25148
+ completed: old.completed ?? null,
25149
+ ...creditKeys(null),
25150
+ legacyCoinTerms: legacy,
25151
+ savedTo: LLM_BENCH_FILE
25152
+ });
25153
+ } else {
25154
+ log.plain(c.bold("genex llm price"));
25155
+ log.dim(` from ${LLM_BENCH_FILE}, benchmarked ${old.ranAt ?? "at an unknown time"}${old.modelId ? ` on ${old.modelId}` : ""}`);
25156
+ log.plain("");
25157
+ log.warn(` ${LLM_PRICE_COIN_TERMS_LINE}`);
25158
+ const measured = legacy.p95 === null ? "no settled sample" : `charged coins p50 ${legacy.p50} \xB7 p95 ${legacy.p95} \xB7 max ${legacy.max}`;
25159
+ const recommended = legacy.recommendedEstimateCoins === null ? "no price recommended" : `it recommended estimateCoins ${legacy.recommendedEstimateCoins}${legacy.recommendedPerCallMaxCoins != null ? ` and perCallMaxCoins ${legacy.recommendedPerCallMaxCoins}` : ""}`;
25160
+ log.dim(` What it measured then: ${measured}; ${recommended}.`);
25161
+ log.dim(` Re-run: ${c.cyan(rerun)}`);
24438
25162
  }
24439
25163
  process.exitCode = 1;
24440
25164
  return;
24441
25165
  }
25166
+ const record = saved.record;
24442
25167
  if (opts.json) {
24443
25168
  writeJsonLine({
24444
25169
  command: "llm price",
24445
- status: record.recommendedEstimateCoins === null ? "failed" : "ok",
24446
- error: record.recommendedEstimateCoins === null ? record.noRecommendationReason ?? "no_recommendation" : null,
25170
+ status: record.recommendedMaxCredits === null ? "failed" : "ok",
25171
+ error: record.recommendedMaxCredits === null ? record.noRecommendationReason ?? "no_recommendation" : null,
25172
+ currency: "credits",
24447
25173
  ranAt: record.ranAt ?? null,
24448
25174
  modelId: record.modelId ?? null,
24449
25175
  samples: record.samples ?? null,
24450
25176
  completed: record.completed ?? null,
24451
- p50: record.p50 ?? null,
24452
- p95: record.p95 ?? null,
24453
- max: record.max ?? null,
24454
- // Absent from a file an older CLI wrote; null / false / 0 then, never missing.
24455
- fitP95: record.fitP95 ?? null,
24456
- chargedBasedEstimateCoins: record.chargedBasedEstimateCoins ?? null,
24457
- lengthRaisedPrice: record.lengthRaisedPrice ?? false,
24458
- cutOffSamples: record.cutOffSamples ?? 0,
24459
- noRecommendationReason: record.noRecommendationReason ?? null,
24460
- recommendedDeclaredHeadroomBps: record.recommendedDeclaredHeadroomBps ?? null,
24461
- recommendedEstimateCoins: record.recommendedEstimateCoins ?? null,
24462
- recommendedPerCallMaxCoins: record.recommendedPerCallMaxCoins ?? null,
25177
+ ...creditKeys(record),
25178
+ legacyCoinTerms: null,
24463
25179
  savedTo: LLM_BENCH_FILE
24464
25180
  });
24465
- if (record.recommendedEstimateCoins === null) process.exitCode = 1;
25181
+ if (record.recommendedMaxCredits === null) process.exitCode = 1;
24466
25182
  return;
24467
25183
  }
24468
25184
  log.plain(c.bold("genex llm price"));
24469
25185
  log.dim(` from ${LLM_BENCH_FILE}, benchmarked ${record.ranAt}`);
24470
25186
  log.plain("");
24471
25187
  printRecommendation(log, record);
24472
- if (record.recommendedEstimateCoins === null) process.exitCode = 1;
25188
+ if (record.recommendedMaxCredits === null) process.exitCode = 1;
24473
25189
  }
24474
25190
  function writeJsonLine(value) {
24475
25191
  process.stdout.write(`${JSON.stringify(value)}
@@ -24729,7 +25445,7 @@ function runtimeRow(state, lane, toolsOnly = false) {
24729
25445
  return {
24730
25446
  label: "Runtime",
24731
25447
  value: `in-game LLM lane live \xB7 ${n} model${n === 1 ? "" : "s"}${keyRefused ? ` \xB7 ${c.yellow("provider key refused")}` : ""}`,
24732
- fix: keyRefused ? LLM_PROVIDER_KEY_REJECTED_LINE : toolsOnly ? convertFix : 'Price a call before a game declares one: `npx genex llm bench "<prompt>" --max-coins <n> --user-approved`.'
25448
+ fix: keyRefused ? LLM_PROVIDER_KEY_REJECTED_LINE : toolsOnly ? convertFix : 'Measure a call before a game declares its per-call ceiling: `npx genex llm bench "<prompt>" --max-credits <n> --user-approved`.'
24733
25449
  };
24734
25450
  }
24735
25451
  case "off":
@@ -26685,16 +27401,16 @@ ${c.bold("Usage")}
26685
27401
  status | cancel. "models" says whether this
26686
27402
  stand serves the runtime LLM lane at all, and
26687
27403
  at what provider rates. "bench" runs the real
26688
- model on YOUR OWN coin and reports what each
26689
- attempt was charged; it needs --max-coins <n>
27404
+ model on YOUR OWN credits and reports what each
27405
+ attempt really cost; it needs --max-credits <n>
26690
27406
  --user-approved, like any spend the player has
26691
27407
  to agree to. "price" reprints the last run's
26692
- recommended estimateCoins. "status" lists this
27408
+ recommended maxCredits. "status" lists this
26693
27409
  project's open calls and the slots they hold;
26694
27410
  "cancel <id>" stops an active one. Declare a
26695
- game's price from a bench, never from a guess:
26696
- a started attempt is charged in full, including
26697
- one that fails.
27411
+ game's per-call ceiling from a bench, never
27412
+ from a guess: the player is billed as used, and
27413
+ the ceiling also sizes how long an answer may be.
26698
27414
  genex player <sub> [options] Answer the in-game model requests you approve, on your
26699
27415
  OWN Claude or ChatGPT subscription: install | run |
26700
27416
  status | stop | uninstall. "install" signs this machine
@@ -27149,7 +27865,7 @@ ${c.bold("Examples")}
27149
27865
  genex shop remove sku_123
27150
27866
  genex llm models
27151
27867
  genex llm models --all
27152
- genex llm bench "Reply with one short taunt." --samples 5 --max-coins 20 --user-approved
27868
+ genex llm bench "Reply with one short taunt." --samples 5 --max-credits 20 --user-approved
27153
27869
  genex llm price
27154
27870
  genex llm status
27155
27871
  genex llm cancel <id>
@@ -27386,22 +28102,24 @@ ${c.bold("How the allowance works")}
27386
28102
 
27387
28103
  Every number printed is live from your account; prices can change without a deploy.
27388
28104
  `,
27389
- llm: `${c.bold("genex llm")} \u2014 in-game model calls: is the lane live, what does one call cost, what should the game declare?
28105
+ llm: `${c.bold("genex llm")} \u2014 in-game model calls: is the lane live, what does one call cost, what ceiling should the game declare?
27390
28106
 
27391
28107
  ${c.bold("Usage")}
27392
28108
  genex llm models [--all] [--json] Which models this stand serves, their provider rates per
27393
- million tokens, and the headroom it recommends over a
27394
- benchmark. The catalog is synced from OpenRouter, so the
27395
- FEATURED short list is printed by default and --all
27396
- prints every row (--json always carries every row). Off
27397
- on this stand: it says so and exits 0 \u2014 build the feature
27398
- without a model rather than promising a 404.
27399
- genex llm bench "<prompt>" --max-coins <n> --user-approved [options]
27400
- Run the real model <n> times through the development
27401
- lane and report what each attempt was CHARGED. Refused
27402
- without both flags, before any network call: this spends
27403
- YOUR OWN COIN, a wallet the build's asset allowance
27404
- cannot see. Needs a folder linked to a game you own.
28109
+ million tokens, the platform fee a call is billed at, and
28110
+ the headroom it recommends over a benchmark. The catalog
28111
+ is synced from OpenRouter, so the FEATURED short list is
28112
+ printed by default and --all prints every row (--json
28113
+ always carries every row). Off on this stand: it says so
28114
+ and exits 0 \u2014 build the feature without a model rather
28115
+ than promising a 404.
28116
+ genex llm bench "<prompt>" --max-credits <n> --user-approved [options]
28117
+ Run the real model through the development lane and
28118
+ report what each attempt really COST and was charged.
28119
+ Refused without both flags, before any network call:
28120
+ this spends YOUR OWN CREDITS, a spend the build's asset
28121
+ allowance does not count. Needs a folder linked to a
28122
+ game you own, and a balance that covers one attempt.
27405
28123
  genex llm price [--json] The recommendation from the last bench in this folder.
27406
28124
  Reads a file; spends nothing.
27407
28125
  genex llm status [--json] This project's open development calls: active, or
@@ -27418,29 +28136,32 @@ ${c.bold("Bench options")}
27418
28136
  --samples <n> How many real attempts (default ${DEFAULT_BENCH_SAMPLES}). More samples, better p95.
27419
28137
  --schema <file> A JSON file holding the output schema; implies JSON output.
27420
28138
  --json-output Ask for JSON without a schema. --text Ask for text (the default).
27421
- --max-coins <n> The per-attempt ceiling YOU approved \u2014 a bench runs on your own coin,
28139
+ --max-credits <n> The per-attempt ceiling YOU approved \u2014 a bench runs on your own credits,
27422
28140
  never a player's. The run's worst case is that number times --samples,
27423
- and it is printed before anything starts.
28141
+ and it is printed before anything starts. (--max-coins is its deprecated
28142
+ name, read as the same number.)
27424
28143
  --timeout <seconds> Bounds the wait for each attempt.
27425
28144
  --json One machine-readable object; every key is always present, null when
27426
28145
  the CLI could not learn it.
27427
28146
 
27428
28147
  ${c.bold("Why a benchmark and not an estimate")}
27429
- \`estimateCoins\` is a PRICE, not a guess: declaring it charges it, in full, even when the
27430
- attempt fails, is cancelled, or stops at its budget. And three layers sit between the
27431
- provider's rate card and that number \u2014 the provider's cost, the platform's tariff (already
27432
- inside every charged coin), and your headroom. The bench itself is billed to YOU, on the
27433
- development lane; the player-funded lane is what the published game uses. Adding a margin
27434
- to the provider's rate silently drops the tariff and under-prices every call, so the
27435
- recommendation is built from CHARGED COINS and the headroom comes from the server. A stand
27436
- that serves no headroom gets no recommendation, because the multiplier is not the CLI's to
27437
- invent.
28148
+ An in-game call is paid from the PLAYER's credits and billed as used: its real provider cost
28149
+ plus the platform fee, never more than the per-call CEILING the game declares (\`maxCredits\`,
28150
+ or \`perCallMaxCredits\` on a standing budget). The ceiling is not a price, but it is still a
28151
+ number with two jobs: it bounds what one call may cost, and it funds how LONG the answer may
28152
+ be. Three layers sit between the provider's rate card and it \u2014 the provider's cost for YOUR
28153
+ prompt, the platform fee, and your headroom \u2014 and only a real call measures the first. The
28154
+ bench is billed to YOU, on the development lane; the player-funded lane is what the
28155
+ published game uses. The recommendation is the p95 real cost with the server's fee and
28156
+ headroom applied, in whole credits; a stand that serves no headroom or no fee gets no
28157
+ recommendation, because those multipliers are not the CLI's to invent.
27438
28158
 
27439
- The declared price also decides how LONG the answer may be: it sizes each call's output
27440
- allowance. So the recommendation is never below the smallest price that leaves room for
27441
- the benchmarked answer, as the server computes it. A sample cut off at your --max-coins
27442
- (provider_token_limit) is not a sample \u2014 re-run with a higher --max-coins; an answer longer
27443
- than one call on this stand may produce gets no price at all \u2014 ask for a shorter one.
28159
+ Because the ceiling sizes each call's output allowance, the recommendation is never below
28160
+ the smallest ceiling that leaves room for the benchmarked answer, as the server computes it.
28161
+ A sample cut off at your --max-credits (provider_token_limit) is not a sample \u2014 re-run with a
28162
+ higher --max-credits; an answer longer than one call on this stand may produce gets no
28163
+ ceiling at all \u2014 ask for a shorter one. The run also prints the measured average per call,
28164
+ fee included, which is the honest estimate a budget's disclosure is built on.
27444
28165
 
27445
28166
  Results land in ${LLM_BENCH_FILE} \u2014 gitignored, machine-local, re-read by \`genex llm price\`.
27446
28167
  `,
@@ -27655,11 +28376,13 @@ function parseArgs(argv) {
27655
28376
  "--limit",
27656
28377
  // `genex budget --assets <credits>` — the allowance the player approved.
27657
28378
  "--assets",
27658
- // `genex llm bench` value flags. `--max-coins` is the COIN half of the
27659
- // same approval shape as `--assets` (a different economy, its own gate).
28379
+ // `genex llm bench` value flags. `--max-credits` is the same approval
28380
+ // shape as `--assets`, but its own gate: the asset allowance does not
28381
+ // count a bench. `--max-coins` is its deprecated name (same number).
27660
28382
  "--model",
27661
28383
  "--samples",
27662
28384
  "--schema",
28385
+ "--max-credits",
27663
28386
  "--max-coins",
27664
28387
  // `genex player install --label <name>` — what this machine is called in
27665
28388
  // the dashboard. Its own key rather than the generic 2nd positional,
@@ -28174,12 +28897,14 @@ function applyValueFlag(options, flag, value) {
28174
28897
  options.samples = n;
28175
28898
  break;
28176
28899
  }
28900
+ case "--max-credits":
28177
28901
  case "--max-coins": {
28178
28902
  const n = Number(value);
28179
28903
  if (!Number.isInteger(n) || n < 1) {
28180
- throw new Error(`Invalid --max-coins value: ${value} (whole coin, 1 or more)`);
28904
+ throw new Error(`Invalid ${flag} value: ${value} (whole credits, 1 or more)`);
28181
28905
  }
28182
- options.maxCoins = n;
28906
+ if (flag === "--max-credits") options.maxCredits = n;
28907
+ else options.maxCoins = n;
28183
28908
  break;
28184
28909
  }
28185
28910
  case "--timeout": {
@@ -28471,9 +29196,9 @@ async function main() {
28471
29196
  await runBudget(parsed.options);
28472
29197
  break;
28473
29198
  // The in-game model lane: is it live here, what does one call really
28474
- // cost, what should the game declare. `bench` is the only CLI command
28475
- // that spends COIN, so it carries its own --max-coins/--user-approved
28476
- // gate — the credits allowance cannot see that wallet.
29199
+ // cost, what ceiling should the game declare. `bench` spends the
29200
+ // builder's own credits OUTSIDE the asset allowance, so it carries its
29201
+ // own --max-credits/--user-approved gate.
28477
29202
  case "llm":
28478
29203
  await runLlm(parsed.options);
28479
29204
  break;