@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/{blender-mcp-QVEGHE5L.js → blender-mcp-WSURPZET.js} +2 -2
- package/dist/{blender-serve-66OHC4ML.js → blender-serve-EH2MOW3U.js} +1 -1
- package/dist/{chunk-4BBTPRJW.js → chunk-EABIVAGT.js} +2 -2
- package/dist/{chunk-QI7FIYBY.js → chunk-K4EHXOZK.js} +1 -1
- package/dist/index.js +1301 -576
- package/package.json +1 -1
- package/templates/controllers/character/aim-cue.ts +2 -1
- package/templates/controllers/character/follow-camera.ts +4 -3
- package/templates/skills/genex-ai-video/SKILL.md +8 -3
- package/templates/skills/genex-game-director/SKILL.md +2 -1
- package/templates/skills/genex-getting-started/SKILL.md +2 -2
- package/templates/skills/genex-llm-in-games/SKILL.md +219 -73
- package/templates/skills/genex-llm-in-games/references/pricing.md +126 -85
- package/templates/skills/genex-monetization/SKILL.md +26 -0
- package/templates/skills/genex-threejs-camera-direction/SKILL.md +3 -3
- package/templates/skills/genex-threejs-character-controller/references/wiring.md +2 -2
- package/templates/skills/genex-threejs-embed-auth/SKILL.md +158 -36
- package/templates/skills/genex-threejs-embed-auth/references/shells.md +207 -0
- package/templates/skills/genex-threejs-game-ui/SKILL.md +22 -14
- package/templates/skills/genex-threejs-multiplayer/SKILL.md +46 -8
- package/templates/skills/genex-threejs-touch-controls/SKILL.md +63 -1
- package/templates/skills/genex-tool-llm/SKILL.md +11 -9
- package/templates/skills/genex-updates/SKILL.md +1 -1
package/dist/index.js
CHANGED
|
@@ -57,7 +57,7 @@ import {
|
|
|
57
57
|
writeSecretFile,
|
|
58
58
|
writeUserToken,
|
|
59
59
|
writeWorkspace
|
|
60
|
-
} from "./chunk-
|
|
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-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
3365
|
+
if (s.motionNoPermission.length) {
|
|
2645
3366
|
log.warn(
|
|
2646
|
-
|
|
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 (
|
|
3159
|
-
|
|
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 (
|
|
3163
|
-
|
|
3164
|
-
|
|
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
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
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
|
-
//
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
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
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
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
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
23535
|
-
|
|
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
|
-
|
|
23538
|
-
|
|
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
|
|
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(
|
|
23576
|
-
fail5("llm bench", `--max-
|
|
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-
|
|
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
|
|
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(`
|
|
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
|
|
23769
|
-
log.dim(`
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
23794
|
-
const settled = results.filter(
|
|
23795
|
-
|
|
23796
|
-
|
|
23797
|
-
const
|
|
23798
|
-
const
|
|
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
|
|
23805
|
-
const
|
|
23806
|
-
const
|
|
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
|
-
|
|
23810
|
-
|
|
23811
|
-
|
|
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
|
-
|
|
23816
|
-
|
|
23817
|
-
|
|
23818
|
-
|
|
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
|
|
23825
|
-
var LLM_EXCEEDS_STAND_CAP_LINE = "No
|
|
23826
|
-
function benchCutOffLine(cutOff,
|
|
23827
|
-
return `No
|
|
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
|
|
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,
|
|
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
|
|
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 ${
|
|
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}
|
|
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
|
-
|
|
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} ${
|
|
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
|
|
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
|
|
23987
|
-
|
|
23988
|
-
|
|
23989
|
-
|
|
23990
|
-
|
|
23991
|
-
const
|
|
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
|
|
24619
|
+
const balanceAfter = (await fetchCreditsSnapshot(apiUrl, token))?.spendable ?? null;
|
|
24620
|
+
const picosOrNull = (v) => v === null ? null : String(v);
|
|
23994
24621
|
const record = {
|
|
23995
|
-
v:
|
|
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
|
-
|
|
24005
|
-
|
|
24006
|
-
|
|
24007
|
-
max,
|
|
24008
|
-
|
|
24009
|
-
|
|
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
|
-
|
|
24015
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
24042
|
-
//
|
|
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
|
-
|
|
24045
|
-
|
|
24046
|
-
|
|
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
|
-
|
|
24051
|
-
|
|
24052
|
-
|
|
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 (
|
|
24061
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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}
|
|
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
|
|
24744
|
+
log.warn(` ${benchCutOffLine(record.cutOffSamples || 1, record.maxCreditsPerSample)}`);
|
|
24093
24745
|
return;
|
|
24094
24746
|
}
|
|
24095
|
-
|
|
24096
|
-
|
|
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" : "
|
|
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
|
|
24104
|
-
if (record.
|
|
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
|
|
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
|
-
`
|
|
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
|
|
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 ${
|
|
24770
|
+
log.dim(` measured over ${attempts}.`);
|
|
24117
24771
|
}
|
|
24118
|
-
log.dim(" That number is a
|
|
24119
|
-
|
|
24120
|
-
|
|
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.
|
|
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
|
-
|
|
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))
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
24886
|
+
maxCreditsPerSample: maxCredits,
|
|
24228
24887
|
balanceBefore: null,
|
|
24229
24888
|
balanceAfter: null,
|
|
24230
24889
|
results: [],
|
|
24231
24890
|
refusal: null,
|
|
24232
|
-
|
|
24233
|
-
|
|
24234
|
-
|
|
24235
|
-
|
|
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
|
-
|
|
24240
|
-
|
|
24241
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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(`
|
|
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
|
|
24406
|
-
let
|
|
25073
|
+
async function readSavedBench(cwd) {
|
|
25074
|
+
let parsed;
|
|
24407
25075
|
try {
|
|
24408
|
-
|
|
25076
|
+
parsed = JSON.parse(await fs35.readFile(path36.join(cwd, LLM_BENCH_FILE), "utf8"));
|
|
24409
25077
|
} catch {
|
|
24410
|
-
|
|
25078
|
+
return null;
|
|
24411
25079
|
}
|
|
24412
|
-
if (!
|
|
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
|
-
|
|
24423
|
-
|
|
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(
|
|
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.
|
|
24446
|
-
error: record.
|
|
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
|
-
|
|
24452
|
-
|
|
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.
|
|
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.
|
|
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 : '
|
|
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
|
|
26689
|
-
attempt
|
|
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
|
|
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
|
|
26696
|
-
a
|
|
26697
|
-
|
|
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-
|
|
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,
|
|
27394
|
-
|
|
27395
|
-
|
|
27396
|
-
|
|
27397
|
-
on this stand: it says so
|
|
27398
|
-
|
|
27399
|
-
|
|
27400
|
-
|
|
27401
|
-
|
|
27402
|
-
|
|
27403
|
-
|
|
27404
|
-
|
|
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-
|
|
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
|
-
|
|
27430
|
-
|
|
27431
|
-
|
|
27432
|
-
|
|
27433
|
-
|
|
27434
|
-
|
|
27435
|
-
|
|
27436
|
-
|
|
27437
|
-
|
|
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
|
-
|
|
27440
|
-
|
|
27441
|
-
|
|
27442
|
-
|
|
27443
|
-
|
|
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-
|
|
27659
|
-
//
|
|
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
|
|
28904
|
+
throw new Error(`Invalid ${flag} value: ${value} (whole credits, 1 or more)`);
|
|
28181
28905
|
}
|
|
28182
|
-
options.
|
|
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`
|
|
28475
|
-
//
|
|
28476
|
-
//
|
|
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;
|